# oj 判题系统
# 项目介绍
OJ = onlone judge 在线判题评测系统
用户可以选择题目,在线做题,编写代码并提交代码;系统会对用户提交的代码,根据出题人设置的答案,来判断用户提交的代码是否正确。
ACM:程序设计竞赛
OJ 系统难题:判题系统
用于在线评测编程题目代码的系统,能够根据用户提交的代码,使用出题人预先设定的输入和输出进行编译、运行
判题系统作为一个 API
# OJ 系统属性
题目限制:时间、内存
题目介绍
题目输入
题目输出
输入样例
输出样例
其他限制:不能随便引入包、随便历遍、暴力破解 => 安全性
判题过程是异步的 => 异步化
提交后会生成提交记录,有结果和运行信息
普通测评:管理员设置题日的输入和输出用例,比如我输入 1,你要输出 2 才是正确的;交给判题机去执行用户
的代码,给用户的代码喂输入用例,比如 1,看用户程序的执行结果是否和标准答案的输出一致。
(比对用例文件)
特殊测评 (SPJ): 管理员设置题日的输入和输出,比如我输入 1,用户的答案只要是 > 0 或 < 2 都是正确的;特判程序,不是通过对比用例文件是否一致这种死板的程序来检验,而是要专门根据这道题目写一个特殊的判断程
序,程序接收题目的输入 (1)、标准输出用例 (2)、用户的结果 (1.5),特判程序根据这些值来比较是否正确。
交互测评:让用户输入一个例子,就给一个输出结果,交互比较灵活,没办法通过简单的、死板的输入输
出文件来搞定
# 做项目的原因
CRUD,更多是编程思想、计算机基础、结构设计、可拓展性强
# 做项目的流程
- 项目介绍、项目调研、需求分析
- 核心业务流程 => 这个项目最核心的功能
- 项目要做的功能(功能模块)
- 技术选型(技术预研)
- 项目初始化
- 项目开发
- 测试
- 优化
- 代码提交、代码审核
- 产品验收
- 上线
写文档、持续调研、持续记录总结
# 现有系统调研
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 实现)
# 实现核心
权限校验
谁能提交代码、查看代码
代码沙箱(安全沙箱)
防止用户代码藏毒:写个木马文件、修改系统权限
沙箱:隔离的、安全的环境,用户的代码不会影响到沙箱之外的系统运行
资源分配:限制用户程序的占用资源
判题的规则
题目用例的对比、结果验证
任务调度
服务器资源有限,用户需要排队,依次判题
# 核心业务流程
用户 => 浏览题目 => 提交题目代码 => 业务后端 => 数据库 => 检查用户是否登录 => 题目是否存在 => 得到题目信息 => 返回给业务后端 => 提交代码和用例 => 判题模块 => 编译执行 => 返回结果 => 业务后端 => 用户

时序图
用户 前端页面 后端主业务 消息队列 判题服务 代码沙箱 数据库

判题服务:获取题目信息、预计的输入输出结果,返回给主业务后端:用户答案是否正确
代码沙箱:只负责运行代码,给出结果,不负责判断结果是否正确
实现解耦
# 功能
- 题目模块
- 创建题目
- 删除题目
- 修改题目
- 搜索题目
- 在线做题
- 提交代码
- 用户模块
- 注册
- 登录
- 判题模块
- 提交判题(结果是否正确)
- 错误处理(内存溢出、超时、安全性)
- 自主实现(代码沙箱)
- 开放接口
- 在线做题、在线提交
# 拓展思路
- 支持多种语言
- remote judge
- 完善的评测功能:普通评测、特殊评测、交互评测、在线自测、子任务分组评测、文件 io
- 统计分析用户判题记录
- 权限校验
# 技术选型
前端:Vue3、Acro Design 组件库 在线代码编辑器、在线文档浏览
Java 进程控制、Java 安全管理器、部分 JVM 知识点
虚拟机(云服务器)、Docker(代码沙箱实现)
Spring Cloud 微服务、消息队列、多种设计模式
# 架构设计
分层架构
用户层 app pc 端 移动端
接入层 Nginx API 网关 负载均衡项目
业务层
服务层
存储层
资源层
# 主流 OJ 系统实现方案
开发原则:能复用就复用
# 1. 使用现成的 OJ 系统
judge0,这是一个非常成熟的商业 OJ 项目,支持 60 多种编程语言
https://github.com/judge0/judge0
# 2. 使用现成的服务 —— 其他人实现了的代码沙箱、判题系统
judge0 提供了判题 API
Judge0 API 地址: https://rapidapi.com/judge0-official/api/judge0-ce
官方文档: https://ce.judge0.com/#submissions-submission-post
# 3. 自主开发
适合学习,但不适合商用
# 4. 把 AI 当作代码沙箱
# 5. 模拟浏览器远程使用其他人的 OJ 系统
# 前端项目初始化 —— 创建一个通用模板
# 1. 确认环境
nodejs => v18 or 14
npm v8 以上
# 2. 初始化
使用 vue-cli 脚手架
npm install -g @vue/cli
检测安装是否成功
vue -v
创建新的 vue 项目
vue create project-name
选择组件库:Babel、TypeScript、Router、Vuex、Linter/Formatter
运行项目,project.json
# 3. 前端工程配置
插件:vue.js prettier
自己整合
- 代码规范 https://eslint.org/docs/latest/use/getting-started
- 代码美化 https://prettier.io/docs/en/install.html
- 直接整合 https://github.com/prettier/eslint-plugin-prettier#recommended-configuration
# 4. 引入组件 Arco Design Vue
安装
···
按照快速入门
改变 main.js
# 项目通用布局
新建布局文件 layouts/BasicLayout.vue
在 app.vue 中引入
<div id="app">
<BasicLayout />
</div>
布局 —— 上中下布局
导航栏(菜单) —— 设置为全局 components/GlobalHeader.vue
菜单设置根据路由文件动态生成 router/index.ts
提取通用路由文件
菜单组件读取路由,动态渲染菜单项
绑点跳转事件
同步路由到菜单项
const router = useRouter(); // 默认主页 const selectedKeys = ref(["/"]); // 路由跳转时,更新选中的菜单项 router.afterEach((to, from, failure) => { selectedKeys.value = [to.path]; }); // 点击菜单项更新路由 const doMenuClick = (key: string) => { router.push({ path: key, }); };
思路:点击菜单项 => 触发点击事件 => 跳转并更新路由 => 更新菜单项选中状态
# 全局状态管理
Vuex 提供了一套增删改查全局变量的 API

所有页面全局共享的变量
适合作为全局状态的数据:已登录用户信息
使用 vuex 开始 | Vuex (vuejs.org)
state:存储状态信息
mutation:要求尽量同步,定义了对变量的增删改方法
actions:执行异步操作,触发 mutation 的更改,即 actions 调用 mutation
modules:模块,把一个大的 state 划分为多个小模块
使用思路:
在 store 目录下新建模块,如 user
在 store 目录下的 index.ts 中引入 user 模块
在 vue 页面中获取 user 信息
const store = useStore(); store.state.user?.userInfo在 vue 页面中使用 dispatch 调用 actions
store.dispatch("模块名/actions里的方法名",{传入的参数}) 如: store.dispatch("userr/getUserInfo"{userInfo: "xxx"})
#
# 全局权限管理
目的:能够通用地定义哪个页面需要什么权限
思路:
在路由配置文件中定义某个路由的访问权限
在全局页面组件 app.vue 中,绑定一个全局路由监听,每次访问页面前,根据用户信息判断用户是否有权限访问该页面
router.beforeEach((to, from, next) => { if (to.meta?.access === "canAdmin") { if (store.state.user.loginUser?.role !== "admin") { next("/noAuth"); return; } } next(); });to 的信息为路由信息
根据配置控制菜单项的显隐
routes.ts 给路由新增一个标志位,根据标志位过滤
const visibleRoutes = routes.filter((item, index) => { if (item.meta?.hideInMenu) { return false; } return true; });
根据权限隐藏菜单项
- 使用计算属性,当登录用户信息发生变更时,触发菜单栏的重新渲染
# 全局项目入口
# 后端项目初始化
构建通用后端框架
https://github.com/ferdikoomen/openapi-typescript-codegen
AOP:面向切面思想


AOP 需要做的三件事:
- 在哪里切入,也就是权限校验和日志记录在哪些代码里执行
- 在什么时候切入,即在业务代码执行前还是执行后
- 切入后做什么事,如权限校验或日志记录等
使用:
引入依赖
AOP 切面类添加 @Aspect 注解
AOP 切面类添加 @Componet 注解,将该类交给 Spring 管理
在该类中实现 Advice,即
package cn.wideth.aop;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
@Aspect@Componentpublic class LogAdvice {
// 定义一个切点:所有被 GetMapping 注解修饰的方法会织入 advice@Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping)")
private void logAdvicePointcut() {}
// Before 表示 logAdvice 将在目标方法执行前执行@Before("logAdvicePointcut()")
public void logAdvice(){
// 这里只是一个示例,你可以写任何处理逻辑System.out.println("get请求的advice触发了");
}}package cn.wideth.controller; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import io.swagger.annotations.ApiOperation; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping(value = "/aop") public class AopController { @GetMapping(value = "/getTest") public JSONObject aopTest() { return JSON.parseObject("{\"message\":\"SUCCESS\",\"code\":200}"); } @PostMapping(value = "/postTest") @ApiOperation(value = "aop测试信息") public JSONObject aopTest2(@RequestParam("id") String id) { return JSON.parseObject("{\"message\":\"SUCCESS\",\"code\":200}"); } }相关注解
@Pointcut 注解,用来定义一个切面,即上文中所关注的某件事情的入口,切入点定义了事件触发时机。
定义需要拦截的东西,两个常用的表达式:一个是使用 execution (),另一个是使用 annotation ()。
"execution(* com.mutest.controller..*.*(..))"- 第一个 * 号的位置:表示返回值类型,* 表示所有类型。
- 包名:表示需要拦截的包名,后面的两个句点表示当前包和当前包的所有子包,在本例中指 com.mutest.controller 包、子包下所有类的方法。
- 第二个 * 号的位置:表示类名,* 表示所有类。
- *(…):这个星号表示方法名, * 表示所有的方法,后面括弧里面表示方法的参数,两个句点表示任何参数。
annotation () 方式是针对某个注解来定义切面
@Around 注解用于修饰 Around 增强处理
@Around 可以自由选择增强动作与目标方法的执行顺序,调用 ProceedingJoinPoint 参数的 procedd () 方法才会执行目标方法。
@Around 可以改变执行目标方法的参数值,也可以改变执行目标方法之后的返回值
@Before 注解指定的方法在切面切入目标方法之前执行,可以做一些 Log 处理,也可以做一些信息的统计,比如获取用户的请求 URL 以及用户的 IP 地址等等
@After 注解和 @Before 注解相对应,指定的方法在切面切入目标方法之后执行,也可以做一些完成某方法之后的 Log 处理。
@AfterReturning 注解和 @After 有些类似,区别在于 @AfterReturning 注解可以用来捕获切入方法执行完之后的返回值,对返回值进行业务逻辑上的增强处理
当被切方法执行过程中抛出异常时,会进入 @AfterThrowing 注解的方法中执行,在该方法中可以做一些异常的处理逻辑。
# 前后端联调
前端发送请求调用后端接口
# 使用工具
Axios
官方文档:https://axios-http.com/docs/intro
自动生成请求的 API
https://github.com/ferdikoomen/openapi-typescript-codegen
# 用户登录
自动登录
再 store/user.ts 编写获取远程登录用户信息的代码
// TODO 远程登录const res = await UserControllerService.getLoginUserUsingGet();
if (res.code === 0) {
commit("updateUser", res.data);
} else {
commit("updateUser", {
...state.loginUser,
userRole: ACCESS_ENUM.NOT_LOGIN,
});
}},
触发 getLoginUser 函数的位置
- 路由拦截
- 全局页面入口 App.vue
- 全局通用布局
# 全局权限管理优化
- 新建 access/index.ts 文件,把原有的路由拦截、权限校验放在独立的文件中
# 支持多套布局
在 routes 路由文件中新建一套用户路由,使用子路由
创建新的 layout、view 文件
在 app.vue 根页面中通过 if-else 区分布局
<template v-if="route.path.startsWith('/user')"> <router-view /> </template> <template v-else> <BasicLayout /> </template>
登录成功
使用 await 同步更新用户信息状态
跳转进入系统页面
使用 router, 使用 replace,不会占用浏览器历史记录的堆栈,会直替换当前登录页
router.push({
path: "/",
replace: true,
})
# 登录页面
核心:表单
# 后端接口开发
根据功能设计库表
用户表 只有管理员才能发布和管理题目
题目表
题目标题
题目内容:介绍、输入输出提示
题目标签:json 数组字符串,栈、队列、简单、中等、困难等
题目答案:管理员设置
输入输出:
输入输出属于判题相关字段
如果用例文件不是很大,小于 512KB,可以直接存在数据库中,否则应该处在文件中,数据库只存文件的 url
当不需要某些字段去倒查这条数据时,且这些字段属于同一类值,可以使用 json 数组存储
[{"input": "1 2",
"output": "3 4"
},
{"input": "1 2",
"output": "3 4"
}]这样便于扩展,且需要变更字段时不用修改数据库表
判题限制:时间、大小等同样可以使用 json 数组
题目提交表
哪个用户提交了哪道题,存放判题结果
提交用户 id
题目 id
编程语言
用户代码
判题状态
判题信息:json 对象
判题信息枚举值:
Accepted 成功
Wrong Answer 答案错误
Compile Error 编译错误
Memory Limit Exceeded 内存溢出
Time Limit Exceeded 超时
Presentation Error 展示错误
Output Limit Exceeded 输出溢出
Waiting 等待中
Dangerous Operation 危险操作
Runtime Error 运行错误(用户程序的问题)
System Error 系统错误(做系统人的问题)自动生成对数据库基本的增删改查(mapper 和 service 层的基本功能)
编写 Controller 层,实现基本的增删改查和权限校验
根据业务定制开发新功能
数据库索引:
什么时候使用索引
当数据量很大时
如何选择索引
从业务出发,无论是单个索引、还是联合索引,都要从时间的查询语句、字段枚举值的区分度、字段的类型考虑选择区分度大的字段充当索引
对上述表的 json 字段,为了更方便的管理,给其独立编写新的类,如:judgeConfig、judgeInfo、judgeCase
定义 vo 类,封装信息,专门返回给前端的类,信息过滤,防止信息泄露,还可以节约网络传输大小
需要对象类转封装类,封装类转对象类:封装类和对象类的某些属性类型可能不一样
为了防止用户按照 id 顺序爬取题目,id 的生成规则改为 ASSING_ID,而不是顺序自增
# 前端界面开发
# 需要的页面:
- 用户注册
- 用户登录
- 创建题目界面
- 题目管理页面
- 题目列表页
- 题目详情页
- 题目提交列表页
# 接入需要的组件
# Markdown 编辑器
使用 Md 编辑器
安装 https://github.com/bytedance/bytemd
npm i @bytemd/vue-next
npm i @bytemd/plugin-highlight @bytemd/plugin-gfm
# 代码优化
- 菜单项的权限控制与显示隐藏
- 删除或隐藏不需要的菜单项
- 设置 mate.access 和
- 页面修复
- 使用 Arco Design 的表格组件 page-change
- watchEffect () 监听传递函数的所有变量,当发生改变时,重新加载
- 1
# 题目描述
计算 A + B
输入两个整数 a 和 b,要求小于等于 1000
输出 a+b 的值
样例输入
1 3 |
样例输出
4
你可以使用 C、C++、JAVA
# 后端判题模块预开发
# 判题模块与代码沙箱的关系
判题模块:调用代码沙箱,把代码和输入交给代码沙箱就可以了,对代码沙箱返回的结果进行判断
代码沙箱:只负责接受代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目 / 服务,提供给其他需要执行代码的项目使用)
两个模块完全解耦,通过 API 交互
# Java 开发知识
# 枚举
# 代码沙箱开发
# 接口定义
- 定义代码沙箱接口,提高通用性,接口规范
- 项目只调用接口,不调用具体的实现类,这样在使用其他代码沙箱实现类时,就不用修改名称
Lonbox Builder 注解:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
- @Builder 构造器
- @NoArgsConstructor 无参构造条件
- @AllArgsConstructor 为当前类添加所有类都有的构造函数
加了以上注解的类,在新建对象时,可以不用 new,直接使用.builder 构建,在构建时就可以传入参数,链式
# 三种代码沙箱的实现方式
本地样例代码沙箱
远程代码沙箱
第三方代码沙箱
# 设计模式
工厂模式
根据用户传入的字符串参数来生成相应的代码沙箱实现类
public static CodeSandbox newInstance(String type) { switch (type) { case "remote": return new RemoteCodeSandbox(); case "thirdParty": return new ThirdPartyCodeSandbox(); default: return new ExampleCodeSandbox(); } }如果确认代码沙箱示例不会出现线程安全问题,可以使用单例工程模式
代理模式
代码沙箱能力增强
如当我们需要在调用代码沙箱前,输出请求参数日志,在代码沙箱调用后,输出响应日志
避免重复的编写 log 代码,执行 log,使用代理模式
提供一个 Proxy ,来增强代码沙箱的能力
![1694275758016]()
步骤:
实现被代理的接口
通过构造函数接受被代理接口的实现类
调用类代理接口的实现类,在调用前后对其新增功能
被代理接口的定义可以使用 final ,只会被定义一次
private final CodeSandbox codeSandbox; public CodeSandboxProxy(CodeSandbox codeSandbox) { this.codeSandbox = codeSandbox; }
# 策略模式
# 参数配置化
把项目中的一些可以交给用户去自定义的选项或字符串,写到配置文件中去,这样开发者只需要修改配置文件,而不用去看代码就可以使用更多功能
在配置文件 application.yml 中
# 参数配置化 testValue: type: te在需要调用该参数的地方使用 @Value 注解
@Value("${testValue.type: example}") private String type;
# 判题服务完整开发
# 判题服务流程:
- 传入题目 id,获取对应题目信息、提交信息
- 如果判题状态不为等待中,就不用再次判题
- 修改题目判题状态(改为判题中),只有状态为等待中才进行判题
- 调用代码沙箱,获取返回结果
- 根据代码沙箱的返回结果设置题目的判题状态和信息
# 策略模式
不同的编程语言需要的判题限制可能是不同的
采用策略模式,根据不同的情况使用不同的策略
# 代码沙箱
# 核心实现流程
知道 Java 代码的执行过程、
Java 进程执行管理类:Process
- 把用户的代码保存为文件
- 编译代码,得到 class 文件
- 执行代码,得到输出结果
# 代码沙箱 Docker 实现
# Docker
为什么使用 Docker:把不同的程序与宿主机进行隔离,使得某个程序的执行不会影响到系统本机
# 消息队列
耦合度高
遇到的问题:
远程开发的时候,因为 docker 是需要安装到 linux 系统上的,使用 ubuntu,在写 docker 代码的时候需要远程连接并且能运行,idea 自己带的一个连接工具只能修改远端代码,但是无法使用 ubuntu 的环境运行,也就是在 windows 系统上无法运行,因为没有安装 docker,然后 idea2021.3 版本以后新出了一个 remote development 能进行远程运行,就去下载了 2021.3 版本,下完后发现使用不是很方便,很多配置环境的设置都找不到,然后又去下载了 jetbrains,使用这个进行远程开发,这个和 remote development 其实是一样的,只是和 idea 分离开,界面也更加友好,在成功连接后又下载 maven 依赖,第一个出现的问题是下载速度慢,而且下完之后会出现有部分依赖无法找到的情况,然后又上网找资料,发现是 maven3.8.1 以上会有一个不安全连接的限制,又去修改 maven 的 settings 文件,依赖成功下好后又出现 pom 文件不暴红,但是 java 文件引入这些依赖的时候有爆红,试过清空缓存,重启都没有解决,最后没办法了把 ubuntu 的远程文件全部清空重头来一遍就莫名其妙的解决了,
