# OnlineJudge
**Repository Path**: programmer-star/online-judge
## Basic Information
- **Project Name**: OnlineJudge
- **Description**: 微服务-在线判题系统
- **Primary Language**: Java
- **License**: Apache-2.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 5
- **Forks**: 1
- **Created**: 2023-11-14
- **Last Updated**: 2024-04-20
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
yuoj线上判断系统

# 1.前端
### 小型扩展点
#### 1.关于界面以及头像问题
```vue
// 获取登录用户信息
const loginUser: LoginUserVO = computed(
() => store.state.user?.loginUser
// eslint-disable-next-line no-undef
) as LoginUserVO;
```
获取用户登录信息
```vue
```
# 2.后端
### 1.初始化项目模板
1. 先阅读 README.md 文件
2. sql/create_table.sql: 定义了数据库初始化建表语句
3. sql/post_es_mapping.json: 帖子表在ES 中的建表语句
4. aop: 用于全局权限校验 全局日志记录
5. common: 万用的类 比如通用相应类
6. config: 用与接收 application.yml 中的参数,初始化一些客户端的配置类(比如对象存储客户端)
7. constant:定义常量
8. controller:接收请求
9. esdao:类似 mybatis 的mapper, 用于操作 ES
10. exception:异常处理相关
11. job:任务相关(定时任务,单次任务)
12. manager:服务层(一般定义一些公共的服务,对接第三方API等)
13. mapper:mybatis 的数据访问层 用于操作数据库
14. model:数据模型,实体类,包装类,枚举类
15. service:服务层,用于编写业务逻辑
16. utils:工具类 各种各样的方法
17. wsmp:公众号相关的包
18. MainApplication:项目主启动入孔
19. Dockerfile:用于构建 Docker (docker 更细粒度的服务器 比如两个用户使用一个服务系统 docker可以开启一个小系统包含主系统的所有功能 小模块 并且相互之间独立互不影响)
### 2.前后端联调
前端和后端是通过 接口/请求 来连接
1. 安装请求axios 文档 [起步 | Axios 中文文档 | Axios 中文网 (axios-http.cn)](https://www.axios-http.cn/docs/intro)
2. 编写调用后端的代码
传统情况下 每个请求都要单独编写代码 至少得写一个 请求路径
3. 直接使用 生成 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,
};
```
比如 获取用户登录信息
```shell
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,
});
}
},
},
```
4. 如果想要自定义参数 怎么办?
1. 修改使用代码生成器 提供的全局参数修改对象

```
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,
};
```
2. 直接定义 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);
}
);
```
### 3. 用户登录
#### 自动登录
1. 在 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,
});
}
},
},
```
2. 在哪里去触发一下 getLoginUser 的函数执行?
应当在一个全局的位置
有很多选择:
- 路由拦截
- 全局页面入口 app.vue
- 全局通用布局(所有页面共享的组件)
####
#### 全局权限管理优化
1. 新建 **access\index** 文件,把原有的路由拦截,权限校验逻辑放在独立的文件中
- 优势:只要不引入 就不会开启 不会对项目有影响
2. 编写权限管理和自动登录的逻辑
```
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();
});
```
#### 支持多套布局
1. 在 routes 路由文件中新建一套用户路由 使用 vue-router 自带的子路由机制 实现布局和路由嵌套
```
{
path: "/user",
name: "用户",
component: UserLayout,
children: [
{
path: "/user/login",
name: "用户登录",
component: UserLoginView,
},
{
path: "/user/register",
name: "用户注册",
component: UserRegisterView,
},
],
},
```
2. 新建一套 UserLayout UserLoginView UserRegisterView 页面,并且在 routes 中引入
3. 在 app.vue 根页面文件 根据路由去区分多套布局
```
```

#### 登录页面开发
### 4.数据库表的创建
#### 题目表
将判题输入用例,输出用例等 存入json对象
好处是: 便于扩展 只需要改变对象内部的字段 ,而不去修改数据库表(可能会影响数据库)
judgeConfig 判题配置 (json对象):
- 时间限制: timeLimit
- 内存限制: memoryLimit
- 输入用例: inputCase
judgeCase 判题用例(json 数组)
- 每一个元素包含一个输入用例和一个相对应的输出用例
```
[
{
"input": "1,2",
"output": "3,4"
},
{
"input": "1,5",
"output": "3,4"
}
]
```
judgeConfig
```json
{
"timeLimit" :1000,
"memoryLimit": 1000,
"stackLimit":1000
}
```
存 json 的前提:
1. 你不需要根据某个字段去倒查这条数据
2. 你的字段含义相关, 属于同一类的值
3. 你的字段存储空间占用不大
题目表
```
-- 题目表
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 '题目提交表';
```
```json
{
"message": "程序执行信息",
"time": 1000, // ms
"memory": 1000 //kb
}
```
判断信息枚举值:
- Accepted 成功
- Wrong Answer 答案错误
- Compile Error 编译错误
- Memory Limit Exceeded 内存溢出
- Time Limit Exceeded 超时
- Presentation Error 展示错误
- Output Limit Exceeded 输出溢出
- Waiting 等待中
- Dangerous Operation 危险操作
- Runtime Error 运行错误(用户程序的问题)
- System Error 系统错误 (做系统的人的问题)
**小知识 - 数据库索引**
什么情况下适合加索引? 如何选择给哪个字段加索引
答: 首先从业务出发,无论是单个索引,还是联合索引,都要从实际的查询语句,字段枚举值的区分度,字段的类型考虑 (where条件的指定字段)
比如 select * from 表 where userId= ? and questionId=?
可以根据userId 和 questionId 分别建立索引(需要分别根据这两个字段单独查询) ; 也可以选择给这两个字段建立联合索引(所查询的字段总是捆绑在一起的)
原则上: 能不用索引就不用索引; 能用单个索引就不要用联合/多个索引。 不要给没区分度的字段加索引 (比如性别 就 男 或者 女) 因为索引也是要占用空间的
### 后端接口开发
#### 后端开发流程
1. 根据功能设计库表
2. 自动生成对数据库基本的增删改查 (mapper 和 service层)
3. 编写Controller 层, 实现基本的增删改查和权限校验
4. 去根据业务定制开发的新功能 / 编写新的代码
#### 代码生成方法
1)安装 mybatisX 插件
2)根据项目去调整生成配置,建议生成代码到独立的包 不要影响老的项目

3) 把代码从生成包中转移到实际项目包中
4)找相似的代码去复制 Controller
- 单表去复制单表 Controller (比如 question => post)
- 关联表去复制关联表 (比如 question_submit => post_thumb ) 也就是说 **用户 => 点赞** 可类比为 **用户 =>提交**
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
```java
/**
* 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;
}
```
# 3.前端
以开发前端页面为主:
1)用户注册页面
2)创建题目页面(管理员)
3)题目管理页面(管理员)
- 查看
- 删除
- 修改
- 快捷创建
4)题目列表页(用户)
5)题目详情页(在线做题页)
- 判题状态查看
6)题目提交列表页
扩展: 提交统计页、用户个人页
### 接入要用到的组件
先接入可能用到的组件,再去写页面,避免因为后续依赖冲突,整合组件失败带来的返工
#### Markdown 编辑器
为什么用 Markdown?
一套通用的文本编辑语法,可以在各大网站上统一标准 渲染出统一的样式 比较简单易学
推荐的Md编辑器 github-> bytemd
阅读官方文档,下载编辑器主题,以及gfm(表格支持)插件,highlight 代码高亮插件

新建MdEditor 组件,编写代码
```
父组件给子组件传值的方式
```
要把 MdEditor 当前输入的值暴露给父组件,便于父组件去使用, 同时也是提高组件的通用性,需要定义属性,要把 value 和 handleChange 事件交给父组件去管理:
MdEditor示例代码
```
```
#### 代码编辑器
微软官方: 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键即可获得代码模板
示例模板:
```
```

使用表单组件,先复制示例代码 再修改 **arco-design**的组件库
此处我们用到了嵌套表和动态增减表单
注意,我们自定义的代码编辑器组件不会被组件库识别,需要手动指定 value 和 handleChange 函数
#### 题目管理页面开发
1. 使用表格组件 找到自定义操作的示例
2. 查询数据
3. 定义表格列
4. 加载数据
5. 调整格式
比如 json 不好看 有两种方法调整
1. 使用组件库自带的方法,自动格式化 (更方便) 表格列 arco-design
2. 完全自定义渲染,想展示什么展示什么 (更灵活)
6. 添加删除 更新操作
删除后如何更新:可以使用 table 组件自带的 table methods
7. 优化
#### 更新页面开发
策略: 由于更新和创建都是相同的表单,所以完全没必要开发/复制2遍, 可以直接复用创建页面。
关键实现: 如何区分两个页面
1. 路由 (/add/question 和 /update/question)
2. 请求参数 (id=1..)
更新页面相比于创建页面, 多了2个改动:
1. 在加载页面时,更新页面需要加载出之前的数据
2. 在提交时,请求的地址不同
#### 第四期
#### 当前代码优化
1) 处理菜单项的权限控制和显示隐藏
通过 meta.hideInMenu 和 meta.access 属性控制
2) 管理页面分页问题的修复
核心实现: 在分页页号改变时, 触发 @page-change 事件,通过改变 searchParams 的值, 并且通过 watchEffect 监听 searchParams 的改便 (然后执行loadData 重新加载), 实现了页号变化时触发的重新加载
3) 修复刷新页面未登录问题
修改 access\index.ts 中的获取用户登录信息, 把登陆后的信息更新到 loginUser 变量上
```typescript
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) 左侧题目信息:
- tabs 切换展示内容
- 定义 MdViewer 组件展示题目内容
- 使用 description 组件展示判题配置
4) 使用 select 组件让用户选择编程语言
#### 判题机模块预开发
目的:跑通完整的业务流程
##### **梳理判题模块和代码沙箱的关系**
判题模块: 调用代码沙箱,把代码和输入交给代码沙箱去执行
代码沙箱: 只负责接受代码和输入, 返回编译运行的结果, 不负责判题 (代码沙箱 可作为一个独立的项目, 提供给其他需要执行代码项目去使用)
这两个模块完全解耦

**思考:为什么代码沙箱需要接受和输出一组运行用例**
前提: 我们的每一道题目有多组测试用例
如果是每个用例单独调用一次代码沙箱,会调用多次接口,需要多次网络传输,程序要多次编译,记录程序的执行状态(重复的代码 不重复编译)
这是一种很常见的性能优化方法!(批处理)
## 开发代码沙箱模块
1) 定义代码沙箱的接口 提高通用性
之后我们的代码只需要调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不需要去修改名称了 便于扩展
扩展:可以增加一个查看代码沙箱状态的接口
2)定义多种不同的代码沙箱实现
示例代码沙箱:
远程代码沙箱:
第三方代码沙箱: github => go-judge
**小知识 - LomBok Builder 注解**
实体类加上:
```
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {
private List 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 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) 使用工厂模式, 传入用户的字符串参数,来生成对应的代码沙箱实现类
此处用的时静态工厂模式 使用简单 符合需求
```java
/**
* 代码沙箱工厂
* 作用: 根据字符串创建指定的代码沙箱示例
*/
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 配置文件中配置
```yml
# 代码沙箱配置
codesandbox:
type: example
```
在 spring 的 bean 中通过 @Value 注解读取
```
@Value("${codesandbox.type:example}")
private String type;
```
6) 代码沙箱能力增强
比如: 我们需要在调用代码沙箱前,输出请求参数的日志; 在代码沙箱调用后,输出响应结果日志,便于管理员去分析
显然 每一个代码沙箱类的前后 写一个 log.info 是不合理的 这里引入代理模式
使用代理模式, 提供一个 proxy 来增强代码沙箱的能力
使用代理后: 不仅不用改变原本的代码沙箱实现类, 而且对调用这来说,调用机会方式没有改变,也不需要在每个调用代码沙箱的地方去写统计代码。
7)实现示例的代码沙箱
```java
/**
* 示例代码沙箱 (仅为了跑通业务流程)
*/
public class ExampleCodeSandBox implements CodeSandBox {
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
List 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;
}
}
```
### 判题服务完整业务流程实现
判题服务业务流程:
1. 传入题目提交 id, 获取对应的题目、 提交信息(包含代码 、 编程语言等)
2. 如果题目的提交状态不为等待中、 就不用重复提交执行了
3. 更改判题 (题目提交) 的状态为 "判题中" , 防止重复执行, 也能让用户即时看到状态
4. 调用沙箱、 获取执行结果
5. 根据沙箱的执行结果、 设置题目的判题状态和信息
判断逻辑:
1. 先判断沙箱执行的结果 输出数量是否和预期输出数量相等
2. 依次判断每一项输出和预期输出是否相等
3. 判断题目的限制是否符合要求
4. 可能还有其他的异常情况
#### **策略模式优化判题代码**
我们的判题策略会有很多种, 比如:我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判断
```java
/**
* 判题管理 (简化调用)
*/
@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";
}
}
```
## Java 原生实现代码沙箱
原生: 尽可能不借助第三方库和依赖,用最干净、最原始的方式实现代码沙箱
代码沙箱需要: 接收代码 => 编译代码(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
1. 把用户的代码保存为文件
2. 编译代码, 得到class文件
3. 执行代码, 得到输出结果
4. 收集整理输出结果
5. 文件清理
6. 错误处理,提升程序健壮性
##### 1.把用户的代码保存为文件
引入Hutool 工具类
```
cn.hutool
hutool-all
5.8.8
```
新建目录,把每个用户的代码存放在独立目录中 便于维护
##### 编译代码, 得到class文件
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);
}
```
编写一个工具类,执行进程,并且获取输出
```java
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
```java
//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 模式 , 需要和用户进行交互, 让用户不断的输入内容并输出比如:
```java
```
对于此类程序,我们需要使用 OutputStream 想程序中断发送参数 并及时获取结果
注意关闭流
```java
/**
* 执行交互式进程并获取信息
* @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
```java
StopWatch stopWatch = new StopWatch();
stopWatch.start();
stopWatch.stop();
executeMessage.setTime(stopWatch.getLastTaskTimeMillis());
```
此处我们使用最大来统计时间:
>扩展:可以每个测试用例都有一个独立的内存, 时间占用的统计
##### 5.文件清理
防止服务器空间不足:
```java
if(userCodeFile.getParentFile()!=null){
boolean del = FileUtil.del(userCodeParentPath);
System.out.println("删除"+(del ? "成功" : "失败"));
}
```
##### 6. 错误处理
封装一个错误处理方法,当程序抛出异常时,直接返回错误响应
```java
/**
* 获取错误响应
*
* @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;
}
```
## 异常情况演示
到目前为止, 核心流程已经实现, 但是想要上线的话 是不安全的
用户会提交恶意代码 怎么办?
#####
### 1) 执行阻塞 占用资源不释放
时间上搞
```java
/**
* 无限睡眠 (程序阻塞)
*/
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 报名去掉
### 2) 占用内存,不释放
空间上搞
```java
package com.yuoj.yuojcodesandbox.unsafeCode;
import java.util.ArrayList;
public class MemoryError {
public static void main(String[] args) {
ArrayList 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目录。
### 3) 读文件 信息泄露
直接通过相对路径获取文件信息
``` java
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 allLines = Files.readAllLines(Paths.get(filePath));
System.out.println(String.join("\n",allLines));
}
}
```
### 4) 写文件 , 越权植入木马
假设有一个木马程序: java -version 2>&1
```java
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("木马植入成功, 你完了 哈哈");
}
}
```
### 5) 执行电脑上的程序
操作电脑上的其他程序
```java
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("木马程序执行成功");
}
}
```
### 6) 执行高危命令
甚至都不用写木马程序,直接执行系统自带的高危命令
比如删除服务器的所有文件(残暴的一批)
比如执行 dir(windows) 、 ls(Linux) 获取你系统上的所有文件信息
### 解决方案:
#### 1) 超时控制
通过创建一个守护线程, 超时后终端process实现
```java
//超时控制
new Thread(()->{
try {
Thread.sleep(TIME_OUT);
System.out.println("超时了。。中断");
process.destroy();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
```
#### 2)限制给用户分配的资源
我们不能让每个 java 进程的执行占用的 JVM 最大堆内存空间都和系统的一致,实际上应该小一点,比如说256MB
在启动 Java 时 可以指定 JVM 的参数: -Xmx256M (最大堆空间大小) -Xms(初始堆空间大小)
```java
String compileCmd = String.format("javac -Xmx256m -encoding utf-8 %s", userCodeFile.getAbsoluteFile());
```
注意: -Xmx 参数。 JVM的堆内存限制,不等同于系统实际占用的最大资源,可能会超出。
如果需要更严格的内存限制,要在系统层面去限制,而不是JVM层面的限制。
如果是Linux 系统, 可以使用 cgroup 来实现对某个进程的 CPU、内存等资源的分配
#### 3)限制代码 - 黑白名单
初始化字典树
Hutool 字典树工具类: WordTree, 可以用更少的空间存储更多的敏感词汇,实现更高效的敏感词查找
```java
private static final List blackList = Arrays.asList("Files","exec");
private static final WordTree WORD_TREE;
static {
//初始化字典树
WORD_TREE = new WordTree();
WORD_TREE.addWords(blackList);
}
```
```java
//校验黑名单代码
FoundWord foundWord = WORD_TREE.matchWord(code);
if (foundWord!=null){
System.out.println(foundWord.getFoundWord());
return null;
}
```
使用字典树可以节约空间

>可以写在简历上
缺点:
1. 我们无法遍历所有的黑名单
2. 不同的编程语言, 不同的领域、关键词都不一样,限制人工成本很大
#### 4)限制用户的操作权限(文件,网络,执行)
限制用户对文件、内存、CPU、网络等资源的操作和访问
Java 安全管理器 (Security Manager)来实现更严格的限制
```java
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);
}
}
```
```java
```
```java
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程序时,指定安全管理器的路径、安全管理器的名称。
```java
-Djava.security.manager=MySecurityManager Main %s
```
**优点**
权限控制灵活,实现简单
**缺点:**
1. 如果要做比较严格的权限限制,需要自己去判断那些文件,包名允许读写,粒度太细了,难以精细化控制
2. 安全管理器本身也是java代码, 也有可能存在漏洞(本身也是程序上的限制,没到系统层面)
#### 5)运行环境隔离
系统层面上,把用户程序封装到沙箱里, 和宿主机(我们的电脑/服务器) 隔离开
Docker 容器技术能够实现(底层是用 cgroup,namespace 等方式实现的)
## 第六期,Docker
为什么要用 Docker 容器技术? 为了提升系统的安全性 把程序和宿主机隔离
Docker 技术可以实现和宿主机的隔离
什么是容器?
理解为一系列得到应用程序、服务和环境的封装,从而把程序运行在一个隔离的、密闭的、隐私的空间内、对外整体提供服务
### Docker 基本概念
镜像:用来创建容器的安装包,可以理解为给电脑安装操作系统的系统镜像
容器:通过镜像来创建一套运行环境,一个容器里可以运行多个程序,可以理解为一个电脑实例
Dockerfile: 制作镜像的文件,可以理解为制作镜像的一个清单

镜像仓库: 存放镜像的仓库,用户可以从仓库下载线程的镜像,也可以把做好的镜像放到仓库里
推荐使用 docker 官方的镜像仓库
### Docker实现原理
看图理解:
1)Docker 运行在 Linux 内核上
2)CGroups: 实现了容器的资源隔离,底层是 Linux CGroup 命令,能够控制进程使用的资源
3)NetWork网络:实现容器的网络隔离,docker容器内部的网络互不影响
4)Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同命名空间下的进程互补影响。
5)storage:存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件


### 安装Docker
一般情况下,不建议在 Windows 系统上安装
Windows 本身就是带了个虚拟机 叫 WSL,不建议,不如一个专业的 隔离的虚拟机软件要方便
Linux使用的是 Ubuntu系统
使用免费的 VMWare Workstation Player 软件:
### Docker 常用操作 - 命令行
#### 如果启动失败
1)增加权限
2)重启虚拟机! 重启远程开发环境! 重启程序!
1)查看命令方法
```
docker --help
```
查看具体子命令的方法:
```
docker run --help
```
2) 从远程仓库拉取现成的镜像
用法:
```shell
docker pull [OPTIONS] NAME[:TAG|@DIGEST]
```
```
docker pull hello-world
```
3)根据镜像创建容器实例:
启动实例,得到容器实例 containerId:
```SHELL
sudo docker create hello-world
```
4) 查看容器状态:
```shell
sudo docker ps -a
```
5) 启动容器:
```shell
docker start [OPTIONS] CONTAINER [CONTAINER...]
```
启动实例:
```shell
sudo docker start confident_banzai
```
6) 查看日志
```shell
docker logs[OPTIONS] CONTAINER
```
启动日志
```shell
sudo docker logs confident_banzai
```
7) 删除容器实例
```shell
sudo docker rm confident_banzai
```
8) 删除进项
```shell
docker rmi --help
```
### Java 操作 Docker
使用 docker-java: github 搜索
引入pom依赖
```xml
com.github.docker-java
docker-java
3.3.0
com.github.docker-java
docker-java-transport-httpclient5
3.3.0
```
DockerClientConfig: 用于定义初始化 DockerClient 的配置(类比 MYSQL 的连接,线程数配置)
DockerHttpClient: 用于向 Docker 的守护进程 (操作 Docker 的接口) 发送请求的客户端,低层封装 不推荐使用, 自己构建请求参数 (类比 JDBC)
DockerClient(推荐): 才是真正和 Docker 守护进程交互的 最方便的 SDK,高层封装 是对 DockerHttpCLient 再去进行了一层封装(理解为 Mybatis) 提供了增删改查
## Linux Docker 远程开发
使用 IDEA Development 先上传代码到Linux,然后使用 JetBrains 远程开发完全连接Linux 实时开发
如果无法启动程序,修改 setting 的compiler配置
```
-Djdk.lang.Process.launchMechanism=vfork
```
拉取镜像:
```java
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("下载完成");
}
```
创建容器:
```java
//创建容器
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
CreateContainerResponse createContainerResponse = containerCmd
.withCmd("echo","Hello docker")
.exec();
System.out.println(createContainerResponse);
```
查看容器状态:
```java
//查看容器状态
ListContainersCmd listContainersCmd = dockerClient.listContainersCmd();
List containerList = listContainersCmd.withShowAll(true).exec();
for (Container container : containerList) {
System.out.println(container);
}
```
启动容器:
```java
//启动容器
dockerClient.startContainerCmd(containerId).exec();
```
启动日志
```java
// 查看日志
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();
```
删除容器
删除镜像
```java
//删除容器
dockerClient.removeContainerCmd(containerId).withForce(true).exec();
//删除镜像
dockerClient.removeImageCmd(containerId).exec();
```
### Docker 实现代码沙箱
实现流程:docker 负责运行java程序 并且得到结果
1. 把用户的代码保存为文件
2. 编译代码, 得到class文件
3. 把编译好的文件上传到容器环境内
4. 在容器中执行代码, 得到输出结果
5. 收集整理输出结果
6. 文件清理
7. 错误处理,提升程序健壮性
```
`扩展:模板方法设计模式,定义同一套实现流程,让不同的子类去负责不同流程中的具体实现,执行步骤一样,但每个步骤的实现方式不一样
```
#### 3.创建容器, 上传编译文件
自定义容器的两种方式:
1)在已有镜像的基础上再扩充:比如拉取线程的 Java环境 (包含jdk),再把编译后的文件复制到容器里,适合新项目、跑通流程
2)完全自定义容器: 适合比较成熟的项目, 比如封装多个语言的环境和实现
思考: 我们每个测试用例都单独创建一个容器,每个容器只执行一次 java 命令吗?
不是的 这样会浪费性能, 所有要创建一个可交互的容器,能够接收多次输入并且输出
创建容器时,可以指定文件路径(Volume) 映射, 作用是把本地的文件同步到容器中, 可以让容器访问。 容器挂在目录
#### 4. 启动容器 执行代码
Docker 执行容器命令 (操作已启动容器):
```shell
Usage: docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
```
```she
docker exec kenn_blackwell java -cp /app Main 1 3
```
注意,要把命令按照空格拆分,作为一个数组传递,否则可能会被识别为一个字符串 而不是多个参数
创建命令:
```java
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withAttachStderr(true)
.withAttachStdin(true)
.withAttachStdout(true)
.withTty(true)
.exec();
```
执行命令:
```java
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 在执行前后统计时间。
获取程序占用内存:
```java
// 监控占用的内存 获取占用的内存
StatsCmd statsCmd = dockerClient.statsCmd(containerId);
ResultCallback statisticsResultCallback = statsCmd.exec(new ResultCallback() {
@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
```
#### Docker 容器安全
1) 超时控制
执行容器时,可以增加超时参数控制
```java
dockerClient.execStartCmd(execId)
.exec(execStartResultCallback)
.awaitCompletion(TIME_OUT, TimeUnit.MICROSECONDS);
```
但是,这种方式无论超时与否,程序都会继续往下执行,无法判断是否超时。
可以定义一个超时标志, 如果程序执行完成, 就把这个超时标志设置为Fakse
2) 内存控制
通过HostConfig 进行限制
```java
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) 网络资源
创建容器时,设置网络配置为关闭:
```java
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
```
4) 权限管理配置
Docker 容器已经做了系统层面的隔离, 比较安全,但不能保证绝对安全
1. 结合 Java 安全管理器和其他策略去使用
2. 限制用户不能向 根目录root 去写文件
```java
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
.withReadonlyRootfs(true)
```
3. Linux 自带的一些安全管理措施 seccomp(Security Compute Mode) Linux内核安全机制
# 微服务模块开发
## 模板方法优化代码沙箱
模板方法; 定义一套通用的执行流程,让子类负责每个执行步骤的具体实现
模板方法的适用场景: 使用于有规范的流程,且执行流程可以复用
作用: 大幅节省重复代码,便于项目扩展,更好维护
### 1.抽象出具体的流程
定义模板方法抽象类。
先复制具体的实现类, 把代码从完整的方法抽离成一个一个的子写法
```java
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
// System.setSecurityManager(new DenySecurityManager());
List 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 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 查看折叠**
### 3.定义子类的具体实现
Java原生代码沙箱的实现,直接复用模板方法定义好的方法实现
```java
/**
* Java 原生代码沙箱实现(直接复用模板方法)
*/
public class JavaNativeCodeSandbox extends JavaCodeSandboxTemplate {
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
return super.executeCode(executeCodeRequest);
}
}
```
Docker实现代码沙箱 使用模板方法实现
```java
/**
* 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 runFile(File userCodeFile, List 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 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 statisticsResultCallback = statsCmd.exec(new ResultCallback() {
@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;
}
}
```
### 2.给代码沙箱提供开放 API
直接再controller层暴露接口
```java
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest){
if (executeCodeRequest == null){
throw new RuntimeException("请求参数为空");
}
return javaNativeCodeSandbox.executeCode(executeCodeRequest);
}
```
#### 调用安全性
如果将服务不做任何的权限校验,直接发到公网,是不安全的
1) 调用方与服务方之间约定一个字符串, **(最好加密)**
优点: 实现最简单,比较适合内部系统之间的相互调用(相对可信的内部调用)
缺点: 不够灵活, 如果 key 泄露或变更 , 需要重启代码
代码沙箱服务, 先定义约定的字符串:
```java
public static final String AUTH_REQUEST_HEADER="auth";
public static final String AUTH_REQUEST_SECRET="secretKey";
```
改造请求,从请求头中获取认证信息,并校验
```java
/**
* 执行代码
*
* @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);
}
```
调用方(也需要定义约定好的字符串):
```java
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 Alibaba
本质: 是在 Spring Cloud 的基础上,进行了增强,补充了一些额外的能力,根据阿里多年的业务沉淀做出了一些定制化的开发
1. ```
1. Spring Cloud Gateway:网关
2. Nacos:服务注册和配置中心
3. Sentinel:熔断限流
4. Seata:分布式事务
5. RocketMQ:消息队列,削峰填谷
6. Docker:使用Docker进行容器化部署
7. Kubernetes:使用k8s进行容器化部署
```

**注意:** 一定要选择对应的版本

Nacos:集中存管项目中所有服务的信息,便于服务之间找到彼此;同时,还支持集中存储整个项目中的配置
整个微服务请求流程:

Discovery 另一个分布式微服务框架
###
#### 改造前思考
从业务需求出发,思考单机和分布式锁的区别。
用户登录功能:需要改造为分布式登录
其他内容
- 有没有用到单机的锁? 改造为分布式锁(伙伴匹配)
- 有没有用到本地缓存?改造为分布式缓存(Redis)
- 需不需要用到分布式事务?比如操作多个库
#### 改造分布式登录
1) application.yml 增加 redis 配置
2) 补充依赖
3 ) 主类取消 Redis 自动配置的移除
4) 修改 session 存储方式:
```yml
session:
# todo 取消注释开启分布式 session(须先配置 Redis)
store-type: redis
# 30 天过期
timeout: 2592000
```
5)使用 redis-cli 或者 redis 管理工具,查看是否有登录后的信息

#### 微服务的划分
从业务出发,想一下那些功能/ 职责是一起的?
```|
公司老板给员工分工
```
依赖服务:
- 注册中心: Nacos
- 微服务网关(yuoj-backed-gateway): Gateway 聚合所有的接口,统一接受处理前端的请求
公共模块:
- common 公共模块(yuoj-backed-common): 全局异常处理器,请求响应封装类、公用的工具类等
- model 模型模块(yuoj-backed-model): 很多服务都有公用的实体类
- 公用接口模块(yuoj-backed-service-client): 只存放接口,不存放实现(多个服务之间要共享)
1 用户服务(yuoj-backed-user-service : 8102 端口)
- 注册
- 登录
- 用户管理
2 题目服务(yuoj-backed-question-service: 8103 端口)
- 创建题目(管理员)
- 删除题目(管理员)
- 修改题目(管理员)
- 搜索题目(管理员)
- 在线做题(题目详情页)
- 题目提交(管理员)
3 . 判题模块((yuoj-backed-judge-service: 8104 端口)较重的操作)
- 执行判题逻辑
- 错误处理(内存溢出,安全性,超时)
- 自主实现代码沙箱
- 开放接口
```\
代码沙箱服务本身就是独立在这个项目以外的,可以不用纳入 Spring Cloud 的管理
```
#### 路由的划分
用 springboot 的 context-path 统一修改各项目的前缀接口, 比如:
用户服务:
- /api/user
- /api/user/inner (内部调用,网关层面要做限制)
题目服务:
- /api/question
- /api/question/inner (内部调用,网关层面要做限制)
判题服务:
- /api/judge
- /api/judge/inner (内部调用,网关层面要做限制)
### Nacos 注册中心启动 2.2.0 版本
在bin目录下输入
```
startup.cmd -m standalone
```
默认账号面膜 nacos nacos
### 新建工程
Spring Cloud 有相当多的依赖,参差不齐,不建议随意找配置, 或者自己写
建议用脚手架创建项目
给项目增加全局依赖配置文件。
使用spring-cloud-alibaba 也需要引入spring-cloud 相关的依赖,并且版本要对应
```
org.springframework.cloud
spring-cloud-dependencies
2021.0.5
pom
import
```
openFeign : 模块之间相互引用的组件
依次使用 new modules 和 spring boot Initializr 创建各模块:

需要给字符模块之间绑定父子依赖关系:
父模块声明
```
yuoj-backend-common
yuoj-backend-model
yuoj-backend-gateway
yuoj-backend-service-client
yuoj-backend-judge-service
yuoj-backend-user-service
yuoj-backend-question-service
```
子模块引入 parent
```
com.yupi
yuoj-backed-microservice
0.0.1-SNAPSHOT
```
通过继承父模块配置,统一项目的定义和版本号
### 同步代码和依赖
1)common 公共模块(yuoj-backed-common): 全局异常处理器,请求响应封装类、公用的工具类等
在外层的xml中引入公共类
```xml
org.apache.commons
commons-lang3
com.google.code.gson
gson
2.9.1
com.alibaba
easyexcel
3.1.1
cn.hutool
hutool-all
5.8.8
org.apache.commons
commons-collections4
4.4
com.baomidou
mybatis-plus-boot-starter
3.5.2
```
2) model 模型模块(yuoj-backed-model): 很多服务都有公用的实体类
3) 公用接口模块(yuoj-backed-service-client): 只存放接口,不存放实现(多个服务之间要共享)
先无脑搬运所有service 在剔除 judgeService 也需要粘

无法识别的版本可以取父依赖中寻找
4) 具体业务服务实现
给所有业务服务引入依赖
```xml
com.yupi
yuoj-backend-common
0.0.1-SNAPSHOT
com.yupi
yuoj-backend-model
0.0.1-SNAPSHOT
com.yupi
yuoj-backend-service-client
0.0.1-SNAPSHOT
```
引入主类注解
引入 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
```
注意事项:
1. 要给接口的每个方法打上请求注解,注意区分 Get,Post
2. 要给请求参数打上注解,比如 RequestParam、 RequestBody
3. FeignClient 定义的请求路径一定要和服务提供方实际的请求路径保持一致
4)修改各业务服务的调用代码为 feignClient
5) 编写 feignClient 客户端服务的实现类
```java
@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依赖 。然后给所有服务(包括网关) 添加如下配置
```yml
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
```
给业务服务项目启动类上打上注解,开启服务发现,找到对应的客户端 Bean 的位置
```java
@EnableDiscoveryClient
@EnableFeignClients(basePackages = {"com.yupi.yuojbackendserviceclient.service"})
```
全局引入负载均衡依赖
```xml
org.springframework.cloud
spring-cloud-loadbalancer
3.1.5
```
### 微服务网关
微服务网关(yuoj-backed-gateway): Gateway 聚合所有的接口,统一接受处理前端的请求
是什么?
为什么要用?
- 所有的服务端口不同,增大了前端调用成本
- 所有服务是分散的,你可以集中进行管理,操作,比如集中解决跨域,鉴权,接口文档,服务的路由,接口安全性、流量染色、限流
```|
Gateway: 想自定义一些功能,需要对这个基数有比较深的理解
```
Gateway 是应用层网关: 会有一定的业务逻辑(比如根据用户信息判断权限)
Nginx 是接入层网关: 比如每个请求的日志,通常没有业务逻辑
#### 接口路由
统一地接受前端的请求,转发请求到对应的服务
如何找到路由? 可以编写一套路由, 通过 api 地址前缀来找到对应的服务
```yml
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) 先给所有业务服务引入依赖,同时开启接口文档的配置
```xml
com.github.xiaoymin
knife4j-openapi2-jakarta-spring-boot-starter
4.3.0
```
```xml
Knife4j:
enable: true
```
2) 给网关配置集中管理接口文档
引入依赖
```xml
com.github.xiaoymin
knife4j-gateway-spring-boot-starter
4.3.0
```
引入配置
```xml
knife4j:
gateway:
# ① 第一个配置,开启gateway聚合组件
enabled: true
# ② 第二行配置,设置聚合模式采用discover服务发现的模式
strategy: discover
discover:
# ③ 第三行配置,开启discover模式
enabled: true
# ④ 第四行配置,聚合子服务全部为Swagger2规范的文档
version: swagger2
```
地址:http://localhost:8101/doc.htm
必须引入分布式依赖
```xml
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.session
spring-session-data-redis
```
解决cookie 的跨路径问题
#### 跨域解决
```java
@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
·
交换机
```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 + "'");
}
}
}
}
```