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