# 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在线判题系统)后端

code size Spring Boot languages Java last commit
Zan421


> 作者:[Zan421](https://gitee.com/zan421) # 一、项目介绍,项目调研,需求分析 ## 1.1 项目介绍 > OJ = Online Judge 在线判题评测系统 > > 用户可以选择题目,在线做题,编写代码并且提交代码;系统会对用户提交的代码,根据我们出题人设置的答案,来判断用户的提交结果是否正确。 > > ACM(程序设计竞赛),也是需要依赖判题系统来检测参赛者的答案是否合理 > > ![img-8c1712a5-1afa-4b4f-86e4-9370fe6d6b08](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/8c1712a5-1afa-4b4f-86e4-9370fe6d6b08.png) > > 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. 权限校验 # 二、核心业务流程 ![image-20240520141453457](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/fc2d5aa7-bb0c-47ef-8d37-4f46cb538f02.png) > 为啥要编译? > > 因为有些语言不编译不能运行 ![image-20240520141623549](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/f3b510e6-9fc4-461a-a6ee-e18728b6fe87.png) 判题服务:获取题目信息、预计的输入输出结果,返回给主业务后端:用户的答案是否正确 代码沙箱:只负责运行代码,给出结果,不管什么结果都是正确的 实现了解耦 # 三、项目要做的功能(功能模块) > 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 架构设计 ![image-20240520150232050](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/cf23bb3a-bf74-41e4-a2a8-645d99c1480d.png) ## 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 两者的关系 > 判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行 > > 代码沙箱:只负责接收代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目 / 服务,提供给其他的需要执行代码的项目去使用) > > 这两个模块完全解耦 > > image-20240528202103690 **思考:为什么代码沙箱要接受和输出一组运行用例** 前提:我们的每道题目有多组测试用例 - 如果是每个用例单独调用一次代码沙箱,会调用多次接口、需要多次网络传输、程序要多次编译、记录程序的运行状态(重复的代码不重复编译) > 这是一种很常见的性能优化方法(批处理) ## 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类来增强代码沙箱的能力 原本:需要用户自己去调用多次 ![image-20240529113204702](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/68e3a4c4-0ade-4893-b861-5673bc1cb289.png) 使用代理后:不仅不用改变原本的代码沙箱实现类,而且对于调用者来说,调用方式基本没有改变,也不需要在每个调用沙箱的地方去写统计代码 ![image-20240529113326085](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/2969e3b5-0229-4182-a54d-c4ca31b1d6c4.png) 代理模式的实现原理: - 实现被代理的接口 - 通过构造函数接收一个可被代理的接口实现类 - 调用被代理的接口实现类,在调用前后增加对应的操作 示例代码: ```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 虚拟机上来可视化查看运行状态。 如图: img #### 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的堆内存限制,不等同于系统实际占用的最大资源,可能会超出. ![image-20240530211234193](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/6b64f08b-084a-4685-aaf7-a86406b99c30.png) 如果需要更严格的内存限制,要在系统层面去限制,而不是 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"); ``` 还可以使用字典树代码列表存储单词,用更少的空间存储更多的敏感词汇,并且实现更高效的敏感词查找。 字典树的原理: ![image-20240531082909679](http://zanchat.top:9000/zan-oj-study-backend/file/2024-05/fb71cb4f-9976-4b20-ba8c-c6ba5caca77e.png) 此处使用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 可以实现程序和宿主机的隔离 #### 什么是容器? 理解为一系列应用程序、服务和环境的封装,从而把程序运行在一个隔离的、密闭的、隐私的空间内,对外整体提供服务。 可以把一个容器理解为一个新的电脑(定制化的操作系统) 【就是一种封装,例如一个游戏,不是你要下载所有的文件,少一个文件都不行,而是他们整合成一个安装包,你进行安装即可】 ![image-20240602160058637](http://zanchat.top:9000/zan-oj-study-backend/file/2024-06/308391fb-523d-4920-abfe-5ea4a460bbfa.png) #### Docker 基本概念 > 镜像:用来创建容器的安装包,可以理解为给电脑安装操作系统的系统镜像 > > 容器:通过镜像来创建的一套运行环境,一个容器里可以运行多个程序,可以理解为一个电脑实例 > > Dockerfile:制作镜像的文件,可以理解为制作镜像的一个清单 > > ![image-20240602170949880](http://zanchat.top:9000/zan-oj-study-backend/file/2024-06/f4dfa8c1-1028-440b-8572-6d6464a17738.png) > > 镜像仓库:存放镜像的仓库,用户可以从仓库下载现成的镜像,也可以把做好的镜像放到仓库里 > > 推荐使用 docker 官方的镜像仓库:https://hub.docker.com/search?q=nginx #### Docker 实现核心 > 对应题目:Docker 能实现哪些资源的隔离 看图理解: (1)Docker 运行在 Linux 内核上 (2)CGroups:实现了容器的资源隔离,底层是 Linux Cgroup 命令,能够控制进程使用的资源 (3)Network 网络:实现容器的网络隔离,docker容器内部的网络互不影响 (4)Namespaces 命名空间:可以把进程隔离在不同的命名空间下,每个容器他都可以有自己的命名空间,不同命名空间下的进程互不影响 (5)Storage 存储空间:容器内的文件是相互隔离的,也可以去使用宿主机的文件 ![image-20240602172348659](http://zanchat.top:9000/zan-oj-study-backend/file/2024-06/f6b67730-3676-4fb1-ab8c-cae646ebd8b3.png) 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 ![20240606](D:\Typora\save\20240606.png) #### 常用操作 (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进行容器化部署 ![spring-cloud](https://sca.aliyun.com/img/overview-doc-img/spring-cloud-alibaba-img.png) 注意,一定要选择对应的版本:https://sca.aliyun.com/docs/2021/overview/version-explain/?spm=5176.29160081.0.0.74801a15DrbsIz 此处选择 2021.0.5.0 ![img](https://cdn.nlark.com/yuque/0/2023/png/398476/1693230835297-547f4959-2e7b-447f-92ef-5ac39ee96945.png) Nacos:集中存管项目中所有服务的信息,便于服务之间找到彼此;同时,还支持集中存储整个项目中的配置。 整个微服务请求流程: ![项目结构示意图](https://sca.aliyun.com/img/best-practice/1.png) > 扩展:感兴趣的可以了解另一个分布式微服务框架: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 ``` ![image-20240607204146228](D:\Typora\save\image-20240607204146228.png) #### 新建工程 Spring Cloud 有相当多的依赖,参差不齐,不建议随便找一套配置,或者自己用IDEA写 建议用脚手架搭建创建项目:https://start.aliyun.com/ ![image-20240607204737818](D:\Typora\save\image-20240607204737818.png) 给项目增加全局依赖配置文件 创建完初始项目后,补充 Spring Cloud 依赖: ```xml org.springframework.cloud spring-cloud-dependencies 2021.0.5 pom import ``` 依次使用 new modules 和 spring boot Initialize ![image-20240607210952820](D:\Typora\save\image-20240607210952820.png) 需要给各模块之间绑定子父依赖关系,效果如下: ![image-20240607211511958](D:\Typora\save\image-20240607211511958.png) 父模块定义 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 配置 ![img](https://cdn.nlark.com/yuque/0/2023/png/398476/1693236183911-9d4ce121-6d55-4785-850f-84edc65204e1.png) ![img](https://cdn.nlark.com/yuque/0/2023/png/398476/1693236156904-baa1afef-202d-40bd-ab25-d7ea5ef1e565.png) ![img](https://cdn.nlark.com/yuque/0/2023/png/398476/1693236172571-08e13aa1-1829-4387-a0d0-6dc21cffd60e.png) #### 服务内部调用 **现在的问题是,题目服务依赖用户服务,但是代码已经分为不同的包,找不到对应的 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); } } } ``` > 扩展:处理消息重试,避免消息积压 > > 扩展:压力测试,验证 # 七、测试 # 八、优化 # 九、代码提交,代码审核 # 十、产品验收 # 十一、上线