# zan-oj-study-backend
**Repository Path**: zan421/zan-oj-study-backend
## Basic Information
- **Project Name**: zan-oj-study-backend
- **Description**: zan-oj-study-backend OJ在线判题系统项目
- **Primary Language**: Java
- **License**: AGPL-3.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2024-05-19
- **Last Updated**: 2024-07-08
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
zan-oj-study-backend(ZanOJ在线判题系统)后端

> 作者:[Zan421](https://gitee.com/zan421)
# 一、项目介绍,项目调研,需求分析
## 1.1 项目介绍
> OJ = Online Judge 在线判题评测系统
>
> 用户可以选择题目,在线做题,编写代码并且提交代码;系统会对用户提交的代码,根据我们出题人设置的答案,来判断用户的提交结果是否正确。
>
> ACM(程序设计竞赛),也是需要依赖判题系统来检测参赛者的答案是否合理
>
> 
>
> OJ 系统最大的难点在于用于在线评测编程题目代码的系统,能够根据用户提交的代码、出题人预先设置的题目输入和输出用例,进行编译代码、运行代码、判断代码运行结果是否正确。
### 1.1.1 OJ 系统的常用概念
ac 标识你的题目通过,结果正确
题目限制:时间限制、内存限制
题目介绍
题目输入输出
题目输入输出用例
普通测评:管理员设置题目的输入和输出用例,比如我输入 1,你要输出 2 才是正确的;交给判题机去执行用户的代码,给用户的代码喂输入用例,比如
1,看用户程序的执行结果是否和标准答案的输出一致(比对用例文件)
特殊测评(SPJ):管理员设置题目的输入和输出,比如我输入 1,用户的答案只要是 > 0 或 <
2都是正确的;特判程序,不是通过对比用例文件是否一致这种死板的程序来检验,而是要专门根据这道题目写一个特殊的判断程序,程序接收题目的输入(1)、标准输出用例(2)、用户的结果(1.5),特判程序根据这些值来比较是否正确
交互测评:让用户输入一个例子,就给一个输出结果,交互比较灵活,没办法通过简单的、死板的输入输出文件来搞定
## 1.2 项目调研
https://github.com/HimitZH/HOJ(适合学习)
https://github.com/QingdaoU/OnlineJudge(python,不好学,很成熟)
https://github.com/hzxie/voj(星星没那么多,没那么成熟,但相对好学)
https://github.com/vfleaking/uoj(php 实现的)
https://github.com/zhblue/hustoj(成熟,但是 php)
https://github.com/hydro-dev/Hydro(功能强大,Node.js 实现)
### 1.2.1 实现核心
> (1)权限校验
>
> 谁能提交代码,谁不能提交代码
>
>
>
> **(2)代码沙箱(安全沙箱)**
>
> 由于要跑用户的代码(有风险)
>
> 用户代码藏毒:写个木马文件、修改系统权限
>
> 沙箱:隔离的、安全的环境,用户的代码不会影响到沙箱之外的系统的运行
>
> 资源分配:系统的内存就2G,用户直接无限for循环或者new对象疯狂占用资源占满你的内存,导致其他人就用不了了。因此要限制用户程序的占用资源
>
>
>
> (3)判题规则
>
> 题目用例的比对,结果的验证
>
>
>
> (4)任务调度(判题时)
>
> 服务器资源有限,用户需要排队,按照顺序去依次执行判题,而不是直接拒绝
### 1.2.2 项目扩展思路
> 1. 支持多种语言
> 2. Remote Judge
> 3. 完善的评测功能:普通评测、特殊评测、交互评测、在线自测、子任务分组评测、文件IO
> 4. 统计分析用户判题记录
> 5. 权限校验
# 二、核心业务流程

> 为啥要编译?
>
> 因为有些语言不编译不能运行

判题服务:获取题目信息、预计的输入输出结果,返回给主业务后端:用户的答案是否正确
代码沙箱:只负责运行代码,给出结果,不管什么结果都是正确的
实现了解耦
# 三、项目要做的功能(功能模块)
> 1. 题目模块
>
> a. 创建题目(管理员)
>
> b. 删除题目(管理员)
>
> c. 修改题目(管理员)
>
> d. 搜索题目(用户)
>
> e. 在线做题
>
> f. 提交题目代码
>
> 2. 用户模块
>
> a. 注册
>
> b. 登录
>
> 3. 判题模块
>
> a. 提交判断(结果是否正确与判断)
>
> b. 错误处理(内存溢出、安全性、超时)
>
> c. **自主实现** 代码沙箱(安全沙箱)
>
> d. 开放接口(提供一个独立的新服务)
# 四、技术选型(技术预研)
> 前后端全栈
>
> 前端:Vue3、Arco Design 组件库、手撸项目模板、在线代码编辑器、在线文档浏览
>
> 后端:Java进程控制、Java安全管理器、部分JVM知识点、虚拟机(云服务器)、Docker(代码沙箱实现)、Spring Cloud 微服务、消息队列
## 4.1 架构设计

## 4.2 主流的OJ系统实现方案
- 开发原则:能用别人现成的,就不要自己写
(1)用现成的OJ系统,比如 judge0:https://github.com/judge0/judge0 (商业成熟)
可以自己用源码来部署,公有云,私有云
(2)用现成的判题API(judge0 https://rapidapi.com/judge0-official/api/judge0-ce ),现成的代码沙箱
> API作用:接收代码返回结果
>
> 先注册 -> 开通订阅 -> 测试language接口 -> 测试执行代码接口submission
>
> https://ce.judge0.com/#submissions-submission-post
- 实例接口参数
```json
{
"language_id": 52,
"source_code": "I2luY2x1ZGUgPHN0ZGlvLmg+CgppbnQgbWFpbih2b2lkKSB7CiAgY2hhciBuYW1lWzEwXTsKICBzY2FuZigiJXMiLCBuYW1lKTsKICBwcmludGYoImhlbGxvLCAlc1xuIiwgbmFtZSk7CiAgcmV0dXJuIDA7Cn0=",
"stdin": "SnVkZ2Uw"
}
```
- 预期返回
```json
{
"source_code": "I2luY2x1ZGUgPHN0ZGlvLmg+CgppbnQgbWFpbih2b2lkKSB7CiAgY2hhciBu\nYW1lWzEwXTsKICBzY2FuZigiJXMiLCBuYW1lKTsKICBwcmludGYoImhlbGxv\nLCAlc1xuIiwgbmFtZSk7CiAgcmV0dXJuIDA7Cn0=\n",
"language_id": 52,
"stdin": "SnVkZ2Uw\n",
"expected_output": null,
"stdout": "aGVsbG8sIEp1ZGdlMAo=\n",
"status_id": 3,
"created_at": "2024-05-20T08:13:33.919Z",
"finished_at": "2024-05-20T08:13:34.208Z",
"time": "0.001",
"memory": 940,
"stderr": null,
"token": "9e3b9f3f-7fa1-46da-b38c-53d11f81d6ab",
"number_of_runs": 1,
"cpu_time_limit": "5.0",
"cpu_extra_time": "1.0",
"wall_time_limit": "10.0",
"memory_limit": 128000,
"stack_limit": 64000,
"max_processes_and_or_threads": 60,
"enable_per_process_and_thread_time_limit": false,
"enable_per_process_and_thread_memory_limit": false,
"max_file_size": 1024,
"compile_output": null,
"exit_code": 0,
"exit_signal": null,
"message": null,
"wall_time": "0.001",
"compiler_options": null,
"command_line_arguments": null,
"redirect_stderr_to_stdout": false,
"callback_url": null,
"additional_files": null,
"enable_network": false,
"status": {
"id": 3,
"description": "Accepted"
},
"language": {
"id": 52,
"name": "C++ (GCC 7.4.0)"
}
}
```
(3)自主开发
(4)把AI来当作代码沙箱
(5)移花接木
可以通过操作浏览模拟器的方式,用别人的OJ帮你判题
比如:无头浏览器,像人一样再别人的项目中提交代码,并且获取结果
# 五、项目初始化
- 前端/后端初始化模板
# 六、项目开发
## 6.1 判题模块和代码沙箱
### 6.1.1 两者的关系
> 判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行
>
> 代码沙箱:只负责接收代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目 / 服务,提供给其他的需要执行代码的项目去使用)
>
> 这两个模块完全解耦
>
>
**思考:为什么代码沙箱要接受和输出一组运行用例**
前提:我们的每道题目有多组测试用例
- 如果是每个用例单独调用一次代码沙箱,会调用多次接口、需要多次网络传输、程序要多次编译、记录程序的运行状态(重复的代码不重复编译)
> 这是一种很常见的性能优化方法(批处理)
## 6.2 代码沙箱架构开发
(1)定义代码沙箱的接口,提高通用性
之后我们的项目代码只调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不用去修改名称了,便于扩展
> 代码沙箱的请求接口中,timeLimit可加可不加,可自行扩展,及时中断程序
扩展思考:代码可以增加一个查看代码沙箱状态的接口
(2)定义多种不同的代码沙箱实现
- 示例代码沙箱
- 远程代码沙箱
- 第三方代码沙箱:https://github.com/criyle/go-judge/blob/master/README.cn.md
(3)编写单元测试,验证单个代码沙箱的执行
```java
@Test
void executeCode() {
CodeSandBox codeSandBox = new ExampleCodeSandBox();
String code = "int main() {}";
String language = QuestionSubmitLanagugeEnum.JAVA.getValue();
List inputList = Arrays.asList("1 2", "3 4");
ExecuteCodeRequest codeRequest = ExecuteCodeRequest.builder()
.code(code)
.language(language)
.inputList(inputList)
.build();
ExecuteCodeResponse executeCodeResponse = codeSandBox.executeCode(codeRequest);
Assertions.assertNotNull(executeCodeResponse);
}
```
但是现在的问题是,我们把 new 某个沙箱的代码写死了,如果后面项目要改用其他沙箱,可能要改很多地方的代码
(4)使用工厂模式,根据用户传入的字符串参数(沙箱类别),来生成对应的代码沙箱实现类
此处使用静态工厂模式,实现比较简单,符合我们的需求
```java
/**
* @Author : Zan
* @Create : 2024/5/28 20:55
* @ClassName : CodeSandBoxFactory
* @Description : 代码沙箱创建工厂(根据字符串参数创建指定的代码沙箱实例)
*/
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();
}
}
}
```
> 扩展思路:如果确定代码沙箱实例不会出现线程安全问题、可复用,那么可以使用单例工厂模式
- 由此,我们可以根据字符串动态生成实例,提高了通用性
```java
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String type = scanner.next();
CodeSandBox codeSandBox = CodeSandBoxFactory.newInstance(type);
String code = "int main() {}";
String language = QuestionSubmitLanagugeEnum.JAVA.getValue();
List inputList = Arrays.asList("1 2", "3 4");
ExecuteCodeRequest codeRequest = ExecuteCodeRequest.builder()
.code(code)
.language(language)
.inputList(inputList)
.build();
ExecuteCodeResponse executeCodeResponse = codeSandBox.executeCode(codeRequest);
Assertions.assertNotNull(executeCodeResponse);
}
}
```
(5)参数配置化,把项目中的一些可以交给用户去自定义的选项或字符串,写到配置文件中,这样开发者只需要改配置文件,而不需要去看你的项目代码,就能够自定义使用你的代码。
- application.yml配置文件中指定变量
```yaml
# 代码沙箱配置
codesandbox:
type: example
```
- 在Spring的Bean中通过 @Value 注解
```java
@Value("${codesandbox.type:example}")
private String type;
```
(6)代码沙箱能力增强
比如:我们需要在调用代码沙箱前,输出请求参数日志;在代码沙箱调用后,输出响应结果日志,便于管理员去分析
每个代码沙箱都写一遍 log.info ?难道每次调用代码沙箱前后都执行 log ?
- 使用代理模式,提供一个Proxy类来增强代码沙箱的能力
原本:需要用户自己去调用多次

使用代理后:不仅不用改变原本的代码沙箱实现类,而且对于调用者来说,调用方式基本没有改变,也不需要在每个调用沙箱的地方去写统计代码

代理模式的实现原理:
- 实现被代理的接口
- 通过构造函数接收一个可被代理的接口实现类
- 调用被代理的接口实现类,在调用前后增加对应的操作
示例代码:
```java
@Slf4j
public class CodeSandBoxProxy implements CodeSandBox {
private final CodeSandBox codeSandBox;
public CodeSandBoxProxy(CodeSandBox codeSandBox) {
this.codeSandBox = codeSandBox;
}
/**
* 增强方法
*
* @param executeCodeRequest
* @return
*/
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
log.info("代码沙箱请求信息:" + executeCodeRequest.toString());
ExecuteCodeResponse executeCodeResponse = codeSandBox.executeCode(executeCodeRequest);
log.info("代码沙箱返回信息:" + executeCodeResponse.toString());
return executeCodeResponse;
}
}
```
(7)实现示例的测试类
```java
/**
* @Author : Zan
* @Create : 2024/5/28 20:39
* @ClassName : ExampleCodeSandBox
* @Description : 示例代码沙箱(为跑通程序)
*/
public class ExampleCodeSandBox implements CodeSandBox {
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
System.out.println("示例代码沙箱");
List inputList = executeCodeRequest.getInputList();
ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();
executeCodeResponse.setOutputList(inputList);
executeCodeResponse.setMessage("测试执行成功");
executeCodeResponse.setStatus(QuestionSubmitStatusEnum.SUCCESS.getValue());
JudgeInfo judgeInfo = new JudgeInfo();
judgeInfo.setMessage(JudgeInfoMessageEnum.ACCEPTED.getText());
judgeInfo.setMemory(100L);
judgeInfo.setTime(100L);
executeCodeResponse.setJudgeInfo(judgeInfo);
return executeCodeResponse;
}
}
```
```java
@Test
void executeCodeByProxy() {
CodeSandBox codeSandBox = new ExampleCodeSandBox();
codeSandBox = new CodeSandBoxProxy(codeSandBox);
String code = "int main() {}";
String language = QuestionSubmitLanagugeEnum.JAVA.getValue();
List inputList = Arrays.asList("1 2", "3 4");
ExecuteCodeRequest codeRequest = ExecuteCodeRequest.builder()
.code(code)
.language(language)
.inputList(inputList)
.build();
ExecuteCodeResponse executeCodeResponse = codeSandBox.executeCode(codeRequest);
Assertions.assertNotNull(executeCodeResponse);
}
```
### 小知识 —— Lombok Builder注解
以前我们是使用 new 对象后,再逐行执行 set 方法的方法来给对象赋值的。
- 实体类加上 @Builder 等注解:
```java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {
private List inputList;
private String code;
private String language;
}
```
- 可以使用链式的方式更方便地给对象赋值
```java
ExecuteCodeRequest codeRequest = ExecuteCodeRequest.builder()
.code(code)
.language(language)
.inputList(inputList)
.build();
```
## 6.3 判题服务完整业务流程开发
- 定义单独的 judgeService 类,而不是把所有判题相关的代码写到 questionSubmitService里,有利于后续的模块抽离、微服务改造
### 6.3.1 判题服务业务流程
(1)传入题目的提交ID,获取到对应的题目、提交信息(包含代码、编程语言等)
(2)如果题目的提交状态不为”等待中“,就不用重复执行了
(3)更改判题(题目提交)的状态为“判题中”,防止重复提交,也能让用户看到状态
(4)调用沙箱,获取到执行结果
(5)根据沙箱的执行结果,设置题目的判题状态和信息
判断逻辑:
(1)先判断沙箱执行的结果输出数量是否和预期输出数量相等
(2)依次判断每一项输出和预期输出是否相等
(3)判断题目的限制是否符合要求
(4)可能还有其他的异常情况
### 6.3.2 策略模式优化判题代码
我们的程序判题策略可能会有很多种,比如:如果我们的代码沙箱本身执行程序需要消耗时间,这个时间可能不同的编程语言是不同的,比如沙箱执行
Java 要额外花 10 秒。
- 我们可以采用策略模式,针对不同的情况,定义独立的策略,而不是把所有的判题逻辑、if ... else 放在一起写
首先编写默认判题模块
如果选择某种判题策略的过程比较复杂,都写在调用判题服务的代码中,代码会越来越复杂,会有很多的 if ... else ,所以建议单独写一个判断策略的类
```java
JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
if(language.
equals("Java")){
judgeStrategy =new
JavaLanguageJudgeStrategy();
}
JudgeInfo judgeInfo = judgeStrategy.doJudge(judgeContext);
```
定义 JudgeManager,目的是尽量简化对判题功能的调用,让调用方写最少的代码、调用最简单。对于判题策略的选取,也是在 JudgeManager
里处理的
```java
@Service
public class JudgeManager {
/**
* 执行判题管理
*
* @param judgeContext
* @return
*/
JudgeInfo doJudgeManager(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);
}
}
```
## 6.4 代码沙箱原生实现
代码沙箱:只负责接收代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目 / 服务,提供给其他的需要执行官代码的项目去使用)
以 Java 编译语言为主,带大家实现代码沙箱,重要是学思想、学关键流程
> 扩展:可以自行实现 C++ 语言的代码沙箱
### 6.4.1 Java 原生实现代码沙箱
原生:尽可能不借助第三方库和依赖,用最干净、最原始的方式实现代码沙箱
代码沙箱需要:接收代码 => 编译代码(javac) => 执行代码(java)
#### **通过命令行执行**
```java
public class SimpleCompute {
public static void main(String[] args) {
Integer a = Integer.parseInt(args[0]);
Integer b = Integer.parseInt(args[1]);
System.out.println("result: " + (a + b));
}
}
```
用 javac 命令编译代码:
```java
javac {
Java代码路径
}
```
用 java 命令执行代码:
```shell
java -cp . SimpleCompute 1 2
```
#### **程序中文乱码问题**
为什么编码后的 class 文件出现中文乱码?
原因:命令行终端的编码是 GBK,和 java 代码文件本身的编码 UTF-8 不一致,导致乱码。
通过 `chcp` 命令查看命令行编码。GBK是936,UTF-8是65001
但是**不建议**改变终端编码来解决编译乱码,因为其他运行你代码的人也要改变环境,兼容性很差。
推荐的 javac 编译命令,用 `-encoding utf-8` 参数解决:
```shell
javac -encoding utf-8 .\SimpleCompute.java
```
java 执行
```shell
java -cp . SimpleCompute 1 2
```
#### **统一类名**
实际的 OJ 系统种,对用户输入的代码会有一定的要求,便于系统进行统一的处理和判题。
此处我们把用户输入代码的类名限制为 Solution(参考leetcode),可以减少编译时类名不一致的风险;而且不用从用户代码中提取类名,更方便。
文件名 Solution.java,示例代码如下:
```java
public class Solution {
public static void main(String[] args) {
Integer a = Integer.parseInt(args[0]);
Integer b = Integer.parseInt(args[1]);
System.out.println("result: " + (a + b));
}
}
```
实际执行命令时,可以统一使用 Solution 类名:
```shell
javac -encoding utf-8 .\Solution.java
java -cp . Solution 3 4
```
#### **核心流程实现**
核心实现思路:用程序代替人工,用程序来操作命令行,去编译执行代码
核心依赖(类):Java 进程执行管理类 Process
> (1)把用户的代码保存为文件
>
> (2)编译代码,得到 .class 文件
>
> (3)执行代码,得到输出结果
>
> (4)收集整理输出结果(进行返回)
>
> (5)文件清理,释放空间
>
> (6)错误处理,提升程序的健壮性
##### 1、保存代码文件
引入 Hutool 工具类
```xml
cn.hutool
hutool-all
5.8.8
```
新建目录,将每个用户的代码都存放在独立目录下,通过 UUID 随机生成目录名,便于隔离和维护:
```java
// 获取根目录 F:\Java\Project\My\zan-oj-code-sandbox
String userDir = System.getProperty("user.dir");
// 全局代码目录
String globalCodePathName = userDir + File.separator + GLOBAL_CODE_DIR_NAME;
// 判断全局代码目录是否存在(第一次不存在)
if(FileUtil.
exist(globalCodePathName)){
FileUtil.
mkdir(globalCodePathName);
}
// 把用户的代码隔离存在 - 存用户代码文件的文件夹
String userCodeParentPath = globalCodePathName + File.separator + UUID.randomUUID();
String userCodePath = userCodeParentPath + File.separator + GLOBAL_JAVA_CLASS_NAME;
// 写入文件
File userCodeFile = FileUtil.writeString(code, userCodePath, StandardCharsets.UTF_8);
```
##### 2、编译代码
使用 Process 类在终端执行命令:
```java
String compileCmd = String.format("javac -encoding utf-8 %s", userCodeFile.getAbsoluteFile());
Process process = Runtime.getRuntime().exec(compileCmd);
```
执行 process.waitFor() 方法等待程序执行完成,并通过返回的 exitValue 判断程序是否正常返回,然后从 Process 的输入流
inputStream 和错误流 errorStream 获取控制台输出。
```java
// compileProcess 编译进程
Process compileProcess = Runtime.getRuntime().exec(compileCmd);
// waitFor() 等待程序的执行 - 程序运行完成后得到 exitValue 退出码
int exitValue = compileProcess.waitFor();
if(exitValue ==0){
// 正常退出
System.out.
println("编译成功");
// 拿到控制台的正常输出(编译正确or错误的信息)
// 分批获取进程的正常输出
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);
}
// 分批获取进程的错误输出
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);
System.out.
println(errorCompileOutputStringBuilder);
}
```
可以把上述代码提取为工具类 ProcessUtils,执行进程并获取输出,并且使用 StringBuilder 拼接控制台输出信息:
```java
/**
* @Author Zan
* @Create 2024/5/30 17:29
* @ClassName: ProcessUtils
* @Description : 进程工具类
*/
public class ProcessUtils {
/**
* 执行进程获取信息
*
* @param runProcess
* @return
*/
public static ExecuteMessage runProcessAndGetMessage(Process runProcess, String optionName) {
ExecuteMessage executeMessage = new ExecuteMessage();
try {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// waitFor() 等待程序的执行 - 程序运行完成后得到 exitValue 退出码
int exitValue = runProcess.waitFor();
executeMessage.setExistValue(exitValue);
if (exitValue == 0) {
// 正常退出
System.out.println(optionName + "成功");
// 拿到控制台的正常输出(编译正确or错误的信息)
// 分批获取进程的正常输出
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());
} else {
// 异常退出
System.out.println(optionName + "失败,错误码:" + exitValue);
// 分批获取进程的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(runProcess.getInputStream()));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行获取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
compileOutputStringBuilder.append(compileOutputLine);
}
// 分批获取进程的错误输出
BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(runProcess.getErrorStream()));
StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
// 逐行获取
String errorCompileOutputLine;
while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
errorCompileOutputStringBuilder.append(errorCompileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());
}
stopWatch.stop();
executeMessage.setTime(stopWatch.getLastTaskTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
return executeMessage;
}
}
```
##### 3、执行程序
同样使用 Process 类运行 java命令,命令中记得增加 `-Dfile.encoding=UTF-8`参数,解决中文乱码问题:
```java
String runCmd = String.format("java -Dfile.encoding=UTF-8 -cp %s Solution %s", userCodeParentPath, inputArgs);
```
注意:上述命令适用于执行从输入参数(args)中获取值的代码
但是很多 OJ 都是 ACM模式,也就是需要和用户进行交互,让用户不断输入内容并获取输出,比如:
```java
import java.io.*;
import java.util.*;
public class Solution {
public static void main(String[] args) {
Scanner cin = new Scanner(System.in);
int a = cin.nextInt();
int b = cin.nextInt();
System.out.println(a + b);
}
}
```
对于此类程序,我们需要使用 OutputStream 向程序终端发送参数,并及时获取结果,注意最后要关闭流释放资源。
```java
/**
* 执行交互式进程获取信息(Scanner)
*
* @param runProcess
* @return
*/
public static ExecuteMessage runInteractProcessAndGetMessage(Process runProcess, String optionName, String inputArgs) {
ExecuteMessage executeMessage = new ExecuteMessage();
try {
// 向控制台输入程序
OutputStream outputStream = runProcess.getOutputStream();
OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputStream);
String[] s = inputArgs.split(" ");
String join = StrUtil.join("\n", s) + "\n";
outputStreamWriter.write(join);
// 相当于按了回车,执行输入的发送
outputStreamWriter.flush(); // 清空缓冲区
// 分批获取进程的正常输出
InputStream inputStream = runProcess.getInputStream();
// 分批获取进程的正常输出
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder compileOutputStringBuilder = new StringBuilder();
// 逐行获取
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
compileOutputStringBuilder.append(compileOutputLine);
}
// 分批获取进程的错误输出
BufferedReader errorBufferedReader = new BufferedReader(new InputStreamReader(inputStream));
StringBuilder errorCompileOutputStringBuilder = new StringBuilder();
// 逐行获取
String errorCompileOutputLine;
while ((errorCompileOutputLine = errorBufferedReader.readLine()) != null) {
errorCompileOutputStringBuilder.append(errorCompileOutputLine);
}
executeMessage.setMessage(compileOutputStringBuilder.toString());
executeMessage.setErrorMessage(errorCompileOutputStringBuilder.toString());
// 记得资源的释放,否则会卡死
outputStreamWriter.close();
outputStream.close();
inputStream.close();
runProcess.destroy();
} catch (Exception e) {
e.printStackTrace();
}
return executeMessage;
}
```
##### 4、整理输出
- 通过 for 循环便利执行结果,从中获取输出列表
- 获取程序执行时间
可以使用 Spring 的 StopWatch 获取一段程序的执行时间
```java
StopWatch stopWatch = new StopWatch();
stopWatch.
start();
......
stopWatch.
stop();
stopWatch.
getLastTaskTimeMillis();
```
此处我们使用最大值来统计时间,便于后续判题服务
```java
// 取用时最大时,判断是否超时
Long maxTime = 0L;
for(
ExecuteMessage executeMessage :executeMessageList){
......
if(executeMessage.
getTime() !=null){
maxTime =Math.
max(maxTime, executeMessage.getTime());
}
}
```
> 扩展:可以每个测试用例都有一个独立的内存、时间占用的统计
(3)获取内存信息
实现比较复杂,因此无法从 Process 对象中获取到子进程号,也不推荐在 Java 原生实现代码沙箱的过程中获取(命令行统计内存,Windows自带的taskList)
##### 5、文件清理
- 防止服务器空间不足,删除代码目录:
- ```java
if (userCodeFile.getParentFile().exists()) {
boolean del = FileUtil.del(userCodeParentPath);
System.out.println("删除" + (del ? "成功" : "失败"));
}
```
##### 6、错误处理
封装一个错误处理的方法,当程序抛出异常时,直接返回错误响应
```java
/**
* 获取错误响应
*
* @param e
* @return
*/
public 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;
}
```
### 6.4.2 Java 程序异常情况
到目前位置,核心流程已经实现,但是不安全
用户提交恶意代码,怎么办?
#### 1、执行超时
- 占用时间资源,导致程序卡死,不释放资源
```java
/**
* @Author Zan
* @Create 2024/5/30 18:44
* @ClassName: SleepError
* @Description : 无线睡眠 - 阻塞程序执行
*/
public class SleepError {
public static void main(String[] args) throws InterruptedException {
long ONE_HOUR = 60 * 60 * 1000L;
Thread.sleep(ONE_HOUR);
System.out.println("睡完了");
}
}
```
要把写好的代码复制到 resources 中,并且一定要把类名改为 Solution!包名一定要去掉!
#### 2、占用内存
- 占用内存资源,导致资源浪费
```java
import java.util.ArrayList;
import java.util.List;
/**
* @Author Zan
* @Create 2024/5/30 19:00
* @ClassName: MemoryError
* @Description : 无线占用内存空间(浪费系统内存)
*/
public class Solution {
public static void main(String[] args) {
List list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]);
}
}
}
```
实际运行上述程序时,我们会发现,内存占用到达一定空间后,程序就自动报错:`java.lang.OutOfMemoryError` or `Java heap space`
,而不是无限增加内存占用,直到系统死机。
这是 JVM 的一个保护机制
> 可以使用 JVisualVM 或 JConsole 工具,连接到 JVM 虚拟机上来可视化查看运行状态。
如图:
#### 3、读文件,信息泄露
比如直接通过相对路径获取项目配置文件,获取到密码:
```java
/**
* @Author : Zan
* @Create : 2024/5/30 20:28
* @ClassName : ReadFileError
* @Description : 读取服务器文件资源(信息泄露)
*/
public class ReadFileError {
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 -verison 2>&1`(实例命令)
> 1. java -version 用于显示 Java 版本信息,这会将版本信息输出到标准错误流(stderr)而不是标准输出流(stdout)。
> 2. 2>&1 将标准错误流重定向到标准输出流。这样,Java 版本信息就会被发送到标准输出流。
```java
/**
* @Author : Zan
* @Create : 2024/5/30 20:37
* @ClassName : WriteFileError
* @Description : 向服务器写文件(植入危险程序)
*/
public class WriteFileError {
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、运行其他程序
直接通过 Process 执行危险程序,或者电脑上的其他程序
```java
/**
* @Author : Zan
* @Create : 2024/5/30 20:42
* @ClassName : RunFileError
* @Description : 运行其他程序(比如危险密码)
*/
public class RunFileError {
public static void main(String[] args) throws InterruptedException, IOException {
String userDir = System.getProperty("user.dir");
String filePath = userDir + File.separator + "src/main/resources/木马程序.bat";
Process process = Runtime.getRuntime().exec(filePath);
process.waitFor();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String compileOutputLine;
while ((compileOutputLine = bufferedReader.readLine()) != null) {
System.out.println(compileOutputLine);
}
System.out.println("执行异常程序成功");
}
}
```
#### 6、执行高危操作
甚至都不用写木马文件,直接执行系统自带的危险命令!
- 比如删除服务器的所有文件(太残暴)
- 比如执行 dir(windows)、ls(linux)获取你系统上的所有文件信息
### 6.4.3 Java 程序安全控制
针对上面的异常情况,分别有如下方案,可以提高程序安全性
> (1)超时控制,设置超时时间
>
> (2)限制给用户程序分配的资源
>
> (3)限制代码 - 黑白名单
>
> (4)限制用户的操作权限(文件、网络、执行)
>
> (5)运行环境隔离
#### 1、超时控制
通过创建一个守护线程,超时后自动中断 process 实现
```java
new Thread(() ->{
try{
Thread.
sleep(TIME_OUT);
System.out.
println("超时了,进行中断");
runProcess.
destroy();
}catch(
InterruptedException e){
e.
printStackTrace();
}
}).
start();
```
#### 2、限制资源的分配
我们不能让每个 java 进程的执行占用的 JVM 最大堆内存空间都和系统的一致,实际上应该小一点,比如说 256MB。
在启动 Java 时,可以指定 JVM 的参数:-Xmx256m(最大堆空间大小) -Xms(初始堆空间大小)
```java
java -Xmx256m
```
注意!-Xmx参数、JVM的堆内存限制,不等同于系统实际占用的最大资源,可能会超出.

如果需要更严格的内存限制,要在系统层面去限制,而不是 JVM 层面的限制。
如果是 Linux 系统,可以使用 cgroup 来实现对某个进程的 CPU、内存等资源的分配
##### 小知识 - 什么是 cgroup?
`cgroup`是Linux内核提供的一种机制,可以用来限制进程组(包括子进程)的资源使用,例如内存、CPU、磁盘 I/O等。通过将 Java
进程放置在特定的 `cgroup`中,可以实现限制其使用的内存和CPU数
##### 小知识 - 常用 JVM 启动参数
> 1. 内存相关参数:
>
- -Xms:设置 JVM 的初始堆内存大小
> - -Xmx:设置 JVM 的最大堆内存大小
>
- -Xss:设置线程的栈大小
> - -XX:MaxMetaspaceSize:设置 Metaspace(元空间)的最大大小
> - -XX:MaxDirectMemorySize:设置直接内存(Direct Memeory)的最大大小
> 2. 垃圾回收相关参数
>
- -XX:+UseSerialGC:使用串行垃圾回收器
> - -XX:+UseParallelGC:使用并行垃圾回收器
>
- -XX:+UseConcMarkSweepGC:使用 CMS 垃圾回收器
> - -XX:+UseG1GC:使用 G1 垃圾回收器
> 3. 线程相关参数
>
- -XX:ParallelGCThreads:设置并行垃圾回收的线程数
> - -XX:ConcGCThreads:设置并行垃圾回收的线程数
>
- -XX:ThreadStackSize:设置线程的栈大小
> 4. JIT 编译器相关参数
>
- -XX:TieredStopAtLevel:设置 JIT 编译器停止编译的层次
> 5. 其他资源限制参数
>
- -XX:MaxRAM:设置 JVM 使用的最大内存
#### 3、限制代码 - 黑白名单
先定义一个黑白名单,比如哪些操作是禁止的,可以是一个列表:
```java
// 黑名单
private static final List blackList = Arrays.asList("Files", "exec");
```
还可以使用字典树代码列表存储单词,用更少的空间存储更多的敏感词汇,并且实现更高效的敏感词查找。
字典树的原理:

此处使用HuTool工具库的字典树工具类:WordTree,不用自己写字典树
(1)先初始化字典树,插入禁用词:
```
static {
// 初始化字典数组
WORD_TREE = new WordTree();
WORD_TREE.addWords(blackList);
}
```
(2)检验用户代码是否包含禁用词
```java
// 校验代码中是否包含黑名单命令
WordTree wordTree = new WordTree();
wordTree.
addWords(blackList);
FoundWord foundWord = wordTree.matchWord(code);
if(foundWord !=null){
System.out.
println(foundWord.getFoundWord());
return null;
}
```
**缺点:**
> - 无法遍历所有的黑名单
> - 不同的编程语言,你对应的领域、关键词都不一样,限制人工成本很大
#### 4、限制权限 - Java 安全管理器
目标:限制用户对文件、内存、CPU、网络等资源的操作和访问
##### Java 安全管理器
Java安全管理器(Security Manager)是Java提供的保护JVM、Java安全的机制,可以实现更严格的资源和操作权限
编写安全管理器,只需要继承 Security Manager 即可
(1)所有权限放开
```java
/**
* @Author Zan
* @Create 2024/5/31 9:05
* @ClassName: DefaultSecurityManager
* @Description : 默认安全管理器
*/
public class DefaultSecurityManager extends SecurityManager {
/**
* 检查所有的权限
* @param perm the requested permission.
*/
@Override
public void checkPermission(Permission perm) {
System.out.println("默认不做任何权限限制");
System.out.println(perm);
super.checkPermission(perm);
}
}
```
(2)所有权限禁止
```java
/**
* @Author Zan
* @Create 2024/5/31 9:14
* @ClassName: DenySecurityManager
* @Description : 禁止所有权限安全管理器
*/
public class DenySecurityManager extends SecurityManager {
@Override
public void checkPermission(Permission perm) {
throw new SecurityException("权限异常: " + perm.getActions());
}
}
```
(3)限制读权限
```java
// 检测程序是否允许读文件
@Override
public void checkRead(String file, Object context) {
if (file.contains("hutool")) {
return;
}
throw new SecurityException("checkRead 权限异常: " + file);
}
```
(4)限制写文件权限
```java
// 检测程序是否允许写文件
@Override
public void checkWrite(String file) {
throw new SecurityException("checkWrite 权限异常: " + file);
}
```
(5)限制执行文件权限
```java
// 检测程序是否可执行文件
@Override
public void checkExec(String cmd) {
throw new SecurityException("checkExec 权限异常: " + cmd);
}
```
(6)限制网络连接权限
```java
// 检测程序是否允许连接网络
@Override
public void checkConnect(String host, int port) {
throw new SecurityException("checkConnect 权限异常: " + host + port);
}
```
##### 综合项目运用
实际情况下,我们只需要限制子程序的权限即可,没必要限制开发者自己写的程序
启动子进程执行命令时,设置安全管理器,而不是在外层设置(会限制住测试用例的读写和子命令的执行)
具体操作:
> (1)根据需要开发自定义的安全管理器(比如 MySecurityManager )
>
> (2)复制 MySecurityManager 类到 `resources/security` 目录下,移除类的包名
>
> (3)手动输入命令编译 MySecurityManager 类,得到 class 文件
>
> (4)在运行 java 程序时,指定安全管理器 class 文件的路径、安全管理器的名称
命令如下:
注意:windows下要用分号间隔多个类路径,linux是冒号
```java
java -Xmx256m -Dfile.encoding=UTF-8-cp %s;%s -Djava.security.manager=%
s Solution %s
```
##### 安全管理器的优点
1. 权限控制很灵活
2. 实现简单
##### 安全管理器的缺点
1. 如果要做比较严格的权限限制,需要自己去判断哪些文件、包名需要允许读写,粒度太细了,难以精细化控制。
2. 安全管理器本身也是 Java 代码,也有可能存在漏洞。本质上还是程序层面的限制,没深入系统的层面。
#### 5、运行环境隔离
原理:操作系统层面上,把用户程序封装到沙箱里,和宿主机(我们的电脑 / 服务器)隔离开,使得用户的程序无法影响到宿主机。
实现方式:Docker 容器技术(底层是用 cgroup、namespace 等方式实现的),也可以直接使用 cgroup 实现。
## 6.5 代码沙箱 Docker 实现
### 6.5.1 Docker 容器技术
为什么要用 Docker 容器技术
- 为了进一步提升系统的安全性,把不同的程序和宿主机进行隔离,使得某个程序(应用)的执行不会影响到系统本身
Docker 可以实现程序和宿主机的隔离
#### 什么是容器?
理解为一系列应用程序、服务和环境的封装,从而把程序运行在一个隔离的、密闭的、隐私的空间内,对外整体提供服务。
可以把一个容器理解为一个新的电脑(定制化的操作系统)
【就是一种封装,例如一个游戏,不是你要下载所有的文件,少一个文件都不行,而是他们整合成一个安装包,你进行安装即可】

#### Docker 基本概念
> 镜像:用来创建容器的安装包,可以理解为给电脑安装操作系统的系统镜像
>
> 容器:通过镜像来创建的一套运行环境,一个容器里可以运行多个程序,可以理解为一个电脑实例
>
> Dockerfile:制作镜像的文件,可以理解为制作镜像的一个清单
>
> 
>
> 镜像仓库:存放镜像的仓库,用户可以从仓库下载现成的镜像,也可以把做好的镜像放到仓库里
>
> 推荐使用 docker 官方的镜像仓库:https://hub.docker.com/search?q=nginx
#### Docker 实现核心
> 对应题目:Docker 能实现哪些资源的隔离
看图理解:
(1)Docker 运行在 Linux 内核上
(2)CGroups:实现了容器的资源隔离,底层是 Linux Cgroup 命令,能够控制进程使用的资源
(3)Network 网络:实现容器的网络隔离,docker容器内部的网络互不影响
(4)Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同命名空间下的进程互不影响
(5)Storage 存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件

docker compose:是一种同时启动多个容器的集群操作工具(容器管理工具),一般情况下,开发者仅做了解即可,实际使用 docker compose 时去百度配置文件
#### 命令行操作 Docker
(1)查看命令用法
```shell
docker --help
```
查看具体子命令(如docker run)的用法:
```shell
docker run --help
```
(2)从远程仓库拉取线程的镜像
用法:TAG是版本
```shell
docker pull [OPTIONS] NAME[:TAG|@DIGEST]
```
示例:
```shell
docker pull hello-world
```
(3)根据镜像创建容器实例
```shell
docker create [OPTIONS] IMAGE [COMMAND] [ARG...]
```
启动实例,得到容器实例 containerId:
```shell
sudo docker create hello-world
```
(4)查看容器状态(ps 只会展示存活的容器,-a 是展示所有的容器)
```shell
sudo docker ps -a
```
(5)启动容器
```shell
docker start [OPTIONS] CONTAINER [CONTAINER...]
```
启动示例:
```shell
sudo docker start mystifying_shamir
```
(6)查看日志
```java
docker logs [OPTIONS] CONTAINER ID
```
启动示例
```shell
sudo docker logs mystifying_shamir
```
(7)删除容器实例
```shell
docker rm [OPTIONS] CONTAINER [CONTAINER...]
```
删除示例
```shell
sudo docker rm mystifying_shamir
```
(8)删除镜像
```shell
docker rmi --help
```
示例,强制删除:
```shell
sudo docker rmi hello-world -f
```
(9)其他:构建镜像(build)、推送镜像(push)、运行容器(run)、执行容器命令(exec)等
### 6.5.2 Java 操作 Docker
#### 前置准备
使用 Docker-Java :https://github.com/docker-java/docker-java
官方入门 :https://github.com/docker-java/docker-java/blob/main/docs/getting_started.md
先引入依赖:
```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),提供了线程的增删改查
#### 远程开发
使用 IDEA Development 先上传代码到 Linux,然后使用 JetBrains 远程开发完全连接 Linux 实时开发
如果无法启动程序,修改 settings 的 compiler 配置:-Djdk.lang.Process.launchMechanism=vfork

#### 常用操作
(1)拉取镜像:
```java
public class DockerDemo {
public static void main(String[] args) throws InterruptedException {
// 获取默认的 Docker Client
DockerClient dockerClient = DockerClientBuilder.getInstance().build();
// pull image 拉取镜像
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("下载完成");
}
}
```
(2)创建容器:
```java
public class DockerDemo {
public static void main(String[] args) throws InterruptedException {
// 获取默认的 Docker Client
DockerClient dockerClient = DockerClientBuilder.getInstance().build();
String image = "nginx:latest";
// 创建容器
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
CreateContainerResponse createContainerResponse = containerCmd
.withCmd("echo", "Hello Docker")
.exec();
System.out.println(createContainerResponse);
}
}
```
(3)查看容器状态:
```java
// 查看容器状态
ListContainersCmd listContainersCmd = dockerClient.listContainersCmd();
List containerList = listContainersCmd.exec();
for (Container container : containerList) {
System.out.println(container);
}
```
(4)启动容器:
```java
// 启动容器
StartContainerCmd startContainerCmd = dockerClient.startContainerCmd(containerId);
startContainerCmd.exec();
```
(5)查看日志:
```java
// 查看日志
LogContainerResultCallback logContainerResultCallback = new LogContainerResultCallback() {
@Override
public void onNext(Frame item) {
System.out.println("日志:" + new String(item.getPayload()));
super.onNext(item);
}
};
LogContainerCmd logContainerCmd = dockerClient.logContainerCmd(containerId);
// await进行阻塞等待日志输出
logContainerCmd
.withStdErr(true)
.withStdOut(true)
.exec(logContainerResultCallback)
.awaitCompletion();
```
(6)删除容器:
```java
// 删除容器
dockerClient.removeContainerCmd(containerId)
.withForce(true)
.exec();
```
(7)删除镜像:
```java
// 删除镜像
dockerClient.removeImageCmd(image).exec();
```
### 6.5.3 Docker 实现代码沙箱
实现思路:docker 负责运行 java 程序,并且得到结果
流程几乎和 Java 原生实现流程相同:
1. 把用户的代码保存为文件
2. 编译代码,得到 class 文件
3. 把编译好的文件上传到容器环境内(能让新电脑找到文件)
4. 在容器种执行代码,并得到输出结果
5. 收集整理输出结果
6. 文件清理,释放空间
7. 错误处理,提升程序健壮性
> 扩展:模板方法设计模式,定义同一套实现流程,让不同改的子类去负责不同流程中的具体实现。执行步骤一样,每个步骤的实现方式不一样。
#### 创建容器,上传编译文件
自定义容器的两种方式:
(1)在已有镜像的基础上进行扩充:比如拉取现成的 Java 环境(包含 jdk),再把编译后的文件复制到容器里
(2)完全自定义容器:适合比较成熟的项目,比如封装多个语言的环境和实现
**思考:我们每个测试用例都要单独创建一个容器,每个容器只执行一次 java 命令???**
- 浪费性能,所以要创建一个 **可交互** 的容器,能够接收多次输入并且输出
创建容器时,可以指定文件路径(Volumn)映射,作用是把本地的文件同步到容器中,可以让容器访问。
> 也可以叫做容器挂载目录
```java
HostConfig hostConfig = new HostConfig();
// path 本地文件路径,volume 虚拟容器里的路径(映射)
hostConfig.setBinds(new Bind(userCodePath, new Volume("/app")));
```
#### 启动容器,执行代码
##### 执行代码
Docker 执行容器命令(操作已启动容器):
```shell
docker exec [OPTIONS] CONTAINER COMMAND [ARG...]
```
示例执行:
```java
docker exec confident_sutherland java -cp /app Solution 1 3
```
注意:要把命令按照空格拆分,作为一个数组传递,否则可能会被识别为一个字符串,而不是多个参数
创建命令:
```java
String[] inputArgsArray = inputArgs.split(" ");
String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Solution"}, inputArgsArray);
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
.withCmd(cmdArray)
.withAttachStderr(true) // 为了得到控制台的输出,最好都设置为true
.withAttachStdin(true)
.withAttachStdout(true)
.exec();
```
执行命令,通过回调接口来获取程序的输出结果,并且通过 StreamType 来区分标准输出和错误输出:
```java
System.out.println("创建执行命令:" + execCreateCmdResponse);
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) {
System.out.println("程序执行异常");
throw new RuntimeException(e);
}
```
尽量复用之前的 `ExecuteMessage`对象,在异步接口中填充正常和异常信息,这样之后的流程的代码都可以复用
##### 获取程序执行时间
和 Java 原生一样,使用 StopWatch 在执行前后统计时间
```java
stopWatch.start();
dockerClient.execStartCmd(execId)
.exec(execStartResultCallback)
.awaitCompletion();
stopWatch.stop();
time = stopWatch.getLastTaskTimeMillis();
```
##### 获取程序占用内存
程序占用的内存每个时刻都在变化,所以不可能获取到所有时间点的内存
因此我们可以定义一个周期,定期的获取程序的内存
```java
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() {}
});
statsCmd.exec(statisticsResultCallback);
statsCmd.close();
```
### 6.5.4 Docker 容器安全性
#### 超时控制
执行容器时,可以增加超时参数控制值:
```java
dockerClient.execStartCmd(execId)
.exec(execStartResultCallback)
.awaitCompletion(TIME_OUT, TimeUnit.MILLISECONDS); // 超过5s,直接往下走
```
但是,这种方式无论超时与否,都会往下执行,无法判断是否超时
解决方案:可以定义一个标志,如果程序执行完成,把超时标志设置为false
```java
// 判断是否超时
final boolean[] timeout = {true};
String execId = execCreateCmdResponse.getId();
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {
@Override
public void onComplete() {
// 如果执行完成,则表示没有超时,设置为没有超时
timeout[0] = false;
super.onComplete();
}
...
};
```
#### 内存资源
通过 HostConfig 的 withMemory 等方法,设置容器的最大内存和资源限制:
```java
// 创建容器
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(image);
HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(100 * 1000 * 1000L); // 限制内存
hostConfig.withCpuCount(1L);
hostConfig.withMemorySwap(0L);
// path 本地文件路径,volume 虚拟容器里的路径(映射)
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withAttachStdin(true)
.withAttachStderr(true)
.withAttachStdout(true) // 与本地进行连接
.withTty(true) // 创建交互终端
.exec();
```
#### 网络资源
创建容器时,设置网络配置为关闭:
```java
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
......
```
#### 权限管理
Docker 容器已经做了系统层面的隔离,比较安全,但不能保证绝对安全
(1)结合 Java 安全管理器和其他策略去使用
(2)限制用户不能向 root 根目录写文件
```java
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
.withReadonlyRootfs(true)
```
(3)Linux 自带的一些安全管理措施,比如 seccomp(Secure Computing Mode)是一个用于 Linux 内核的安全功能,它允许你限制进程可以执行的系统调用,从而减少潜在的攻击面和提高容器的安全性。通过配置 seccomp,你可以控制容器内进程可以使用的系统调用类型和参数
```json
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{
"name": "write",
"action": "SCMP_ACT_ALLOW"
},
{
"name": "read",
"action": "SCMP_ACT_ALLOW"
}
]
}
```
在 hostConfig 中开启安全机制:
```java
String profileConfig = ResourceUtil.readUtf8Str("profile.json");
hostConfig.withSecurityOpts(Arrays.asList("seccomp=%s"));
```
## 6.6 小优化
### 6.6.1 模块方式优化代码沙箱
模板方法:定义一套通用的执行流程,让子类负责每个执行步骤的具体实现
模板方法的适用场景:适用于有规范的流程,且执行流程可以复用
作用:大幅节省重复代码量,便于项目扩展、更好维护
#### 抽象出具体的流程
定义一个模板方法抽象类
先复制具体的实现类,把代码从完整的方法抽离成一个一个子写法
```java
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
List inputList = executeCodeRequest.getInputList();
String code = executeCodeRequest.getCode();
//(1)把用户的代码保存为文件
File userCodeFile = saveCodeToFile(code);
//(2)编译代码,得到 .class 文件(编译)
ExecuteMessage compileFileExecuteMessage = compileFile(userCodeFile);
System.out.println(compileFileExecuteMessage);
//(3)执行程序,得到输出结果(运行)
List executeMessageList = runFile(userCodeFile, inputList);
//(4)整理数据输出
ExecuteCodeResponse executeCodeResponse = getOutputResponse(executeMessageList);
//(5)文件清理
boolean b = deleteFile(userCodeFile);
if (!b) {
log.error("deleteFile error, userCodeFilePath = {}", userCodeFile.getAbsoluteFile());
}
return executeCodeResponse;
}
```
#### 定义子类的具体实现
- Java 原生代码沙箱实现,直接复用模板方法定义好的方法实现
```java
public class JavaNativeCodeSandBox extends JavaCodeSandBoxTemplate {
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
return super.executeCode(executeCodeRequest);
}
}
```
- Dockers 代码沙箱实现,需要自行重写 RunFile
```java
@Override
public List runFile(File userCodeFile, List inputList) {
String userCodeParentPath = userCodeFile.getParentFile().getAbsolutePath();
//(3)创建容器,把文件复制到文件内
// 获取默认的 Docker Client
DockerClient dockerClient = DockerClientBuilder.getInstance().build();
// pull image 拉取镜像
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 * 1000L); // 限制内存
hostConfig.withMemorySwap(0L);
hostConfig.withCpuCount(1L);
hostConfig.withSecurityOpts(Arrays.asList("seccomp=%s"));
// path 本地文件路径,volume 虚拟容器里的路径(映射)
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app")));
CreateContainerResponse createContainerResponse = containerCmd
.withHostConfig(hostConfig)
.withNetworkDisabled(true)
.withReadonlyRootfs(true)
.withAttachStdin(true)
.withAttachStderr(true)
.withAttachStdout(true) // 与本地进行连接
.withTty(true) // 创建交互终端
.exec();
System.out.println(createContainerResponse);
String containerId = createContainerResponse.getId();
// 启动容器
dockerClient.startContainerCmd(containerId).exec();
//(4)Docker 执行代码并获取结果 - 在容器种执行代码,并得到输出结果
// docker exec confident_sutherland java -cp /app Solution 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", "Solution"}, inputArgsArray);
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId)
.withCmd(cmdArray)
.withAttachStderr(true) // 为了得到控制台的输出,最好都设置为true
.withAttachStdin(true)
.withAttachStdout(true)
.exec();
System.out.println("创建执行命令:" + execCreateCmdResponse);
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() {}
});
statsCmd.exec(statisticsResultCallback);
try {
stopWatch.start();
dockerClient.execStartCmd(execId)
.exec(execStartResultCallback)
.awaitCompletion(TIME_OUT, TimeUnit.MILLISECONDS); // 超过5s,直接往下走
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;
}
```
### 6.6.2 给代码沙箱提供开放 API
直接在 controller 暴露 CodeSandBox 定义的接口:
```java
@RestController
@RequestMapping("/")
public class CodeSandBoxController {
@Resource
private JavaNativeCodeSandBox javaNativeCodeSandBox;
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
@PostMapping("/executeCode")
ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest) {
if (executeCodeRequest == null) {
throw new RuntimeException("请求参数为空");
}
return javaNativeCodeSandBox.executeCode(executeCodeRequest);
}
}
```
#### 调用安全性
如果将服务不做任何的权限检验,直接发到公网,是不安全的
**(1)调用方与服务提供方之间约定一个字符串(最好加密)**
> 优点:实现最简单,比较适合内部系统之间互相调用(相对可信的环境内部调用)
>
> 缺点:不够灵活,如果 key 泄露或变更,需要重启代码
代码沙箱服务,先定义约定的字符串:
```java
/**
* 定义鉴权请求头和请求密钥
*/
private static final String AUTH_REQUEST_HEADER = "auth";
private static final String AUTH_REQUEST_SECRET = "secretKey";
```
改造请求,从请求头中获取认证信息,并校验
```java
@RestController
@RequestMapping("/")
public class CodeSandBoxController {
/**
* 定义鉴权请求头和请求密钥
*/
private static final String AUTH_REQUEST_HEADER = "auth";
private static final String AUTH_REQUEST_SECRET = "secretKey";
@Resource
private JavaNativeCodeSandBox javaNativeCodeSandBox;
/**
* 执行代码
*
* @param executeCodeRequest
* @return
*/
@PostMapping("/executeCode")
ExecuteCodeResponse executeCode(@RequestBody ExecuteCodeRequest executeCodeRequest, HttpServletRequest request, HttpServletResponse response) {
String authHeader = request.getHeader(AUTH_REQUEST_HEADER);
if (!AUTH_REQUEST_SECRET.equals(authHeader)) {
response.setStatus(403);
return null;
}
if (executeCodeRequest == null) {
throw new RuntimeException("请求参数为空");
}
return javaNativeCodeSandBox.executeCode(executeCodeRequest);
}
}
```
调用方,在调用时补充请求头:
```java
public class RemoteCodeSandBox implements CodeSandBox {
/**
* 定义鉴权请求头和请求密钥
*/
private static final String AUTH_REQUEST_HEADER = "auth";
private static final String AUTH_REQUEST_SECRET = "secretKey";
@Override
public ExecuteCodeResponse executeCode(ExecuteCodeRequest 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();
if (StringUtils.isBlank(responseStr)) {
throw new BusinessException(ErrorCode.API_REQUEST_ERROR, "executeCode remoteSandBox error, message = " + responseStr);
}
return JSONUtil.toBean(responseStr, ExecuteCodeResponse.class);
}
}
```
**(2)API 签名认证**
> 给允许调用的人员分配 accessKey、secretKey,然后校验这两组 key 是否匹配
### 6.6.3 跑通整个项目流程
(1)移动 QuestionSubmitController 代码到 QuestionController中
(2)由于后端改了接口地址,前端需要重新生成接口调用代码
```shell
openapi --input http://127.0.0.1:8101/api/v2/api-docs --output ./generated --client axios
```
还需要更改前端调用的Controller
(3)后端调试
(4)开发提交列表页面
> 扩展:每隔一段时间刷新一下提交状态,因为后端是异步判题的
## 6.7 单体项目改造成微服务
### 6.7.1 什么是微服务
服务:提供某类功能的代码
微服务:专注于提供某类特定功能的代码,而不是把所有的代码全部放到同一个项目里。会把整个大的项目按照一定的功能、逻辑进行拆分,拆分为多个模块,多个子模块可以独立运行、独立负责一类功能,子模块之间项目调用、互不影响。
一个公司:一个人干活,这个人 icu 了,公司直接倒闭
一个公司有多个不同类的岗位,多个人干活,一个组垮了还有其他组可以正常工作,不会说公司直接倒闭。
微服务的几个重要的实现因素:服务管理、服务调用、服务拆分
### 6.7.2 微服务实现技术
Spring Cloud Netifix
Spring Cloud Alibaba(本项目采用)
Dubbo(DubboX)
RPC(GRPC、TRPC)
本质上是通过 HTTP、或者其他的网络协议进行通讯来实现的
#### Spring Cloud Alibaba
https://github.com/alibaba/spring-cloud-alibaba
推荐参考中文文档来学习:https://sca.aliyun.com/
本质:在 Spring Cloud 的基础上,进行了增强,补充了一些额外的能力,根据阿里多年的业务沉淀做了一些定制化的开发
1. Spring Cloud Gateway:网关
2. Nacos:服务注册和配置中心
3. Sentinel:熔断限流
4. Seata:分布式事务
5. RocketMQ:消息队列,削峰填谷
6. Docker:使用Docker进行容器化部署
7. Kubernetes:使用k8s进行容器化部署

注意,一定要选择对应的版本:https://sca.aliyun.com/docs/2021/overview/version-explain/?spm=5176.29160081.0.0.74801a15DrbsIz
此处选择 2021.0.5.0

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

> 扩展:感兴趣的可以了解另一个分布式微服务框架:https://github.com/Nepxion/Discovery
>
> https://github.com/benggee/apollo?tab=readme-ov-file (携程阿波罗)
### 6.7.3 改造前思考
从业务需求出发,思考单机和分布式的区别
用户登录功能:需要改造为分布式登录
其他内容:
- 有没有用过单机的锁?改造为分布式锁
- 有没有用到本地缓存?改造为分布式缓存(Redis)
- 需不需要用到分布式事务?比如操作多个库
#### 改造分布式登录
(1)application.yml 增加 redis 配置
(2)补充依赖
```xml
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.session
spring-session-data-redis
```
(3)主类取消 Redis 自动配置的移除
(4)修改 session 存储方式
```yml
# 公共配置文件
spring:
# session 配置
session:
# todo 取消注释开启分布式 session(须先配置 Redis)
store-type: redis
```
(5)使用 redis-cli 或者 redis 管理工具,查看是否有登陆后的信息
#### 微服务的划分
从业务出发,想一下哪些功能 / 职责是一起的
> 公司老板给员工分工
依赖服务:
- 注册中心:Nacos
- 微服务网关(zan-oj-backend-gateway):Gateway 聚合所有的接口,统一接收处理前端的请求
公共模块:
- common 公共模块(zan-oj-backend-common):全局异常处理器、请求响应封装类、公用的工具类等
- model 模型模块(zan-oj-backend-model):很多服务公用的实体类
- 公用接口模块(zan-oj-backend-service-client):只存放接口,不存放实现(多个服务之前要共享)
业务功能:
1. 用户模块(zan-oj-backend-user-service:8102端口)
a. 注册
b. 登录
c. 用户管理
2. 题目模块(zan-oj-backend-question-service:8103端口)
a. 创建题目(管理员)
b. 删除题目(管理员)
c. 修改题目(管理员)
d. 搜索题目(用户)
e. 在线做题(题目详情页)
f. 提交题目
3. 判题模块(zan-oj-backend-judge-service:8104端口,较重的操作)
a. 执行判题逻辑
b. 错误处理(内存溢出、安全性、超时)
c. **自主实现** 代码沙箱(安全沙箱)
d. 开放接口(提供一个独立的新服务)
> 代码沙箱服务本身就是独立的,不用纳入 Spring Cloud 的管理
#### 路由划分
- 用 SpringBoot 的 context-path 统一修改个项目的接口前缀,比如:
用户服务:
- /api/user
- /api/user/inner(内部调用,网关层面要做限制)
题目服务:
- /api/question(也包括题目提交信息)
- /api/question/inner(内部调用,网关层面要做限制)
判题服务:
- /api/judge
- /api/judge/inner(内部调用,网管层面要做限制)
#### Nacos 注册中心启动
一定要选择 2.2.0 版本!!!
教程:https://sca.aliyun.com/docs/2021/user-guide/nacos/overview/
Nacos 官网教程:https://nacos.io/zh-cn/docs/quick-start.html
到官网下载 Nacos:https://github.com/alibaba/nacos/releases/tag/2.2.0
安装好后,进入 bin 目录启动:
```
startup.cmd -m standalone
```

#### 新建工程
Spring Cloud 有相当多的依赖,参差不齐,不建议随便找一套配置,或者自己用IDEA写
建议用脚手架搭建创建项目:https://start.aliyun.com/

给项目增加全局依赖配置文件
创建完初始项目后,补充 Spring Cloud 依赖:
```xml
org.springframework.cloud
spring-cloud-dependencies
2021.0.5
pom
import
```
依次使用 new modules 和 spring boot Initialize

需要给各模块之间绑定子父依赖关系,效果如下:

父模块定义 modules,子模块引入 parent 语法,可以通过继承夫模块配置,统一版本的定义和版本号
#### 同步代码和依赖
(1)common 公共模块(zan-oj-backend-common):全局异常处理器、请求响应封装类、公用的工具类等
(2)model 模型模块(zan-oj-backend-model):很多服务公用的实体类
直接复制 model 包,注意代码沙箱 model 的引入
(3)公用接口模块(zan-oj-backend-service-client):只存放接口,不存放实现(多个服务之前要共享)
先无脑搬运所有的 service,judgeSerivce 也需要搬运
```xml
org.springframework.cloud
spring-cloud-starter-openfeign
```
(4)具体业务服务实现
给所有业务服务引入公共依赖:
```xml
com.zan
zan-oj-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.isAdmin(loginUser)
userService.getLoginUser(request)
judgeService.doJudge(questionSubmitId)
判题服务:
questionService.getById(questionId)
questionSubmitService.getById(questionSubmitId)
questionSubmitService.updateById(questionSubmitUpdate)
(2)确认要提供哪些服务 - 修改 zan-oj-backend-service-client,只暴露需要调用的公共服务
用户服务:没有其他的依赖
userService.getById(userId)
userService.getUserVO(user)
userService.listByIds(userIdSet)
userService.isAdmin(loginUser)
userService.getLoginUser(request)
题目服务:
questionService.getById(questionId)
questionSubmitService.getById(questionSubmitId)
questionSubmitService.updateById(questionSubmitUpdate)
判题服务:
judgeService.doJudge(questionSubmitId)
(3)实现 client 接口
对于用户服务,**有一些不利于远程调用参数传递、或者实现起来非常简单(工具类),可以直接用 default 默认方法,无需远程调用**,节约性能
开启 OpenFeign 的支持,把我们的接口暴露出去(服务注册到注册中心上),作为 API 给其他服务调用(其他服务从注册中心寻找)
需要修改每个服务提供者的 context-path 全局请求路径
服务提供者:理解为接口的实现类,实际提供服务的模块(服务注册到注册中心上)
服务消费者:理解为接口的调用方,需要去找到服务提供类,然后调用(其他服务从注册中心寻找)
```yaml
server:
address: localhost
port: 8104
servlet:
context-path: /api/judge
```
> 注意:
>
> 1. 要给接口的每个方法打上请求注解,注意区分 Get、Post
> 2. 要给请求参数打上注解,比如 RequestParam、RequestBody
> 3. FeignClient 定义的请求路径一定要和服务提供方实际的请求路径保持一致
示例代码:
```java
@FeignClient(name = "zan-oj-user-service", path = "/api/user/inner")
public interface UserFeignClient {
/**
* 根据 id 获取用户
*
* @param userId
* @return
*/
@GetMapping("/get/id")
User getById(@RequestParam("userId") Long userId);
/**
* 根据 id 获取用户列表
*
* @param idList
* @return
*/
@GetMapping("/get/ids")
List listByIds(@RequestParam("idList") Collection idList);
/**
* 获取当前登录用户
*
* @param request
* @return
*/
default User getLoginUser(HttpServletRequest request) {
// 先判断是否已登录
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null || currentUser.getId() == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
return currentUser;
}
/**
* 是否为管理员
*
* @param user
* @return
*/
default boolean isAdmin(User user) {
return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUserRole());
}
/**
* 获取脱敏的用户信息
*
* @param user
* @return
*/
default UserVO getUserVO(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
BeanUtils.copyProperties(user, userVO);
return userVO;
}
}
```
(4)修改各业务服务的调用代码为 FeignClient
(5)编写 FeignClient 服务的实现类,注意要和之前定义的客户端保持一致
```java
@RestController
@RequestMapping("/inner")
public class UserInnerController implements UserFeignClient {
@Resource
private UserService userService;
/**
* 根据 id 获取用户
*
* @param userId
* @return
*/
@Override
@GetMapping("/get/id")
public User getUserById(@RequestParam("userId") long userId) {
return userService.getById(userId);
}
/**
* 根据 id 获取用户列表
*
* @param idList
* @return
*/
@Override
@GetMapping("/get/ids")
public List listUserByIds(@RequestParam("idList") Collection idList) {
return userService.listByIds(idList);
}
}
```
(6)开启 Nacos 的配置,让服务之间能够互相发现
所有模块引入 Nacos 依赖,然后给业务服务(包括网关)增加配置
```yaml
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
```
给**业务服务项目**启动类打上注解,开启服务发现、找到对应的客户端 Bean 的位置:
```java
@EnableDiscoveryClient // 使用 Nacos,去Nacos找服务
@EnableFeignClients(basePackages = {"com.zan.zanojbackendserviceclient.service"}) // 去指定的包路径找,将其注册到服务中心Nacos中
```
全局引入负载均衡器依赖:
```xml
org.springframework.cloud
spring-cloud-loadbalancer
3.1.5
```
(7)启动项目,测试依赖能否注入,能否完成相互调用
#### 微服务网关
- 微服务网关(zan-oj-backend-gateway):Gateway 聚合所有的接口,统一接收处理前端的请求
为什么要用?
- 所有的服务端口不同,增大了前端的调用成本
- 所有服务是分散的,因此需要集中进行管理、操作,比如集中解决跨域、鉴权、接口文档、服务的路由、接口安全性、流量染色、限流
> Gateway:想自定义一些功能,需要对这个技术有比较深的理解
Gateway 是应用层网关:会有一定的业务逻辑(比如根据用户细信息判断权限)
Nginx 是接入层网关:比如每个请求的日志,通常没有业务逻辑
##### 接口路由
统一地接收前端的请求,转发请求到对应的服务
如何找到路由?可以编写一套路由配置,通过 api **地址前缀**来找到对应的服务
```yaml
spring:
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
gateway:
routes:
- id: zan-oj-user-service
uri: lb://zan-oj-user-service
predicates:
- Path=/api/user/**
- id: zan-oj-question-service
uri: lb://zan-oj-question-service
predicates:
- Path=/api/question/**
- id: zan-oj-judge-service
uri: lb://zan-oj-judge-service
predicates:
- Path=/api/judge/**
application:
name: zan-oj-backend-gateway
main:
web-application-type: reactive
server:
port: 8101
```
##### 聚合文档
以一个全局的视角集中查看管理接口文档
使用 Knife4j 接口文档生成器:https://doc.xiaominfo.com/docs/middleware-sources/spring-cloud-gateway/spring-gateway-introduction
(1)先给所有业务服务引入依赖,同时开启接口文档的配置
https://doc.xiaominfo.com/docs/middleware-sources/spring-cloud-gateway/spring-gateway-introduction#%E6%89%8B%E5%8A%A8%E9%85%8D%E7%BD%AE%E8%81%9A%E5%90%88manual
```xml
com.github.xiaoymin
knife4j-openapi2-spring-boot-starter
4.3.0
```
```yaml
# 接口文档配置
knife4j:
enable: true
```
(2)给网关配置集中管理接口文档
网关项目引入依赖:
```xml
com.github.xiaoymin
knife4j-gateway-spring-boot-starter
4.3.0
```
引入配置:
```yaml
knife4j:
gateway:
# ① 第一个配置,开启gateway聚合组件
enabled: true
# ② 第二行配置,设置聚合模式采用discover服务发现的模式 服务发现自动聚合
strategy: discover
discover:
# ③ 第三行配置,开启discover模式
enabled: true
# ④ 第四行配置,聚合子服务全部为Swagger2规范的文档
version: swagger2
```
(3)访问地址即可查看聚合接口文档:http://localhost:8101/doc.html#/home
##### 分布式 Session 登录
必须引入 Spring Data Redis 依赖:
```xml
org.springframework.boot
spring-boot-starter-data-redis
org.springframework.session
spring-session-data-redis
```
解决 Cookie 跨路径问题:
```yaml
server:
address: 0.0.0.0
port: 8104
servlet:
context-path: /api/judge
# cookie 30 天过期
session:
cookie:
max-age: 2592000
path: /api
```
##### 跨域解决
全局解决跨域配置:
```java
@Configuration
public class CorsConfig {
@Bean
public CorsWebFilter corsWebFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("*");
config.setAllowCredentials(true);
// todo 实际改为线上真实域名、本地域名
config.setAllowedOriginPatterns(Arrays.asList("*"));
config.addAllowedHeader("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(new PathPatternParser());
source.registerCorsConfiguration("/**", config);
return new CorsWebFilter(source);
}
}
```
##### 权限校验
可以使用 Spring Cloud Gateway 的 Filter 请求拦截器,接受到该请求后根据请求的路径判断能否访问
```java
@Component
public class GlobalAuthFilter implements GlobalFilter, Ordered {
private AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
String path = serverHttpRequest.getURI().getPath();
// 判断路径中是否包含 inner,只允许内部调用
if (antPathMatcher.match("/**/inner/**", path)) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.FORBIDDEN);
DataBufferFactory dataBufferFactory = response.bufferFactory();
DataBuffer dataBuffer = dataBufferFactory.wrap("无权限".getBytes(StandardCharsets.UTF_8));
return response.writeWith(Mono.just(dataBuffer));
}
// todo 统一权限校验,通过 JWT 获取登录用户信息
return chain.filter(exchange);
}
/**
* 优先级提到最高
* @return
*/
@Override
public int getOrder() {
return 0;
}
}
```
> 扩展:可以在网关实现 Sentinel 接口限流降级,参考https://sca.aliyun.com/docs/2021/user-guide/sentinel/overview/
>
> 扩展:可以使用 JWT Token 实现用户登录,在网关层面通过 token 获取登录信息,实现鉴权
Redisson RateLimiter 也可以实现限流。
#### 思考
真有必要用微服务吗?
企业内部一般使用 API(RPC、HTTP)实现跨部门、跨服务的调用,数据格式和调用代码全部自动生成,保持统一,同时解耦
#### 消息队列解耦
此处选用 RabbitMQ 消息队列改造项目,解耦判题服务和题目服务,题目服务只需要向消息队列发送消息,判题服务从消息队列中获取消息去执行判题,然后异步更新数据库即可
##### 基本代码引入
(1)引入依赖
注意:使用的版本一定要和你的 SpringBoot 版本一致
```xml
org.springframework.boot
spring-boot-starter-amqp
2.6.13
```
(2)在 yml 中引入配置
```yaml
spring:
rabbitmq:
host: localhost
port: 5672
password: guest
username: guest
```
(3)创建交换机和队列
```java
public static void doInit() {
try {
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
String EXCHANGE_BANE = "code_exchange";
channel.exchangeDeclare(EXCHANGE_BANE, "direct");
// 创建队列,随机分配一个队列名称
String queueName = "code_queue";
channel.queueDeclare(queueName, true, false, false, null);
channel.queueBind(queueName, EXCHANGE_BANE, "my_routingKey");
log.info("消息队列启动成功");
} catch (Exception e) {
log.error("消息队列启动失败");
}
}
```
(4)生产者代码:
```java
@Component
public class MyMessageProducer {
@Resource
private RabbitTemplate rabbitTemplate;
/**
* 发送消息
*
* @param exchange
* @param routingKey
* @param message
*/
public void sendMessage(String exchange, String routingKey, String message) {
rabbitTemplate.convertAndSend(exchange, routingKey, message);
}
}
```
(5)消费者代码
```java
@Component
@Slf4j
public class MyMessageConsumer {
/**
* 指定程序监听的消息队列和确认机制
*
* @param message
* @param channel
* @param deliveryTag
*/
@SneakyThrows
@RabbitListener(queues = {"code_queue"}, ackMode = "MANUAL")
public void receiveMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
log.info("receiveMessage message = {}", message);
channel.basicAck(deliveryTag, false);
}
}
```
(6)单元测试执行
```java
```
##### 项目异步化改造
要传递的消息是什么?题目提交 id
题目服务中,把原本的本地异步执行改为向消息队列发送消息:
```java
// 通过消息队列执行判题服务 - 发送消息
myMessageProducer.sendMessage("code_exchange", "my_routingKey", String.valueOf(questionSubmitId));
// CompletableFuture.runAsync(() -> {
// judgeFeignClient.doJudge(questionSubmitId);
// });
```
判题服务中,监听消息,执行判题
```java
@Component
@Slf4j
public class MyMessageConsumer {
@Resource
private JudgeService judgeService;
/**
* 指定程序监听的消息队列和确认机制
*
* @param message
* @param channel
* @param deliveryTag
*/
@SneakyThrows
@RabbitListener(queues = {"code_queue"}, ackMode = "MANUAL")
public void receiveMessage(String message, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) {
log.info("receiveMessage message = {}", message);
long questionSubmitId = Long.parseLong(message);
try {
judgeService.doJudge(questionSubmitId);
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
channel.basicNack(deliveryTag, false, true);
}
}
}
```
> 扩展:处理消息重试,避免消息积压
>
> 扩展:压力测试,验证
# 七、测试
# 八、优化
# 九、代码提交,代码审核
# 十、产品验收
# 十一、上线