为什么要做一个 OJ 呢?因为能在自己的网站刷算法是一件很酷的事情!!


本系统已正式上线,欢迎使用。nngo.cn

本系统在后端开发上很大程度参考了前辈的 开源项目。在此特别鸣谢!!

架构设计

​ 本系统采用前后端分离架构。前端基于 Vue2 进行开发,使用 Nginx 作为反向代理服务器将用户请求代理转发 Vue 静态页面后端业务服务。后端微服务基于 Spring BootSpring Cloud Alibaba 框架进行构建并使用 Nacos 作为服务注册中心和配置中心,支持服务注册与发现、配置动态刷新,使用 Shiro+Jwt+Redis 实现登录认证和权限认证,选用 MySQL 作为数据库,使用 Redis 作为缓存中间件。程序评测使用开源 Docker 镜像作为判题机,保证程序的高性能判题和安全防护。使用 Dokcer 进行容器化部署,以提高系统的稳定性和扩展性。系统整体架构如下图所示:

image-20230305205125983

前端技术架构

  1. 技术以 Vue2 为主,Element-UI 为主要的 UI 框架。
  2. 使用 JS、CSS 实现响应式布局,同时支持手机、电脑端。
  3. CodeMirror 作为在线代码编辑器。
  4. Mavon-Editor 作为富文本编辑器。

后端技术架构

  1. 主体 Web 框架技术以 Spring Boot/Spring Cloud Alibaba 为主。
  2. Nacos 为分布式注册中心及分布式配置中心,支持服务发现、配置动态刷新。
  3. Mybatis-PlusMyBatis-Plus-Join 为数据库中间件,负责数据实体类与数据库数据的转化与获取以及连表操作。
  4. Shiro 为安全框架,并整合 JwtRedis 实现分布式下的授权认证问题。
  5. Redis 作为数据缓存中间件,以及使用它作为实现接口幂等的主要工具。
  6. 使用 JSR303 对用户的所有请求参数进行校验。
  7. 使用全局异常类处理系统抛出的所有异常,包括参数校验异常、数据库操作异常、访问受限等异常。
  8. 使用 Spring 提供的 RestTemplate 类实现各服务之间的调用,包括远程调用判题机。
  9. 使用线程池批量提交所有测试用例进行评测。
  10. 使用 QQ 邮箱负责邮件发送任务。

数据库设计

image-20230305205150337

上图为本系统数据库核心表的设计模型,以下讲解各表的作用以及它们之间的关系:

  1. 图中绿色的表为用户表和题目表,用户是是操作主体,题目表是操作对象。其他表都是在这两张表的基础上进行拓展。
  2. 图中粉色的表都与系统授权认证有关。权限表存储本系统中所有权限,使用权限字符串来表示不同的权限,例如 user::edit 表示编辑用户权限。角色表存储本系统所有角色并通过角色权限表为角色分配各个权限。最后通过用户角色表为用户分配角色,支持分配多个角色。
  3. 图中蓝色的表与系统的判题有关。题解表存储用户在题目详情页中发布的题解。测试用例表存储系统所有测试用例,用户提交代码判题时需要取出对应题目的所有测试用例进行评测。而判题记录表负责存储评测结果,同时存储判题状态码以实时更新一个判题流程中的各种状态。
  4. 图中黄色的表与系统的讨论模块有关。系统讨论分为评论和回复,评论指用户在某道题目底下发布评论,而回复指的是用户回复某个评论,它们分别对应评论表和回复表。
  5. 图中灰色的表为会话表,用户每次登录之后都会在该表中生成一条记录,此表作为系统的访问日志。

功能设计

题目详情

image-20230305204407270

题目详情页为用户前台最主要的页面,用户可在题目详情页编辑和评测代码,查看题目描述、讨论、题解以及提交记录。功能描述如下:

  1. 整体页面风格统一,简约大方,提高用户编码体验。同时支持手机端、电脑端,响应式布局。
  2. 页面左侧为题目 Tabs 栏,可查看题目描述、讨论、题解以及用户对该题目的提交记录。
  3. 题目描述使用 Mavon-Editor 富文本编辑器显示数据库存储的 Markdown 字符串。
  4. 右侧代码编辑区使用 CodeMirror 代码编辑器,支持用户自定义代码编辑区主题、字体大小。
  5. 右侧顶部选择框支持用户选择多种代码语言,每种语言在编辑区都有对应的高亮,用户切换语言可保存之前语言的代码内容,也可恢复默认代码内容。
  6. 右侧底部控制台为一个抽屉,支持打开和关闭,用户需要时打开即可,不占用页面空间。在控制台里可以选择提供的少数几个测试用例进行评测,同时支持用户自测,即用户自定义测试用例,选择后点击运行按钮后端会返回运行结果,包括运行时间、运行内存、错误信息。
  7. 可点击提交按钮将代码交给后端进行判题,这里后端使用该题目对应的测试用例库进行评测,评测过程对用户来说是异步的,用户可继续其他操作。后续用户可在提交记录板块查看评测结果。
  8. 用户可在讨论版块对发布评论或回复,限制 500 字数,支持发布表情、点赞。
  9. 用户可在题解板块查看该题目所有题解,并可发布自己的题解。

用户注册

image-20230305205246798

  1. 用户填写邮箱后前端会自动将邮箱发送给后端去异步检查该邮箱是否已被注册,并返回结果。

  2. 如未被注册则可点击发送验证码,请求后端发送验证码邮件。

  3. 后端检查 Redis 是否存在以 REGISTER_EMAIL_LOCK:用户邮箱key 的键值对,如果存在,说明用户已发送验证码,返回结果。

  4. 后端调用第三方邮件服务向注册邮箱发送验证码,并往 Redis 存入两个键值对:

    ①:keyREGISTER_EMAIL_LOCK:用户邮箱、过期时间为 60s,表示用户只能在 60s 后才能重新发送验证码。

    ②:keyREGISTER_CODE_PREFIX:用户邮箱value 为验证码、过期时间为10分钟,表示验证码的有效期为10分钟

  5. 之后用户填写正确的注册表单将邮箱、验证码、密码发送给后端进行注册。

  6. 后端从 Redis 中拿 keyREGISTER_CODE_PREFIX:用户邮箱 的键值对,如果不存在,说明验证码已过期,用户需要重新发送验证码;如果存在,拿到这个键值对的值,跟用户携带的验证码进行比对。如果比对失败,用户需重新获取验证码或填写正确的验证码;如果比对成功,进行最后一步。

  7. 后端对用户携带的密码进行 MD5 加密,为该用户分配 user 角色,并向数据库新增一条用户记录。

登录、授权认证

image-20230305205542166

  1. 用户发送邮箱、密码交给后端进行校验,如校验失败,返回结果。

  2. 后端通过 Jwt 为用户生成一个拥有过期时间的 token,一份存储到 Redis,一份下发给用户存储到 Local Storage

  3. 之后用户请求后端资源如果 Local Storage 存在 token 都会在请求头携带。JwtFilter 将会按步骤进行以下处理:

    ①:如果用户访问公共资源,跳出过滤器,用户访问成功;

    ②:如果用户请求头没有携带 token,跳出过滤器,进行 Shiro 注解过滤;

    ③:从 Redis 拿到用户的 token 与用户请求头携带的 token 比对。如果 token 失效,跳出过滤器,将错误码交给全局异常处理;

    ④:token 未失效,进行 Shiro 认证。

  4. Shiro认证:通过 Jwt 解析 token 拿到用户 ID,通过用户 IDRedis 拿用户信息,如果不存在,再从 MySQL 中获取,之后再将用户信息存入缓存。如果解析出来的用户 ID 没有对应的用户,则将错误码交个全局异常处理。

  5. Shiro注解过滤Shiro 支持在控制器方法上标识一系列注解,例如 @RequiresRoles@RequiresPermissions,它们表示用户需要有指定的角色或权限才能访问目标资源。因此 Shiro 会从 Redis 缓存中获取认证用户的权限,如果用户没有指定的权限,将授权异常抛给全局异常处理。

  6. 用户成功访问后端资源。

判题调度

image-20230305205952089

判题调度流程如下所诉:

第一步:用户通过前端界面向系统提交源代码、编译语言以及题目 ID。如果为自测(也就是单用例测试),用户还需传递标准输入、标准输出。

第二步:数据服务先判断该用户是否有权限评测代码,如果没有权限就返回用户访问受限信息。之后再判断当前是否存在其它判题任务,如果不存在就为用户生成一条判题记录并调用判题服务进行判题。以下为判断判题任务是否存在的伪代码:

1
2
3
4
5
6
7
8
9
10
11
// 获取锁  
long count = redisUtils.incr("judge-lock", 1);
if (count > 1) {
// 当前存在判题任务,拒绝此次判题
}
// 新增判题记录

// 远程调用判题服务并等待结果返回

// 删除锁
redisUtils.del("judge-lock");

其中远程调用判题服务需从 Nacos 中获取判题服务地址,并通过 Spring 提供的 RestTemplate 类发送 http 请求将判题 ID 传递给判题服务进行判题,以下为代码实现:

1
2
3
4
@Value("${judgeServer.judge.url}")  
private String judgeUrl;

JSONObject result = restTemplate.postForObject(judgeUrl, judgeId, JSONObject.class);

第三步:判题服务通过判题 IDMySQL 获取判题记录,根据评测语言构造对应的编译命令,之后调用封装好的函数调用判题机进行编译,如果编译出错,返回错误码,否则会返回成功状态码以及可执行文件 ID 。以下为本系统各语言的编译命令:

语言 编译命令
C gcc main.c -o main
C++ g++ main.cpp -o main
Java javac Main.java && jar -cvf Main.jar *.class
Python python3 -m py_compile main.py

第四步:如果编译失败,更新判题状态码并返回判题 ID,本次判题结束。否则构造运行命令并获取所评测题目的所有测试用例,为每个测试用例都构造一个线程任务。

以下为本系统各语言的执行命令:

语言 执行命令
C main
C++ main
Java java -cp Main.jar Main
Python python3 __pycache__/main.cpython-310.pyc

以下为线程任务的构造代码:

1
2
3
4
FutureTask<JSONObject> = new  FutureTask<>(() -> {       
JSONObject result = normalJudge.judge(judgeDTO, judgeGlobalDTO);
return result;
};

其中 judgeDTO 存储所属测试用例的标准输入和标准输出等信息,judgeGlobalDTO 存储代码运行时的最大运行时间、内存等限制。judge 函数会调用判题机执行用户代码。

第五步:将所有线程任务交给线程池,线程池会调用判题机执行评测任务并返回结果。

在本系统中,有两种对测试用例的评测方式:

1)全部测评,所有测试用例都必须评测,不分先后,异步。以下为代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private List<JSONObject> SubmitBatchTask2ThreadPool(List<FutureTask<JSONObject>> futureTasks)
throws InterruptedException, ExecutionException {
// 将所有线程任务提交到线程池
for (FutureTask<JSONObject> futureTask : futureTasks) {
ThreadPoolUtils.getInstance().getThreadPool().submit(futureTask);
}
List<JSONObject> result = new LinkedList<>();
while (futureTasks.size() > 0) {
Iterator<FutureTask<JSONObject>> iterable = futureTasks.iterator();
// 迭代器遍历线程任务
while (iterable.hasNext()) {
FutureTask<JSONObject> future = iterable.next();
if (future.isDone() && !future.isCancelled()) {
// 获取线程返回结果
JSONObject tmp = future.get();
result.add(tmp);
// 任务完成移除任务
iterable.remove();
} else {
Thread.sleep(10); // 避免 CPU 高速运转,休息 10 毫秒
}
}
}
return result;
}

2)遇错止评,按顺序评测所有测试用例,如果某个测试用例执行失败,结束评测,同步。以下为代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
private JSONObject SubmitTask2ThreadPool(FutureTask<JSONObject> futureTask)
throws InterruptedException, ExecutionException {
// 提交到线程池进行执行
ThreadPoolUtils.getInstance().getThreadPool().submit(futureTask);
while (true) {
if (futureTask.isDone() && !futureTask.isCancelled()) {
// 获取线程返回结果
return futureTask.get();
} else {
Thread.sleep(10); // 避免 CPU 高速运转,休息 10 毫秒
}
}
}

该方法接收单个线程任务并提交给线程池,返回线程执行结果,上层函数判断结果是否正确,如果正确则传递下一个线程任务继续执行该方法。直至遇到错误的执行结果或者所有任务都执行结束。

第六步:最后构造本次判题的评测结果,包括判题状态码、消耗的资源(如时间、内存等)以及错误信息,修改对应的判题记录,返回判题 ID。

第七步:用户提交判题任务后,即可在提交记录页中查看判题状态,无需等待接收判题 ID。如果提交页面没被销毁,接收到判题 ID 后自动跳转并打开提交记录。

判题机

判题机使用开源 Docker镜像 进行构造,此镜像运行时会暴露接口接收命令并在容器内部的操作系统运行,支持为命令设置运行限制以及控制台输入,之后会将命令执行后的控制台输出返回,并返回命令实际运行时间、内存等信息。

镜像主要 REST API 接口如下表所示:

方法 接口 解释
POST /run 在受限环境中运行命令
DELETE /file/fileId 删除文件 ID 指定的文件

以下为 /run 接口主要参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"cmd": [
{
"args": ["touch", "a.txt ", "b.txt"],
"files": [{"content": ""}],
"realCpuLimit":30000000000,
"memoryLimit": 104857600,
"stackLimit":134217728,
"copyIn": {
"a.txt": {"content": "1111"},
"b.txt”:{"fileId","WDQA5TNDRRVC2KAP"}
},
"copyOut": ["stdout", "stderr"],
"copyOutCached": ["a.txt"],
}
]
}

上诉各参数释义如下表所示:

参数 解释
args 需要执行的命令
files 命令执行时所需控制台输入
realCpuLimit 命令最大执行时间(ns
memoryLimit 命令最大运行内存(byte
stackLimit 命令最大栈空间(byte
copyIn 命令执行前需要拷贝进文件的内容
copyOut 命令执行后需要拷贝出来的内容
copyOutCached 命令执行后需要生成文件 ID 的文件

其中 copyOut 可选值有 stdoutstderr,表示命令执行后需要返回控制台输出以及报错信息。 copyOutCached 表示命令执行后将命令中的指定文件拷贝到文件系统中并未其生成一个文件 ID 返回。

以下为 /run 接口返回结果:

1
2
3
4
5
6
7
8
{
"status": "Accepted",
"exitStatus": 0,
"time": 303225231,
"memory": 32243712,
"files": {"stderr": "","stdout": ""},
"fileIds": {"a.txt": "WDQL5TNLRRVB2KAP"}
}

上诉各参数释义如下表:

参数 解释
status 状态,表示执行成功与否
exitStatus 状态码,执行成功为 0
time 运行时间(ns
memory 所耗内存(byte
files stdout:程序输出、stderr:报错信息
fileIds 拷贝到文件系统的文件 ID

为了能够运行代码,还需要进入镜像内部安装编译环境并重新构建成新的镜像。本系统支持的语言以及对应的安装命令如下表所示:

语言 安装命令
C/C++ apt install g++
Java apt install openjdk-11-jdk
Python apt install python3 python3-pip

至此判题机就已经构造完成了,之后在判题服务构造相应的方法,来编译和执行代码。以下为判题服务对 /run 接口的封装函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 调用判题机 /run 接口
*/
public JSONObject run(String uri, JSONObject cmd) throws SystemError{

JSONObject param = new JSONObject().set("cmd", new JSONArray().put(cmd));

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(JSONUtil.toJsonStr(param), headers);
ResponseEntity<String> postForEntity;
try {
postForEntity = restTemplate.postForEntity(SANDBOX_BASE_URL + uri, request, String.class);
JSONObject result = (JSONObject) JSONUtil.parseArray(postForEntity.getBody()).get(0);
return result;
} catch (RestClientResponseException ex) {
if (ex.getRawStatusCode() != 200) {
throw new SystemError("Cannot Connect To SandBox Service.", null, ex.getResponseBodyAsString());
}
} catch (Exception e) {
throw new SystemError("Call SandBox Error.", null, e.getMessage());
}
return null;
}

以下为判题服务对 /file:fileId 接口的封装函数:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 删除判题机文件系统中的文件
*/
public static void delFile(String fileId) {
try {
restTemplate.delete(SANDBOX_BASE_URL + "/file/{0}", fileId);
} catch (RestClientResponseException ex) {
if (ex.getRawStatusCode() != 200) {
log.error("Delete SanBox File Error: {}", ex.getResponseBodyAsString());
}
}
}

C 语言为例,判题机调用流程如下所诉:

​ 当用户提交 C 语言代码,构造对应的编译命令(gcc main.c -o main),之后调用 run 函数执行编译命令,在命令执行前将用户代码拷贝进 main.c 文件中("copyIn": {"main.c": {"content": "code"}}),如果编译没有出错,将编译后的可执行文件 main 拷贝到文件系统,为其生成一个 ID 并返回("copyOutCached": ["main"])。之后构造执行命令(main),再次调用 run 函数执行代码,通过文件 ID 将文件系统中的可执行文件内容拷贝进 main 文件("copyIn":{"main":{"fileId":"id"}}),程序执行所需的控制台输入(即测试用例)从 files 中获取("files": [{"content": ""}]),执行结束返回用户程序输出结果("copyOut": ["stdout", "stderr"])以及运行时间等信息,最后调用 delFile 函数传递文件 ID 将文件系统中的文件删除。

在判题机中执行命令所需的文件会在执行前创建,执行后生成的文件如果没有拷贝到文件系统,命令执行后自动删除。

评测引擎

当判题机评测完所有测试用例,还需要根据所有线程结果去构造评测结果。

本系统判题状态分为以下几种:

判题状态 状态码 注释
Submitted Failed -3 提交失败
Compile Error -2 编译错误
Wrong Answer -1 错误答案
Accepted 0 正确答案
Time Limit Exceeded 1 时间超限
Memory Limit Exceeded 2 内存超限
Output Limit Exceeded 3 输出超限
Runtime Error 4 运行时异常
System Error 5 系统异常
Pending 6 判题中

以下为评测引擎捕获流程图:

image-20230305213723810

一次完整的评测结果构造如下所诉:

  1. 当后端接收到用户代码后,生成一条判题记录并设置判题状态为 Pending
  2. 判断是否是支持的评测语言,如果不支持更新判题状态为 Submitted Failed,如果支持则进行编译。
  3. 如编译失败,更新判题状态为 Compile Error
  4. 编译成功,判断判题机是否返回了可执行文件 ID,如未返回更新判题状态为 Submitted Failed
  5. 调用判题机指定可执行文件 ID 运行用户程序,如果程序运行时出错,更新判题状态为 Runtime Error
  6. 检查结果,若程序运行时间超出题目最大时间限制,更新判题状态为 Time Limit Exceeded
  7. 若程序运行内存超出题目最大内存限制,更新判题状态为 Memory Limit Exceeded
  8. 若程序输出超出题目最大输出限制,更新判题状态为 Output Limit Exceeded
  9. 程序输出MD5标准输出MD5不一致,更新判题状态为 Wrong Answer,否则更新判题状态为 Accepted
  10. 整个过程如果系统发生错误,更新判题状态为 System Error,判题结束。

项目部署

image-20230305214026970

上图为项目部署后的架构,以下介绍各模块如何部署以及负责的功能:

  1. 服务器采用基于 Linux 建立的 CentOS8 操作系统作为项目部署环境。
  2. 图中 Vue 为本地 Vue 项目打包后的静态页面,其存放在服务器的文件系统中。
  3. 使用 Nginx 作为 反向代理服务器,负责接收用户请求,如果请求 URI 没有携带 /api 前缀,将请求转发到静态页面并展示到用户浏览器中。之后用户会发送 Ajax 请求后端资源,这些请求的 URI 都会携带 /api 前缀,Nginx 重写 URI/api 前缀去掉再将请求转发到数据服务。
  4. 后端之间调度所需的所有应用程序在 Docker 进行部署,每个应用运行在各自的 Docker容器 里,相当于拥有一个属于自己的虚拟化运行环境。使用端口映射将服务器端口与容器端口进行绑定,使容器内外网络互通。
  5. Docker 中创建一个桥接网络,为所有容器配置一个相同的网关。使各容器之间能通过网关进行通信。
  6. 使用 Docker容器数据卷RedisMySQL 中的数据持久化到服务器本地的文件系统中,避免因删除容器导致数据丢失。
  7. 数据服务、判题服务基于 Java:8 镜像进行部署,部署前需在 pom 文件中指定启动类以及绑定资源路径并进行打包,之后编写 Dockerfile,将服务构建成镜像。
  8. 判题机本身就是一个开源 Docker镜像,从 Docker 仓库拉取后,进入容器内部安装编译环境,最后使用 commit 命令在原镜像的基础上构建一个新的镜像。

总结与展望

总结

  • 数据收集:在项目初期广泛参考同类型网站的前端界面设计以及实现的功能,了解了哪些功能使用较多以及如何设计界面以让用户有更好的编码体验,对于系统在设计方面有极大帮助。
  • 功能更新:在系统的代码实现阶段,许多设计好的功能用已学的技术并不能够实现,需要先学习再应用。例如为了实现权限认证学习了 Shiro 框架,但是又因为 Shiro 默认将权限信息存储在 Session 中,而系统架构是分布式,所以还学习了如何将 ShiroRedis 整合,将权限信息存储在 Redis。整个系统的功能更新过程属于边学边应用,并且以实现核心功能作为首要工作。
  • 问题识别:在功能的实现过程中,会不可避免的出现了很多问题。例如版本兼容性问题导致的后端服务不被 Nacos 发现并注册、低版本 MySQL 不支持 utf8mb4 编码以至于不能存储表情、aarch64 的系统架构导致没有支持的 java:8 镜像进行部署测试。每个问题都不是因为编码导致的,需要付诸耐心去发现问题并解决。
  • 代码检查:写代码总避免不了粗心大意,例如本系统使用 JSR303 对请求参数进行数据校验,但是由于几乎所有的后端接口都需要校验就没有对它们都进行测试,导致将近一半的接口忘记写 @Valid 注解进行校验,如果没有及时发现,将会极大的降低系统的安全性。所以养成检查代码的习惯并及时的对功能进行测试是非常有必要的。

展望

  • 提高并发:系统目前在同一时间段内仅支持一个用户进行判题,其他用户的判题将会被系统拒绝,不符合系统正常的判题需求量。后续可以将用户的判题请求加入消息对列,之后轮询消息对列的判题请求调用判题机进行评测。也可以添加判题机数量,判题服务通过调用空闲的判题机进行评测从而提高判题请求的并发量。
  • 语言支持:系统目前支持用户提交 C/C++、Java、Python 四种代码语言进行判题,与其他编程网站相比,显然支持的语言还是不足。可在判题机中安装更多语言的编译环境,并在判题服务构造各语言的编译命令、运行命令枚举,当然对于像 JavaScript 这种无需编译就可运行的解释型脚本语言,仅需构造运行命令。
  • 简化部署:系统目前的部署流程复杂,需要各自构建部署并且对各应用部署的先后顺序有严格要求。例如必须先部署 NacosMySQL 才能部署数据服务和判题服务,否则报错。而且如果之后拓展功能,还需要本地重新打包项目再上传到服务器进行部署。后续可以将本地项目上传到代码仓库,之后在服务器使用 Git 拉取项目并使用 Docker Compose 编排各容器之间的调用顺序,只要一个命令,完成一键部署上线。