yuoj线上判断系统
// 获取登录用户信息
const loginUser: LoginUserVO = computed(
() => store.state.user?.loginUser
// eslint-disable-next-line no-undef
) as LoginUserVO;
获取用户登录信息
<template
v-if="loginUser && loginUser.userRole !== AccessEnum.NOT_LOGIN"
>
<template v-if="loginUser.userAvatar">
<a-avatar shape="circle" :image-url="loginUser.userAvatar">
</a-avatar>
</template>
<template v-else>
<a-avatar shape="circle">
<IconUser />
</a-avatar>
</template>
</template>
<template v-else>
<a-avatar shape="circle" :style="{ backgroundColor: '#3370ff' }">
<IconUser />
</a-avatar>
</template>
前端和后端是通过 接口/请求 来连接
安装请求axios 文档 起步 | Axios 中文文档 | Axios 中文网 (axios-http.cn)
编写调用后端的代码
传统情况下 每个请求都要单独编写代码 至少得写一个 请求路径
直接使用 生成 Service 代码。 直接调用函数发送请求即可
openapi --input http://localhost:8121/api/v2/api-docs --output ./generated --client axios
注意- 用户登陆后仍显示未登录
是因为 OpenApi.ts 文件下的WITH_CREDENTIALS 赋值未改成True
export const OpenAPI: OpenAPIConfig = {
BASE: 'http://localhost:8121',
VERSION: '1.0',
WITH_CREDENTIALS: true,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};
比如 获取用户登录信息
actions: {
async getLoginUser({ commit, state }, payload) {
// 从远程获取用户登录信息
const res = await UserControllerService.getLoginUserUsingGet();
if (res.code === 0) {
commit("updateUser", res.data);
} else {
commit("updateUser", {
...state.loginUser,
userRole: ACCESS_ENUM.NOT_LOGIN,
});
}
},
},
如果想要自定义参数 怎么办?
修改使用代码生成器 提供的全局参数修改对象
export const OpenAPI: OpenAPIConfig = {
BASE: 'http://localhost:8121',
VERSION: '1.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'include',
TOKEN: undefined,
USERNAME: undefined,
PASSWORD: undefined,
HEADERS: undefined,
ENCODE_PATH: undefined,
};
直接定义 axios 请求库的全局参数, 比如全局请求响应拦截器
在 src 下新建包 plugins
import axios from "axios";
axios.interceptors.response.use(
function (response) {
console.log("响应", response);
// 2xx 范围内的状态码都会触发该函数。
// 对响应数据做点什么
return response;
},
function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error);
}
);
在 store\user.ts 编写远程登录用户信息的代码
actions: {
async getLoginUser({ commit, state }, payload) {
// 从远程获取用户登录信息
const res = await UserControllerService.getLoginUserUsingGet();
if (res.code === 0) {
commit("updateUser", res.data);
} else {
commit("updateUser", {
...state.loginUser,
userRole: ACCESS_ENUM.NOT_LOGIN,
});
}
},
},
在哪里去触发一下 getLoginUser 的函数执行?
应当在一个全局的位置
有很多选择:
新建 access\index 文件,把原有的路由拦截,权限校验逻辑放在独立的文件中
编写权限管理和自动登录的逻辑
router.beforeEach(async (to, from, next) => {
// 登录的数据 权限信息
const loginUser = store.state.user.loginUser;
// 如果 之前没登陆过 自动登录
if (!loginUser || !loginUser.userRole) {
//
await store.dispatch("user/userLogin");
}
const needAccess = to.meta?.access ?? ACCESS_ENUM.NOT_LOGIN;
// 要跳转的页面必须要登录
if (needAccess !== ACCESS_ENUM.NOT_LOGIN) {
// 如果没有登录 则跳转到登录页面
if (!needAccess) {
next("/user/login?redirect=${to.fullPath}");
return;
}
//如果已经登录了 但是权限不足 则跳转到无权限页面
if (!checkAccess(loginUser, needAccess)) {
next("/noAuth");
return;
}
}
next();
});
在 routes 路由文件中新建一套用户路由 使用 vue-router 自带的子路由机制 实现布局和路由嵌套
{
path: "/user",
name: "用户",
component: UserLayout,
children: [
{
path: "/user/login",
name: "用户登录",
component: UserLoginView,
},
{
path: "/user/register",
name: "用户注册",
component: UserRegisterView,
},
],
},
新建一套 UserLayout UserLoginView UserRegisterView 页面,并且在 routes 中引入
在 app.vue 根页面文件 根据路由去区分多套布局
<div id="app">
<template v-if="route.path.startsWith('/user')">
<router-view />
</template>
<template v-else>
<BasicLayout />
</template>
</div>
将判题输入用例,输出用例等 存入json对象
好处是: 便于扩展 只需要改变对象内部的字段 ,而不去修改数据库表(可能会影响数据库)
judgeConfig 判题配置 (json对象):
judgeCase 判题用例(json 数组)
每一个元素包含一个输入用例和一个相对应的输出用例
[
{
"input": "1,2",
"output": "3,4"
},
{
"input": "1,5",
"output": "3,4"
}
]
judgeConfig
{
"timeLimit" :1000,
"memoryLimit": 1000,
"stackLimit":1000
}
存 json 的前提:
题目表
-- 题目表
create table if not exists post
(
id bigint auto_increment comment 'id' primary key,
title varchar(512) null comment '标题',
content text null comment '内容',
tags varchar(1024) null comment '标签列表(json 数组)',
answer text null comment '题目答案',
submitNum int default 0 not null comment '题目提交数',
acceptedNum int default 0 not null comment '题目通过数',
judgeCase text null comment '判题用例 (json 数组)',
judgeConfig text null comment '判题配置 (json 对象)',
thumbNum int default 0 not null comment '点赞数',
favourNum int default 0 not null comment '收藏数',
userId bigint not null comment '创建用户 id',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_userId (userId)
) comment '帖子' collate = utf8mb4_unicode_ci;
哪儿个用户提交了哪道题目,存放判题结果等
提交用户 id : userId
题目 id : questionId
语言: language
用户的代码: code
判题状态: status(0-待判题,1-判题中,2-成功,3- 失败)
判题信息(判题过程中得到的一些信息, 比如程序的失败原因,程序执行消耗的空间 时间):judgeInfo(Json对象)
-- 题目提交表
create table if not exists question_submit
(
id bigint auto_increment comment 'id' primary key,
language varchar(128) not null comment '编程语言',
code text not null comment '用户代码',
judgeInfo text null comment '判题信息(json 数组)',
status int default 0 not null comment '(0-待判题,1-判题中,2-成功,3- 失败)',
questionId bigint not null comment '题目 Id',
userId bigint not null comment '创建用户 id',
createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
isDelete tinyint default 0 not null comment '是否删除',
index idx_questionId (questionId),
index idx_userId (userId)
) comment '题目提交表';
{
"message": "程序执行信息",
"time": 1000, // ms
"memory": 1000 //kb
}
判断信息枚举值:
小知识 - 数据库索引
什么情况下适合加索引? 如何选择给哪个字段加索引
答: 首先从业务出发,无论是单个索引,还是联合索引,都要从实际的查询语句,字段枚举值的区分度,字段的类型考虑 (where条件的指定字段)
比如 select * from 表 where userId= ? and questionId=?
可以根据userId 和 questionId 分别建立索引(需要分别根据这两个字段单独查询) ; 也可以选择给这两个字段建立联合索引(所查询的字段总是捆绑在一起的)
原则上: 能不用索引就不用索引; 能用单个索引就不要用联合/多个索引。 不要给没区分度的字段加索引 (比如性别 就 男 或者 女) 因为索引也是要占用空间的
1)安装 mybatisX 插件
2)根据项目去调整生成配置,建议生成代码到独立的包 不要影响老的项目
3) 把代码从生成包中转移到实际项目包中
4)找相似的代码去复制 Controller
5)复制实体类相关的DTO、VO、枚举字段(用于接受前端请求,或者业务间的传递信息)复制之后,调整需要的字段
updateRequest 和 editRequest 的区别: 前者是给管理员更新用的,可以指定更多的字段; 后者是给普通用户用的,只能指定部分的字段
6)为了更方便地处理 json字段中的某个字段,需要给对应的json 字段编写独立的类。比如 judgeCase judgeInfo judgeConfig 示例代码:
/**
* 题目用例
*/
@Data
public class JudgeCase {
/**
* 输入用例
*/
private String input;
/**
* 输出用例
*/
private String output;
}
DTO: 业务层面的封装类
什么情况下要加业务前缀? 什么情况下不加?
加业务前缀的好处: 能防止多个表都有类似的类。产生冲突;不加的前提:因为可能这个类是多个业务之间共享的 能够复用的
定义 VO 类: 作用是专门给前端返回对象,可以节约网络传输大小、或者过滤字段(脱敏) 保证安全性
比如: judgeCase answer 这些字段是不允许直接让用户看到的
脱敏:是屏蔽敏感数据,对某些敏感信息(比如,身份证号、手机号、卡号、客户姓名、客户地址、邮箱地址、薪资等等 )通过脱敏规则进行数据的变形,实现隐私数据的可靠保护。****
7) 校验Controller 层的代码, 看看除了要调用的方法缺失外,还有无报错
8) 实现 Service 层的代码 从对应的已经编写号的实现类复制粘贴 全局替换(比如 question => post)
9) 编写QuestionVO 的 json / 对象转换工具类
10)用同样的方法,编写 questionSubmit 提交类, 这次参考 postthumb 相关文件
11)编写枚举类
编写好基本代码后, 通过 Swagger 或者编写单元测试去验证
小知识
为了防止用户按照 id 顺序爬取题目, 建议把id 的生成规则改为 ASSIGN_ID 而不是 从1 开始自增的 AUTO
/**
* id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;
(Integer不穿可以用默认)
功能: 能够根据用户id,或者题目id,编程语言,题目状态,去查询提交记录
注意事项: 仅本人和管理员能看见自己(提交的 userId 和登录用户 id 不同的)提交代码的答案、提交代码
实现方案: 先查询 再根据权限去脱敏
核心代码:
@Override
public QuestionSubmitVO getQuestionSubmitVO(QuestionSubmit questionSubmit, User loginUser) {
QuestionSubmitVO questionSubmitVO = QuestionSubmitVO.objToVo(questionSubmit);
//脱敏:仅本人和管理员能看见自己(提交的 userId 和登录用户 id 不同的)提交代码
Long userId = loginUser.getId();
//处理脱敏
if (userId != questionSubmit.getUserId() && !userService.isAdmin(loginUser)){
questionSubmitVO.setCode(null);
}
return questionSubmitVO;
}
以开发前端页面为主:
1)用户注册页面
2)创建题目页面(管理员)
3)题目管理页面(管理员)
4)题目列表页(用户)
5)题目详情页(在线做题页)
6)题目提交列表页
扩展: 提交统计页、用户个人页
先接入可能用到的组件,再去写页面,避免因为后续依赖冲突,整合组件失败带来的返工
为什么用 Markdown?
一套通用的文本编辑语法,可以在各大网站上统一标准 渲染出统一的样式 比较简单易学
推荐的Md编辑器 github-> bytemd
阅读官方文档,下载编辑器主题,以及gfm(表格支持)插件,highlight 代码高亮插件
新建MdEditor 组件,编写代码
<template>
<Editor :value="value" :plugins="plugins" @change="handleChange" />
</template>
<script setup lang="ts">
import gfm from "@bytemd/plugin-gfm";
import highlight from "@bytemd/plugin-highlight";
import { Editor, Viewer } from "@bytemd/vue-next";
import { ref, withDefaults, defineProps } from "vue";
interface Props {
value: string;
handleChange: (v: string) => void;
}
const plugins = [
gfm(),
highlight(),
// Add more plugins here
];
const props = withDefaults(defineProps<Props>(), {
value: () => "",
handleChange: (v: string) => {
console.log(v);
},
});
</script>
<style scoped
></style>
父组件给子组件传值的方式
要把 MdEditor 当前输入的值暴露给父组件,便于父组件去使用, 同时也是提高组件的通用性,需要定义属性,要把 value 和 handleChange 事件交给父组件去管理:
MdEditor示例代码
<template>
<Editor :value="value" :plugins="plugins" @change="handleChange" />
</template>
<script setup lang="ts">
import gfm from "@bytemd/plugin-gfm";
import highlight from "@bytemd/plugin-highlight";
import { Editor, Viewer } from "@bytemd/vue-next";
import { ref, withDefaults, defineProps } from "vue";
/**
* 定义组件类型
*/
interface Props {
value: string;
handleChange: (v: string) => void;
}
const plugins = [
gfm(),
highlight(),
// Add more plugins here
];
const props = withDefaults(defineProps<Props>(), {
value: () => "",
handleChange: (v: string) => {
console.log(v);
},
});
</script>
<style scoped></style>
微软官方: manaco-editor
1)安装编辑器
npm instal了monaco-editor
vue-cli 项目 (webpack 项目) 整合 monaco-editor:
先
npm install monaco-editor-webpack-plugin
const { defineConfig } = require("@vue/cli-service");
const MonacoWebPackPlugin = require("monaco-editor-webpack-plugin");
module.exports = defineConfig({
transpileDependencies: true,
chainWebpack(config) {
config.plugin(new MonacoWebPackPlugin({}));
},
});
Code编辑器,同 Md 编辑器一样 , 也要接受父组件的传值,把显示的输入交给父组件去控制。从而能够让父组件实时得到用户的输出代码
注意, monaco editor 在读写值的时候 要使用toRaw(编辑器实例) 的语法
重新根据后端生成前端请求代码
openapi --input http://localhost:8121/api/v2/api-docs --output ./generated --client axios
{
"answer": "暴力破解",
"content": "题目内容",
"judgeCase": [
{
"input": "1 2",
"output": "3 4"
}
],
"judgeConfig": {
"memoryLimit": 1000,
"stackLimit": 1000,
"timeLimit": 1000
},
"tags": ["栈","简单"],
"title": "A+B"
}
代码模板
在 JetBrains 系列编辑器中
file->setting-Live template 先创建一个模板组,在模板组下创建代码模板, 操作完之后直接输入模板名+tab键即可获得代码模板
示例模板:
<template>
<div id="$ID$"></div>
</template>
<script setup lang="ts">
$END$
</script>
<style scoped>
#$ID$ {
}
</style>
使用表单组件,先复制示例代码 再修改 arco-design的组件库
此处我们用到了嵌套表和动态增减表单
注意,我们自定义的代码编辑器组件不会被组件库识别,需要手动指定 value 和 handleChange 函数
使用表格组件 找到自定义操作的示例
查询数据
定义表格列
加载数据
调整格式
比如 json 不好看 有两种方法调整
添加删除 更新操作
删除后如何更新:可以使用 table 组件自带的 table methods
优化
策略: 由于更新和创建都是相同的表单,所以完全没必要开发/复制2遍, 可以直接复用创建页面。
关键实现: 如何区分两个页面
更新页面相比于创建页面, 多了2个改动:
1) 处理菜单项的权限控制和显示隐藏
通过 meta.hideInMenu 和 meta.access 属性控制
2) 管理页面分页问题的修复
核心实现: 在分页页号改变时, 触发 @page-change 事件,通过改变 searchParams 的值, 并且通过 watchEffect 监听 searchParams 的改便 (然后执行loadData 重新加载), 实现了页号变化时触发的重新加载
3) 修复刷新页面未登录问题
修改 access\index.ts 中的获取用户登录信息, 把登陆后的信息更新到 loginUser 变量上
let loginUser = store.state.user.loginUser;
// // 如果之前没登陆过,自动登录
if (!loginUser || !loginUser.userRole) {
// 加 await 是为了等用户登录成功之后,再执行后续的代码
await store.dispatch("user/getLoginUser");
loginUser = store.state.user.loginUser;
}
核心实现 表格组件
1) 复制管理题目的表格
2) 只保留需要的 columns 字段
3)自定义表格列的渲染
标签: 使用 tag组件
通过率: 自行计算
创建时间: 使用meoment 库进行 格式化渲染
操作按钮:跳转到做题页面 使用模板字符串的写法
4) 编写搜索表单, 使用 form 的 layout = inline 布局, 让用户的输入和 searchParam 同步, 并且给提交按钮绑定修改 searchParams 从而被 watchEffect 监听到,触发查询
1) 先定义动态参数路由,开启 props 为 true , 可以在页面的 props 中直接获取到动态参数 (题目 id)
{
path: "/view/question/:id",
name: "在线做题",
component: ViewQuestionsView,
props: true,
meta: {
access: ACCESS_ENUM.USER,
hideInMenu: true,
},
},
2) 定义布局: 左侧是题目信息, 右侧是代码编辑器
3) 左侧题目信息:
4) 使用 select 组件让用户选择编程语言
目的:跑通完整的业务流程
判题模块: 调用代码沙箱,把代码和输入交给代码沙箱去执行
代码沙箱: 只负责接受代码和输入, 返回编译运行的结果, 不负责判题 (代码沙箱 可作为一个独立的项目, 提供给其他需要执行代码项目去使用)
这两个模块完全解耦
思考:为什么代码沙箱需要接受和输出一组运行用例
前提: 我们的每一道题目有多组测试用例
如果是每个用例单独调用一次代码沙箱,会调用多次接口,需要多次网络传输,程序要多次编译,记录程序的执行状态(重复的代码 不重复编译)
这是一种很常见的性能优化方法!(批处理)
1) 定义代码沙箱的接口 提高通用性
之后我们的代码只需要调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不需要去修改名称了 便于扩展
扩展:可以增加一个查看代码沙箱状态的接口
2)定义多种不同的代码沙箱实现
示例代码沙箱:
远程代码沙箱:
第三方代码沙箱: github => go-judge
小知识 - LomBok Builder 注解
实体类加上:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {
private List<String> inputList;
private String code ;
private String language;
}
可以使用链式的方式 很方便的给对象赋值
3)
@Test
void executeCode(){
CodeSandBox codeSandBox = new ExampleCodeSandBox();
String code = "int main(){}";
String language = QuestionSubmitLanguageEnum.JAVA.getValue();
List<String> inputList = Arrays.asList("1 2","3 4");
ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
.code(code)
.language(language)
.inputList(inputList)
.build();
ExecuteCodeResponse executeCodeResponse = codeSandBox.executeCode(executeCodeRequest);
Assertions.assertNotNull(executeCodeResponse);
}
现在的问题是, 我们把 new 某个沙箱代码写死了, 如果后面项目要改用其他沙箱, 可能要改很多地方的代码
4) 使用工厂模式, 传入用户的字符串参数,来生成对应的代码沙箱实现类
此处用的时静态工厂模式 使用简单 符合需求
/**
* 代码沙箱工厂
* 作用: 根据字符串创建指定的代码沙箱示例
*/
public class CodeSandBoxFactory {
/**
* 闯将代码沙箱示例
* @param type
* @return
*/
public static CodeSandBox newInstance(String type){
switch (type){
case "example":
return new ExampleCodeSandBox();
case "remote":
return new RemoteCodeSandBox();
case "thirdParty":
return new ThirdPartyCodeSandBox();
default:
return new ExampleCodeSandBox();
}
}
}
扩展思路: 如果确定代码沙箱不会出现线程安全问题, 可复用 那么可以使用单例工厂模式
5) 参数配置化,把项目中的一些可以交给用户去自定义的选项或者字符串,写到配置文件中。这样开发者只需要改配置文件,而不是去看你的项目代码,就能够自定义你项目的参数配置
application.yml 配置文件中配置
# 代码沙箱配置
codesandbox:
type: example
在 spring 的 bean 中通过 @Value 注解读取
@Value("${codesandbox.type:example}")
private String type;
6) 代码沙箱能力增强
比如: 我们需要在调用代码沙箱前,输出请求参数的日志; 在代码沙箱调用后,输出响应结果日志,便于管理员去分析
显然 每一个代码沙箱类的前后 写一个 log.info 是不合理的 这里引入代理模式
使用代理模式, 提供一个 proxy 来增强代码沙箱的能力
使用代理后: 不仅不用改变原本的代码沙箱实现类, 而且对调用这来说,调用机会方式没有改变,也不需要在每个调用代码沙箱的地方去写统计代码。
7)实现示例的代码沙箱
/**
* 示例代码沙箱 (仅为了跑通业务流程)
*/
public class ExampleCodeSandBox implements CodeSandBox {
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
List<String> inputList = executeCodeRequest.getInputList();
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
executeCodeResponse.setOutputlist(inputList);
executeCodeResponse.setMessage("测试执行成功");
executeCodeResponse.setStatus(QuestionSubmitStatusEnum.SUCCEED.getValue());
JudgeInfo judgeInfo = new JudgeInfo();
judgeInfo.setMessage(JudgeInfoMessageEnum.ACCEPTED.getText());
judgeInfo.setMemory(100L);
judgeInfo.setTime(100L);
executeCodeResponse.setJudgeInfo(judgeInfo);
return null;
}
}
判题服务业务流程:
判断逻辑:
我们的判题策略会有很多种, 比如:我i们的代码沙箱本身执行程序索要花费的事件 这个时间不同的编程语言时不同的,
我们可以采用策略模式, 针对不同的情况, 定义独立的策略, 而不是把所有的 if else 代码全部混在一提起
首先编写默认判题模块
如果选择某种判题策略过程比较复杂, 都写在调用判题服务的代码中,代码会越来越复杂, 会有很多的 if---*--else 所以 建议单独编写写一个类
/**
* 判题管理 (简化调用)
*/
@Service
public class JudgeManager {
/**
* 执行判题
* @param judgeContext
* @return
*/
JudgeInfo doJudge(JudgeContext judgeContext){
QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();
String language = questionSubmit.getLanguage();
JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
if ("java".equals(language)){
judgeStrategy= new JavaLanguageJudgeStrategy();
}
return judgeStrategy.doJudge(judgeContext);
}
}
定义 JudgeManager 目的时尽量简化对判题功能的调用,让调用方最简单
也就是说 我们将判断的方法再封装 这次包含有判断语言的功能 可以不需要if判断
/**
* 判题管理 (简化调用)
*/
@Service
public class JudgeManager {
/**
* 执行判题
* @param judgeContext
* @return
*/
JudgeInfo doJudge(JudgeContext judgeContext){
QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();
String language = questionSubmit.getLanguage();
JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
if ("java".equals(language)){
judgeStrategy= new JavaLanguageJudgeStrategy();
}
return judgeStrategy.doJudge(judgeContext);
}
}
代码沙箱: 只负责接受代码和输入 返回编译运行的结果, 不负责判题(可以作为独立的项目/ 服务, 提供给其他的需要执行代码的项目去使用)
以java编程语言为主 ,实现代码沙箱
扩展: 实现 c++ 语言的代码沙箱
将代码沙箱做成微服务项目
新建一个 Springboot web 项目, 最终这个项目提供一个能够执行代码 操作代码沙箱的解雇偶
选择java8、 springboot 2.7 版本
编写测试接口,验证能否访问
package com.yuoj.yuojcodesandbox.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController("/")
public class MainController {
@GetMapping("/health")
public String healthCheck(){
return "OK";
}
}
原生: 尽可能不借助第三方库和依赖,用最干净、最原始的方式实现代码沙箱
代码沙箱需要: 接收代码 => 编译代码(javac) => 执行代码 (java)
去掉包名放在 resource 目录下
为什么编译后会中文乱码
因为 命令行的编码是 GBK, 和 java 代码文件本身的编码 UTF-8 不一致 导致乱码
通过chcp 命令查看编码 GBK 是 936 UTF-8 时 65001
但是不建议改变终端代码来解决乱码,因为其他运行你代码时也要改变环境 兼容性很差
正确的代码支持:
D:\Oj\yuoj-code-sandbox\src\main\resources\testCode\simpleComputeArgs>javac -encoding utf-8 SimpleCompute.java
实际OJ系统中, 对用户输入的代码会有一定的要求, 便于系统统一地处理,所以此处,我们把用户输入代码的类名限制为 Main(参考北大 OJ) 可以减少类名不一致带来的风险。 而且不用从用户代码中提取类名 更方便
核心实现思路: 用程序的代替人工, 用程序来操作命令行 ,去编译执行代码
Java 进程执行管理类: Process
引入Hutool 工具类
<!-- https://hutool.cn/docs/index.html#/-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.8</version>
</dependency>
新建目录,把每个用户的代码存放在独立目录中 便于维护
java执行程序
String compileCmd = String.format("javac -encoding utf-8%s",userCodeFile.getAbsoluteFile());
Process compileProcess = Runtime.getRuntime().exec(compileCmd);
Java获取控制台输出
通过 exitValue 判断程序是否正常返回 从 inputStream 和 errorStream 获取控制台的输出
Process compileProcess = Runtime.getRuntime().exec(compileCmd);
// 等待程序执行 获取错误码
int exitValue = compileProcess.waitFor();
//正常退出
if (exitValue ==0){
System.out.println("编译成功");
// 分批获取程序的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine())!= null){
compileOutputStringBuilder.append(compileOutputLine);
}
System.out.println(compileOutputStringBuilder);
}else {
// 分批获取程序的正常输出 从进程的输入流里面读取
System.out.println("编译失败 . 错误码"+exitValue);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine())!= null){
compileOutputStringBuilder.append(compileOutputLine);
}
// 分批获取程序的正常输出
System.out.println("编译失败 . 错误码"+exitValue);
BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(compileProcess.getErrorStream()));
StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
// 逐行读取
String errorCompileOutputLine;
while ((errorCompileOutputLine = errorBufferedReader.readLine())!= null){
errorCompileOutputStringBuilder.append(errorCompileOutputLine);
}
System.out.println(compileOutputStringBuilder);
}
编写一个工具类,执行进程,并且获取输出
package com.yuoj.yuojcodesandbox.utils;
import com.yuoj.yuojcodesandbox.model.ExecuteMessage;
import java.io.BufferedReader;
import java.io.InputStreamReader;
/**
* 进程工具类
*/
public class ProcessUtils {
/**
* 执行进程并获取信息
* @param runProcess
* @param opName
* @return
*/
public static ExecuteMessage runProcessAndGetMessage(Process runProcess,String opName){
ExecuteMessage executeMessage = new ExecuteMessage();
try {
int exitValue = runProcess.waitFor();
executeMessage.setExitValue(exitValue);
//正常退出
if (exitValue ==0){
System.out.println(opName+"编译成功");
// 分批获取程序的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine())!= null){
compileOutputStringBuilder.append(compileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
System.out.println(compileOutputStringBuilder);
}else {
// 分批获取程序的正常输出 从进程的输入流里面读取
System.out.println(opName+"编译失败 . 错误码"+exitValue);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine())!= null){
compileOutputStringBuilder.append(compileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
// 分批获取程序的正常输出
System.out.println("编译失败 . 错误码"+exitValue);
BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
// 逐行读取
String errorCompileOutputLine;
while ((errorCompileOutputLine = errorBufferedReader.readLine())!= null){
errorCompileOutputStringBuilder.append(errorCompileOutputLine);
}
executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());
}
}catch (Exception e){
e.printStackTrace();
}
return executeMessage;
}
}
如果遇到乱码 加上 -Dfile.encoding=UTF-8
//3. 执行代码, 得到输出结果
for (String inputArgs : inputList) {
String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Main %s",userCodeParentPath,inputArgs);
try {
Process process = Runtime.getRuntime().exec(runCmd);
ExecuteMessage executeMessage = ProcessUtils.runProcessAndGetMessage(process, "运行");
System.out.println(executeMessage);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
很多 OJ 都是ACM 模式 , 需要和用户进行交互, 让用户不断的输入内容并输出比如:
对于此类程序,我们需要使用 OutputStream 想程序中断发送参数 并及时获取结果
注意关闭流
/**
* 执行交互式进程并获取信息
* @param runProcess
* @param opName
* @return
*/
public static ExecuteMessage runInteractProcessAndGetMessage(Process runProcess,String opName,String args){
ExecuteMessage executeMessage = new ExecuteMessage();
try {
// 想控制台输入程序
InputStream inputStream = runProcess.getInputStream();
OutputStream outputStream = runProcess.getOutputStream();
String[] s = args.split(" ");
OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
outputStreamWriter.write(StrUtil.join("\n",s)+"\n");
outputStreamWriter.flush();
// 分批获取程序的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行读取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine())!= null){
compileOutputStringBuilder.append(compileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
outputStreamWriter.close();
outputStream.close();
inputStream.close();
runProcess.destroy();
}catch (Exception e){
e.printStackTrace();
}
return executeMessage;
}
获取程序执行时间,使用 spring 的StopWatch
StopWatch stopWatch = new StopWatch();
stopWatch.start();
stopWatch.stop();
executeMessage.setTime(stopWatch.getLastTaskTimeMillis());
此处我们使用最大来统计时间:
扩展:可以每个测试用例都有一个独立的内存, 时间占用的统计
防止服务器空间不足:
if(userCodeFile.getParentFile()!=null){
boolean del = FileUtil.del(userCodeParentPath);
System.out.println("删除"+(del ? "成功" : "失败"));
}
封装一个错误处理方法,当程序抛出异常时,直接返回错误响应
/**
* 获取错误响应
*
* @param e
* @return
*/
private ExecuteCodeResponse getErrorResponse(Throwable e){
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
executeCodeResponse.setOutputlist(new ArrayList<>());
executeCodeResponse.setMessage(e.getMessage());
// 表示代码沙箱错误
executeCodeResponse.setStatus(2);
executeCodeResponse.setJudgeInfo(new JudgeInfo());
return executeCodeResponse;
}
到目前为止, 核心流程已经实现, 但是想要上线的话 是不安全的
用户会提交恶意代码 怎么办?
时间上搞
/**
* 无限睡眠 (程序阻塞)
*/
public class SleepError {
public static void main(String[] args) throws InterruptedException {
long ONE_HOUR = 60*60*1000L;
Thread.sleep(ONE_HOUR);
System.out.println("睡完了~·");
}
}
要将写过的代码复制到 resource 目录下 并且把类名改为 Main 报名去掉
空间上搞
package com.yuoj.yuojcodesandbox.unsafeCode;
import java.util.ArrayList;
public class MemoryError {
public static void main(String[] args) {
ArrayList<byte[]> bytes = new ArrayList<>();
while (true){
bytes.add(new byte[100000]);
}
}
}
在实际的运行中, 我们会发现,内存占用到达一定空间后, 程序就会报错:
这是 JVM 的一个保护机制
JVisualVM 或 JConsole工具, 可以连接到 JVM 虚拟机上 来查看JVM 的状态
System.getProperty("user.dir");** 解释: 默认定位到的当前用户目录("user.dir")(即工程根目录) JVM就可以据"user.dir" + "你自己设置的目录" 得到完整的路径(即绝对路径) 这有个前提,你的工程不是web项目,不然,这个返回值就不是项目的根目录啦,是tomcat的bin目录。
直接通过相对路径获取文件信息
package testCode.unsafeCode;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
public class Main {
public static void main(String[] args) throws IOException {
String userDir = System.getProperty("user.dir");
String filePath = userDir + File.separator+ "src/main/resources/application.yml";
List<String> allLines = Files.readAllLines(Paths.get(filePath));
System.out.println(String.join("\n",allLines));
}
}
假设有一个木马程序: java -version 2>&1
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
public class Main {
public static void main(String[] args) throws IOException {
String userDir = System.getProperty("user.dir");
String filePath = userDir + File.separator+ "src/main/resources/木马程序.bat";
String errorProgram = "java -version 2>&1";
Files.write(Paths.get(filePath), Arrays.asList(errorProgram));
System.out.println("木马植入成功, 你完了 哈哈");
}
}
操作电脑上的其他程序
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
/**
* 执行程序
*/
public class Main {
public static void main(String[] args) throws IOException {
String userDir = System.getProperty("user.dir");
String filePath = userDir + File.separator+ "src/main/resources/木马程序.bat";
Process process = Runtime.getRuntime().exec(filePath);
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String compileLine;
while ((compileLine = bufferedReader.readLine())!= null){
System.out.println(compileLine);
}
System.out.println("木马程序执行成功");
}
}
甚至都不用写木马程序,直接执行系统自带的高危命令
比如删除服务器的所有文件(残暴的一批)
比如执行 dir(windows) 、 ls(Linux) 获取你系统上的所有文件信息
通过创建一个守护线程, 超时后终端process实现
//超时控制
new Thread(()->{
try {
Thread.sleep(TIME_OUT);
System.out.println("超时了。。中断");
process.destroy();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
我们不能让每个 java 进程的执行占用的 JVM 最大堆内存空间都和系统的一致,实际上应该小一点,比如说256MB
在启动 Java 时 可以指定 JVM 的参数: -Xmx256M (最大堆空间大小) -Xms(初始堆空间大小)
String compileCmd = String.format("javac -Xmx256m -encoding utf-8 %s", userCodeFile.getAbsoluteFile());
注意: -Xmx 参数。 JVM的堆内存限制,不等同于系统实际占用的最大资源,可能会超出。
如果需要更严格的内存限制,要在系统层面去限制,而不是JVM层面的限制。
如果是Linux 系统, 可以使用 cgroup 来实现对某个进程的 CPU、内存等资源的分配
初始化字典树
Hutool 字典树工具类: WordTree, 可以用更少的空间存储更多的敏感词汇,实现更高效的敏感词查找
private static final List<String> blackList = Arrays.asList("Files","exec");
private static final WordTree WORD_TREE;
static {
//初始化字典树
WORD_TREE = new WordTree();
WORD_TREE.addWords(blackList);
}
//校验黑名单代码
FoundWord foundWord = WORD_TREE.matchWord(code);
if (foundWord!=null){
System.out.println(foundWord.getFoundWord());
return null;
}
使用字典树可以节约空间
可以写在简历上
缺点:
限制用户对文件、内存、CPU、网络等资源的操作和访问
Java 安全管理器 (Security Manager)来实现更严格的限制
package com.yuoj.yuojcodesandbox.security;
import java.security.Permission;
/**
* 默认安全管理器
*/
public class DefaultSecurityManager extends SecurityManager{
@Override
public void checkPermission(Permission perm) {
System.out.println("默认不做任何操作");
super.checkPermission(perm);
}
}
package com.yuoj.yuojcodesandbox.security;
import java.security.Permission;
/**
* 禁用所有的安全管理器
*/
public class DenySecurityManager extends SecurityManager{
@Override
public void checkPermission(Permission perm) {
throw new SecurityException("权限异常00"+ perm.toString());
}
}
实际情况下, 我们只需要限制子程序的权限即可,没必要限制开发者自己写的程序,
1)编写安全管理器
2)编译安全管理器,去掉包名
3)在运行java程序时,指定安全管理器的路径、安全管理器的名称。
-Djava.security.manager=MySecurityManager Main %s
优点
权限控制灵活,实现简单
缺点:
系统层面上,把用户程序封装到沙箱里, 和宿主机(我们的电脑/服务器) 隔离开
Docker 容器技术能够实现(底层是用 cgroup,namespace 等方式实现的)
为什么要用 Docker 容器技术? 为了提升系统的安全性 把程序和宿主机隔离
Docker 技术可以实现和宿主机的隔离
什么是容器?
理解为一系列得到应用程序、服务和环境的封装,从而把程序运行在一个隔离的、密闭的、隐私的空间内、对外整体提供服务
镜像:用来创建容器的安装包,可以理解为给电脑安装操作系统的系统镜像
容器:通过镜像来创建一套运行环境,一个容器里可以运行多个程序,可以理解为一个电脑实例
Dockerfile: 制作镜像的文件,可以理解为制作镜像的一个清单
镜像仓库: 存放镜像的仓库,用户可以从仓库下载线程的镜像,也可以把做好的镜像放到仓库里
推荐使用 docker 官方的镜像仓库
看图理解:
1)Docker 运行在 Linux 内核上
2)CGroups: 实现了容器的资源隔离,底层是 Linux CGroup 命令,能够控制进程使用的资源
3)NetWork网络:实现容器的网络隔离,docker容器内部的网络互不影响
4)Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同命名空间下的进程互补影响。
5)storage:存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件
一般情况下,不建议在 Windows 系统上安装
Windows 本身就是带了个虚拟机 叫 WSL,不建议,不如一个专业的 隔离的虚拟机软件要方便
Linux使用的是 Ubuntu系统
使用免费的 VMWare Workstation Player 软件:
1)增加权限
2)重启虚拟机! 重启远程开发环境! 重启程序!
1)查看命令方法
docker --help
查看具体子命令的方法:
docker run --help
2) 从远程仓库拉取现成的镜像
用法:
docker pull [OPTIONS] NAME[:TAG|@DIGEST]
docker pull hello-world
3)根据镜像创建容器实例:
启动实例,得到容器实例 containerId:
sudo docker create hello-world
4) 查看容器状态:
sudo docker ps -a
5) 启动容器:
docker start [OPTIONS] CONTAINER [CONTAINER...]
启动实例:
sudo docker start confident_banzai
6) 查看日志
docker logs[OPTIONS] CONTAINER
启动日志
sudo docker logs confident_banzai
7) 删除容器实例
sudo docker rm confident_banzai
8) 删除进项
docker rmi --help
使用 docker-java: github 搜索
引入pom依赖
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>com.github.docker-java</groupId>
<artifactId>docker-java-transport-httpclient5</artifactId>
<version>3.3.0</version>
</dependency>
DockerClientConfig: 用于定义初始化 DockerClient 的配置(类比 MYSQL 的连接,线程数配置)
DockerHttpClient: 用于向 Docker 的守护进程 (操作 Docker 的接口) 发送请求的客户端,低层封装 不推荐使用, 自己构建请求参数 (类比 JDBC)
DockerClient(推荐): 才是真正和 Docker 守护进程交互的 最方便的 SDK,高层封装 是对 DockerHttpCLient 再去进行了一层封装(理解为 Mybatis) 提供了增删改查
使用 IDEA Development 先上传代码到Linux,然后使用 JetBrains 远程开发完全连接Linux 实时开发
如果无法启动程序,修改 setting 的compiler配置
-Djdk.lang.Process.launchMechanism=vfork
拉取镜像:
public static void main(String[] args) throws InterruptedException {
// 获取默认的 Docker Client
DockerClient dockerClient = DockerClientBuilder.getInstance().build();
String image="nginx:latest";
PullImageCmd pullImageCmd = dockerClient.pullImageCmd(image);
PullImageResultCallback pullImageResultCallback = new PullImageResultCallback(){
@Override
public void onNext(PullResponseItem item) {
System.out.println("下载镜像"+item.getStatus());
super.onNext(item);
}
};
pullImageCmd.
exec(pullImageResultCallback).
awaitCompletion();
System.out.println("下载完成");
}
创建容器:
//创建容器
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
CreateContainerResponse createContainerResponse = containerCmd
.withCmd("echo","Hello docker")
.exec();
System.out.println(createContainerResponse);
查看容器状态:
//查看容器状态
ListContainersCmd listContainersCmd = dockerClient.listContainersCmd();
List<Container> containerList = listContainersCmd.withShowAll(true).exec();
for (Container container : containerList) {
System.out.println(container);
}
启动容器:
//启动容器
dockerClient.startContainerCmd(containerId).exec();
启动日志
// 查看日志
LogContainerResultCallback logContainerResultCallback = new LogContainerResultCallback() {
@Override
public void onNext(Frame item) {
System.out.println("日志信息: " + new String(item.getPayload()));
super.onNext(item);
}
};
dockerClient.logContainerCmd(containerId).
withStdErr(true)
.withStdOut(true).
exec(logContainerResultCallback).awaitCompletion();
删除容器
删除镜像
//删除容器
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
//删除镜像
dockerClient.removeImageCmd(containerId).exec();
实现流程:docker 负责运行java程序 并且得到结果
`扩展:模板方法设计模式,定义同一套实现流程,让不同的子类去负责不同流程中的具体实现,执行步骤一样,但每个步骤的实现方式不一样
自定义容器的两种方式:
1)在已有镜像的基础上再扩充:比如拉取线程的 Java环境 (包含jdk),再把编译后的文件复制到容器里,适合新项目、跑通流程
2)完全自定义容器: 适合比较成熟的项目, 比如封装多个语言的环境和实现
思考: 我们每个测试用例都单独创建一个容器,每个容器只执行一次 java 命令吗?
不是的 这样会浪费性能, 所有要创建一个可交互的容器,能够接收多次输入并且输出
创建容器时,可以指定文件路径(Volume) 映射, 作用是把本地的文件同步到容器中, 可以让容器访问。 容器挂在目录
Docker 执行容器命令 (操作已启动容器):
Usage: docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
docker exec kenn_blackwell java -cp /app Main 1 3
注意,要把命令按照空格拆分,作为一个数组传递,否则可能会被识别为一个字符串 而不是多个参数
创建命令:
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withAttachStderr(true)
.withAttachStdin(true)
.withAttachStdout(true)
.withTty(true)
.exec();
执行命令:
String execId = execCreateCmdResponse.getId();
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback(){
@Override
public void onNext(Frame frame) {
StreamType streamType = frame.getStreamType();
if (streamType.STDERR.equals(streamType)){
System.out.println("输出错误结果"+ new String(frame.getPayload()));
}else {
System.out.println("输出结果"+ new String(frame.getPayload()));
}
super.onNext(frame);
}
};
try {
dockerClient.execStartCmd(execId).exec(execStartResultCallback)
.awaitCompletion();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
尽量复用之前的 ExecuteMessage 模式, 再异步接口中填充正常和异常信息
获取程序执行时间: 和 Java原生一样 , 使用StopWatich 在执行前后统计时间。
获取程序占用内存:
// 监控占用的内存 获取占用的内存
StatsCmd statsCmd = dockerClient.statsCmd(containerId);
ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {
@Override
public void onNext(Statistics statistics) {
System.out.println("内存占用: " + statistics.getMemoryStats().getUsage());
maxMemory[0] =Math.max(statistics.getMemoryStats().getUsage(), maxMemory[0]);
}
@Override
public void onStart(Closeable closeable) {
}
@Override
public void onError(Throwable throwable) {
}
@Override
public void onComplete() {
}
@Override
public void close() throws IOException {
}
});
//无休止的监控 这个回调没有 awaitCompletion
1) 超时控制
执行容器时,可以增加超时参数控制
dockerClient.execStartCmd(execId)
.exec(execStartResultCallback)
.awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);
但是,这种方式无论超时与否,程序都会继续往下执行,无法判断是否超时。
可以定义一个超时标志, 如果程序执行完成, 就把这个超时标志设置为Fakse
2) 内存控制
通过HostConfig 进行限制
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(100 * 1000 * 1024L);
hostConfig.withCpuCount(1L);
// 和 硬盘的交互
hostConfig.withMemorySwap(0L);
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));
// 三个 with 必须开启 作用:把docker容器和本地的终端进行连接 能够获取到输入并且能够获取到输出
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withAttachStderr(true)
.withAttachStdin(true)
.withAttachStdout(true)
.withTty(true)
.exec();
3) 网络资源
创建容器时,设置网络配置为关闭:
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
4) 权限管理配置
Docker 容器已经做了系统层面的隔离, 比较安全,但不能保证绝对安全
结合 Java 安全管理器和其他策略去使用
限制用户不能向 根目录root 去写文件
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
.withReadonlyRootfs(true)
Linux 自带的一些安全管理措施 seccomp(Security Compute Mode) Linux内核安全机制
模板方法; 定义一套通用的执行流程,让子类负责每个执行步骤的具体实现
模板方法的适用场景: 使用于有规范的流程,且执行流程可以复用
作用: 大幅节省重复代码,便于项目扩展,更好维护
定义模板方法抽象类。
先复制具体的实现类, 把代码从完整的方法抽离成一个一个的子写法
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
// System.setSecurityManager(new DenySecurityManager());
List<String> inputList = executeCodeRequest.getInputList();
String code = executeCodeRequest.getCode();
String language = executeCodeRequest.getLanguage();
//1. 把用户的代码保存为文件
File userCodeFile = saveCodeToFile(code);
//2. 编译代码, 得到class文件
ExecuteMessage compileFileExecuteMessage = compileFile(userCodeFile);
System.out.println(compileFileExecuteMessage);
//3. 执行代码, 得到输出结果
List<ExecuteMessage> executeMessageList = runFile(userCodeFile, inputList);
//4. 收集整理输出结果
ExecuteCodeResponse outputResponse = getOutputResponse(executeMessageList);
//5. 文件清理
//6. 错误处理,提升程序健壮性
boolean b = deleteFile(userCodeFile);
if (!b){
log.error("deleteFile Error,userCodeFIle={}",userCodeFile.getAbsoluteFile());
}
return outputResponse;
}
setting->editor action->folding 查看折叠
Java原生代码沙箱的实现,直接复用模板方法定义好的方法实现
/**
* Java 原生代码沙箱实现(直接复用模板方法)
*/
public class JavaNativeCodeSandbox extends JavaCodeSandboxTemplate {
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
return super.executeCode(executeCodeRequest);
}
}
Docker实现代码沙箱 使用模板方法实现
/**
* Java Docker 代码沙箱模板方法的具体实现
*/
public class JavaDockerCodeSandBox extends JavaCodeSandboxTemplate{
private static final long TIME_OUT = 5000L;
public static final Boolean First_INIT = true;
public static void main(String[] args) {
JavaDockerCodeSandBox javaNativeCodeSandBox = new JavaDockerCodeSandBox();
ExecuteCodeRequest executeCodeRequest = new ExecuteCodeRequest();
executeCodeRequest.setInputList(Arrays.asList("1 2", "1 3"));
// String code = "";
// executeCodeRequest.setInputList(Arrays.asList(" 1 2 ", "3 4 "));
// String code = ResourceUtil.readStr("/home/xingxing/yuoj-code-sandbox/src/main/java/testCode/simpleCompute/SimpleCompute.java", StandardCharsets.UTF_8);
// String code = ResourceUtil.readStr("testCode/unsafeCode/RunFileError.java", StandardCharsets.UTF_8);
String code = ResourceUtil.readStr("/home/xingxing/yuoj-code-sandbox/src/main/resources/testCode/unsafeCode/SleepError.java", StandardCharsets.UTF_8);
executeCodeRequest.setCode(code);
executeCodeRequest.setLanguage("java");
ExecuteCodeResponse executeCodeResponse = javaNativeCodeSandBox.executeCode(executeCodeRequest);
System.out.println(executeCodeResponse);
}
/**
* 3. 创建容器 把文件赋值到容器内
* @param userCodeFile
* @param inputList
* @return
*/
@Override
public List<ExecuteMessage> runFile(File userCodeFile, List<String> inputList) {
String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
// 获取默认的 Docker Client
DockerClient dockerClient = DockerClientBuilder.getInstance().build();
String image = "openjdk:8-alpine";
//拉取镜像
if (First_INIT) {
PullImageCmd pullImageCmd = dockerClient.pullImageCmd(image);
PullImageResultCallback pullImageResultCallback = new PullImageResultCallback() {
@Override
public void onNext(PullResponseItem item) {
System.out.println("下载镜像" + item.getStatus());
super.onNext(item);
}
};
try {
pullImageCmd.
exec(pullImageResultCallback).
awaitCompletion();
} catch (InterruptedException e) {
System.out.println("拉取镜像异常");
throw new RuntimeException(e);
}
}
System.out.println("下载完成");
//创建容器
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(100 * 1000 * 1024L);
hostConfig.withCpuCount(1L);
// 和 硬盘的交互
hostConfig.withMemorySwap(0L);
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));
// 三个 with 必须开启 作用:把docker容器和本地的终端进行连接 能够获取到输入并且能够获取到输出
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
.withReadonlyRootfs(true)
.withAttachStderr(true)
.withAttachStdin(true)
.withAttachStdout(true)
.withTty(true)
.exec();
System.out.println(createContainerResponse);
String containerId = createContainerResponse.getId();
//启动容器
dockerClient.startContainerCmd(containerId).exec();
//执行命令 并获取结果
//docker exec kenn_blackwell java -cp /app Main 1 3
List<ExecuteMessage> executeMessageList = new ArrayList<>();
for (String inputArgs : inputList) {
StopWatch stopWatch = new StopWatch();
String[] inputArgsArray = inputArgs.split(" ");
String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Main", "1", "3"}, inputArgsArray);
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
.withCmd(cmdArray)
.withAttachStderr(true)
.withAttachStdin(true)
.withAttachStdout(true)
.exec();
System.out.println("创建执行命令: " + execCreateCmdResponse);
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
ExecuteMessage executeMessage = new ExecuteMessage();
final String[] message = {null};
final String[] errorMessage = {null};
long time = 0L;
final boolean[] timeout = {true};
String execId = execCreateCmdResponse.getId();
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {
@Override
public void onComplete() {
// 如果执行完成 则表示没超时
timeout[0] = false;
super.onComplete();
}
@Override
public void onNext(Frame frame) {
StreamType streamType = frame.getStreamType();
if (streamType.STDERR.equals(streamType)) {
errorMessage[0] = new String(frame.getPayload());
System.out.println("输出错误结果" + errorMessage[0]);
} else {
message[0] = new String(frame.getPayload());
System.out.println("输出结果" + message[0]);
}
super.onNext(frame);
}
};
final long[] maxMemory = {0L};
// 监控占用的内存 获取占用的内存
StatsCmd statsCmd = dockerClient.statsCmd(containerId);
ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {
@Override
public void onNext(Statistics statistics) {
System.out.println("内存占用: " + statistics.getMemoryStats().getUsage());
maxMemory[0] = Math.max(statistics.getMemoryStats().getUsage(), maxMemory[0]);
}
@Override
public void onStart(Closeable closeable) {
}
@Override
public void onError(Throwable throwable) {
}
@Override
public void onComplete() {
}
@Override
public void close() throws IOException {
}
});
//无休止的监控 这个回调没有 awaitCompletion
statsCmd.exec(statisticsResultCallback);
try {
stopWatch.start();
dockerClient.execStartCmd(execId)
.exec(execStartResultCallback)
.awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);
stopWatch.stop();
time = stopWatch.getLastTaskTimeMillis();
statsCmd.close();
} catch (InterruptedException e) {
System.out.println("程序执行异常");
throw new RuntimeException(e);
}
executeMessage.setMessage(message[0]);
executeMessage.setErrorMessage(errorMessage[0]);
executeMessage.setTime(time);
executeMessage.setMemory(maxMemory[0]);
executeMessageList.add(executeMessage);
}
return executeMessageList;
}
}
直接再controller层暴露接口
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest){
if (executeCodeRequest == null){
throw new RuntimeException("请求参数为空");
}
return javaNativeCodeSandbox.executeCode(executeCodeRequest);
}
如果将服务不做任何的权限校验,直接发到公网,是不安全的
1) 调用方与服务方之间约定一个字符串, (最好加密)
优点: 实现最简单,比较适合内部系统之间的相互调用(相对可信的内部调用)
缺点: 不够灵活, 如果 key 泄露或变更 , 需要重启代码
代码沙箱服务, 先定义约定的字符串:
public static final String AUTH_REQUEST_HEADER="auth";
public static final String AUTH_REQUEST_SECRET="secretKey";
改造请求,从请求头中获取认证信息,并校验
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
@PostMapping("/executeCode")
ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest,
HttpServletRequest request,
HttpServletResponse response){
String authHeader = request.getHeader(AUTH_REQUEST_HEADER);
if (!authHeader.equals(AUTH_REQUEST_HEADER)){
response.setStatus(403);
return null;
}
if (executeCodeRequest == null){
throw new RuntimeException("请求参数为空");
}
return javaNativeCodeSandbox.executeCode(executeCodeRequest);
}
调用方(也需要定义约定好的字符串):
System.out.println("远程代码沙箱");
String url = "http://localhost:8090/executeCode";
String json = JSONUtil.toJsonStr(executeCodeRequest);
String responseStr = HttpUtil.createPost(url)
.header(AUTH_REQUEST_HEADER,AUTH_REQUEST_SECRET)
.body(json)
.execute()
.body();
2) API签名认证。 给允许调用的人员分配 accessKey 、 secretKey , 然后校验这两组Key 是否匹配
详细看 API 开放平台项目
1) 移动 questionSubmitController 到questionController 里面
2) 由于后端改了接口地址,前端需要重新生成接口调用代码
3)后端调试
4) 开发提交列表页面
扩展:每隔一段时间刷新一下提交状态,因为后端是异步判题的
新建一个项目
服务: 提供某类功能的代码
微服务: 专注于提供某类特定功能的代码,而不是把所有的代码全部放到同一个项目里面。 会把整个大的项目按照一定的功能,逻辑进行拆分,拆分为多个子模块,每个子模块之间可以独立运行,独立负责一类功能,子模块之间相互调用,互不影响。
微服务几个重要的实现因素:服务管理,服务调用,服务拆分
一个公司:一个人干活,这个人生病了 公司寄了
一个公司有多个不同的岗位,多个人干活,一个人不影响整个公司的运作。各个模块各个组之间可能需要交互来完成大的项目/
Spring Cloud
本项目采用 Spring Cloud Alibaba
Dubbo(Dubbox)
RPC (GRPC、TRPC)
本质上是通过HTTP、 或者其他网络协议进行通讯开发实现的
本质: 是在 Spring Cloud 的基础上,进行了增强,补充了一些额外的能力,根据阿里多年的业务沉淀做出了一些定制化的开发
1. Spring Cloud Gateway:网关
2. Nacos:服务注册和配置中心
3. Sentinel:熔断限流
4. Seata:分布式事务
5. RocketMQ:消息队列,削峰填谷
6. Docker:使用Docker进行容器化部署
7. Kubernetes:使用k8s进行容器化部署
注意: 一定要选择对应的版本
Nacos:集中存管项目中所有服务的信息,便于服务之间找到彼此;同时,还支持集中存储整个项目中的配置
整个微服务请求流程:
Discovery 另一个分布式微服务框架
从业务需求出发,思考单机和分布式锁的区别。
用户登录功能:需要改造为分布式登录
其他内容
1) application.yml 增加 redis 配置
2) 补充依赖
3 ) 主类取消 Redis 自动配置的移除
4) 修改 session 存储方式:
session:
# todo 取消注释开启分布式 session(须先配置 Redis)
store-type: redis
# 30 天过期
timeout: 2592000
5)使用 redis-cli 或者 redis 管理工具,查看是否有登录后的信息
从业务出发,想一下那些功能/ 职责是一起的?
公司老板给员工分工
依赖服务:
公共模块:
1 用户服务(yuoj-backed-user-service : 8102 端口)
2 题目服务(yuoj-backed-question-service: 8103 端口)
3 . 判题模块((yuoj-backed-judge-service: 8104 端口)较重的操作)
代码沙箱服务本身就是独立在这个项目以外的,可以不用纳入 Spring Cloud 的管理
用 springboot 的 context-path 统一修改各项目的前缀接口, 比如:
用户服务:
题目服务:
判题服务:
在bin目录下输入
startup.cmd -m standalone
默认账号面膜 nacos nacos
Spring Cloud 有相当多的依赖,参差不齐,不建议随意找配置, 或者自己写
建议用脚手架创建项目
给项目增加全局依赖配置文件。
使用spring-cloud-alibaba 也需要引入spring-cloud 相关的依赖,并且版本要对应
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
openFeign : 模块之间相互引用的组件
依次使用 new modules 和 spring boot Initializr 创建各模块:
需要给字符模块之间绑定父子依赖关系:
父模块声明
<modules>
<module>yuoj-backend-common</module>
<module>yuoj-backend-model</module>
<module>yuoj-backend-gateway</module>
<module>yuoj-backend-service-client</module>
<module>yuoj-backend-judge-service</module>
<module>yuoj-backend-user-service</module>
<module>yuoj-backend-question-service</module>
</modules>
子模块引入 parent
<parent>
<groupId>com.yupi</groupId>
<artifactId>yuoj-backed-microservice</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
通过继承父模块配置,统一项目的定义和版本号
1)common 公共模块(yuoj-backed-common): 全局异常处理器,请求响应封装类、公用的工具类等
在外层的xml中引入公共类
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.9.1</version>
</dependency>
<!-- https://github.com/alibaba/easyexcel -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.1.1</version>
</dependency>
<!-- https://hutool.cn/docs/index.html#/-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.8</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.4</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
2) model 模型模块(yuoj-backed-model): 很多服务都有公用的实体类
3) 公用接口模块(yuoj-backed-service-client): 只存放接口,不存放实现(多个服务之间要共享)
先无脑搬运所有service 在剔除 judgeService 也需要粘
无法识别的版本可以取父依赖中寻找
4) 具体业务服务实现
给所有业务服务引入依赖
<dependency>
<groupId>com.yupi</groupId>
<artifactId>yuoj-backend-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.yupi</groupId>
<artifactId>yuoj-backend-model</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.yupi</groupId>
<artifactId>yuoj-backend-service-client</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
引入主类注解
引入 application.yml 配置
现在的问题是,题目服务依赖用户服务,但是代码已经分到不同的包,找不到对应的Bean。
可以使用 Open Feign 组件实现跨服务的远程调用
Open Feign:Http 调用客户端, 提供了更方便的方式来让你远程调用其他服务 不用关心项目的调用地址
Nacos 注册中心获取服务调用地址
1) 梳理服务的调用关系,确定那些服务(接口)需要给内部调用
用户服务:没有其他依赖
题目服务:
userService.getById(userId)
userService.getUserVO(user)
userService.listByIds(userIdSet)
userService.getUserVO(user)
userService.getLoginUser(request)
判题服务:
questionFeignClient.getQuestionSubmitById(questionSubmitId);
questionService.getById(questionId)
questionSubmitService.updateById(questionSubmitUpdate)
2) 确认要提供哪些服务
用户服务:没有其他依赖
userService.getById(userId):
userService.getUserVO(user)
userService.listByIds(userIdSet)
userService.getUserVO(user)
userService.getLoginUser(request)
题目服务
questionFeignClient.getQuestionSubmitById(questionSubmitId);
questionService.getById(questionId)
questionSubmitService.updateById(questionSubmitUpdate)
判题服务
judgeService.doJudge(questionSubmitId)
3) 实现client 接口
开启 openfeign 的支持,把我们的接口暴露出去(把服务注册到注册中心上) 作为 API 给其他用户服务调用(其他服务从注册中心寻找)
对于一些用户服务,有一些不利于远程调用参数传递,或者实现起来非常简单(工具类),可以直接用默认方法,无需节约性能
需要修改每个服务提供者的 context-path 全局请求路径
服务提供者:理解为接口的实现类,实际提供服务提供方法的模块
服务消费者:理解为接口的调用方,需要取调用服务提供者的具体实现,解决问题
server:
address: 0.0.0.0
port: 8104
servlet:
context-path: /api/judge
注意事项:
4)修改各业务服务的调用代码为 feignClient
5) 编写 feignClient 客户端服务的实现类
@RestController
@RequestMapping("/inner")
public class JudgeInnerController implements JudgeFeignClient {
@Resource
private JudgeService judgeService;
@Override
@PostMapping("/do")
public QuestionSubmit doJudge(@RequestParam("questionSubmitId") long questionSubmitId){
return judgeService.doJudge(questionSubmitId);
}
}
6) 开启 Nacos 让服务之间可以互相发现
所有模块引入 Nacos依赖 。然后给所有服务(包括网关) 添加如下配置
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
给业务服务项目启动类上打上注解,开启服务发现,找到对应的客户端 Bean 的位置
@EnableDiscoveryClient
@EnableFeignClients(basePackages = {"com.yupi.yuojbackendserviceclient.service"})
全局引入负载均衡依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-loadbalancer</artifactId>
<version>3.1.5</version>
</dependency>
微服务网关(yuoj-backed-gateway): Gateway 聚合所有的接口,统一接受处理前端的请求
是什么?
为什么要用?
Gateway: 想自定义一些功能,需要对这个基数有比较深的理解
Gateway 是应用层网关: 会有一定的业务逻辑(比如根据用户信息判断权限)
Nginx 是接入层网关: 比如每个请求的日志,通常没有业务逻辑
统一地接受前端的请求,转发请求到对应的服务
如何找到路由? 可以编写一套路由, 通过 api 地址前缀来找到对应的服务
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
gateway:
routes:
- id: yuoj-backend-user-service
uri: lb://yuoj-backend-user-service
predicates:
- Path=/api/user/
- id: yuoj-backend-question-service
uri: lb://yuoj-backend-question-service
predicates:
- Path=/api/question/**
- id: yuoj-backend-judge-service
uri: lb://yuoj-backend-judge-service
predicates:
- Path=/api/judge/**
application:
name: yuoj-backend-gateway
main:
web-application-type: reactive
server:
port: 8101
以一个全局的视角集中查看管理接口文档
使用 Knifej 接口文档生成器, 非常方便: 1) 先给所有业务服务引入依赖,同时开启接口文档的配置
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi2-jakarta-spring-boot-starter</artifactId>
<version>4.3.0</version>
</dependency>
Knife4j:
enable: true
2) 给网关配置集中管理接口文档
引入依赖
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-gateway-spring-boot-starter</artifactId>
<version>4.3.0</version>
</dependency>
引入配置
knife4j:
gateway:
# ① 第一个配置,开启gateway聚合组件
enabled: true
# ② 第二行配置,设置聚合模式采用discover服务发现的模式
strategy: discover
discover:
# ③ 第三行配置,开启discover模式
enabled: true
# ④ 第四行配置,聚合子服务全部为Swagger2规范的文档
version: swagger2
地址:http://localhost:8101/doc.htm
必须引入分布式依赖
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 将session放在redis中存储-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
解决cookie 的跨路径问题
@Bean
public CorsWebFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedMethod("*");
config.setAllowCredentials(true);
// todo 实际改为线上真实域名、本地域名
config.setAllowedOriginPatterns(Arrays.asList("*"));
config.addAllowedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
扩展: 可以在网关实现 Sentinel 接口限流降级
可以使用JWT Token实现用户登录, 在网关层面通过 token 获取登录信息,实现鉴权
真的有必要使用微服务吗?
真的有必要使用 Spring Cloud 实现微服务么?
企业内部一般使用 API (RPC HTTP)实现跨部门,跨服务的调用,数据格式和调用代码全部自动生成,保持统一,同时解耦
此处选用 RabbitMQ 消息队列改造项目,解耦判题服务和题目服务,题目服务只需要向消息队列发消息,判题服务从消息队列中取执行判题,然后异步更新数据库R
·
交换机
private static final String EXCHANGE_NAME = "fanout-exchange";
public static void main(String[] argv) throws Exception {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
try (Connection connection = factory.newConnection();
Channel channel = connection.createChannel()) {
// 创建交换机
channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String message = scanner.nextLine();
channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes("UTF-8"));
System.out.println(" [x] Sent '" + message + "'");
}
}
}
}
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。