Featured image of post 用 Java 实现在线评测系统

用 Java 实现在线评测系统

如何从0到1实现一个在线评测系统(OJ)?这篇文章介绍系统开发思路、技术选型以及其中的关键技术

思路和技术选型

系统面向的用户群体是高校老师和学生。老师以班级为单位,操作系统后台布置作业(添加若干个编程题);学生登录系统前台,浏览班级作业,在线提交源代码。待作业提交截止时,老师可以登录系统后台,浏览班级作业的完成情况,以及作业完成的质量。

基本功能需求如下:

  • 实现班级、用户、作业、题目、源代码等信息的前台展示和后台管理
  • 实现源代码在线提交、实时运行判题,实现进程资源占用监控和答案比对
  • 实现源代码相似度检测,防止抄袭

学生/教师 Web 端的各种内容基本就是增删改查,目前的逻辑不算复杂,难度不大。后端就采用 Spring 全家桶,因为之前从来没有彻底前后端分离地做过项目,所以这次打算改变一下,前端使用了 Vue.js 框架开发,页面单独发布。

主要的难点就在于判题内核的实现,包括代码的编译运行、测试点数据的处理、资源监控、结果判定等,还有源代码的查重,以及如何保证系统安全性等等。首先用户提交代码,系统编译执行肯定不能采用同步的方式(如果学生们聚集在 DDL 之前同时大量提交,系统很可能因此崩溃),我认为比较好的方式是使用队列,把项目拆分为三个子系统:Web API 服务、判题服务、查重服务,并分布式部署,其中判题服务做成判题集群,保证判题功能高效和高可用。服务间的通信使用消息队列中间件。判题和查重两个服务属于内部服务,不提供外部调用入口。整个系统是一个类似垂直型的应用架构。

  • JDK 1.8
  • MySQL
  • Redis
  • RabbitMQ
  • Spring Boot
  • Spring Data
  • Vue.js
  • Semantic-UI
  • Nginx
  • JMeter
  • Jenkins
  • Docker
  • SIM(A software and text similarity tester)

框架的部分就不再介绍了,本篇主要就是分享一些关键部分的解决思路和实现方式。

代码的编译运行

编译

以 Java 源代码为例,可直接通过 Process 执行 shell 命令 javac Main.java,生成类文件:

1
2
3
4
5
private int java(String[] strings) throws IOException, InterruptedException {
    Process compileProcess = Runtime.getRuntime().exec("javac "+strings[0]+strings[1]+strings[2]);
    int result = compileProcess.waitFor();
    return result;
}

这个方法可以返回目标文件的编译结果,返回 0 代表通过编译。

其实有一个现成的方法,是通过使用 javax.tools.JavaCompiler 类来处理文件编译,会方便许多,不过通过 shell 的方式也不复杂,两种方式都可以。

运行

除了要处理可执行程序的调用,还需要处理程序的输入输出,使用比较贴近操作系统的方式比较好,所以用了 C++来写,sys 库有许多现成系统 API 可供调用,方便处理输入输出、耗时内存监控等等。(一开始其实还是用 Java 做的,需要处理输入输出流以及父子进程,太不方便就放弃了)

处理过程:

  1. 开始
  2. 创建子进程
  3. 设置进程运行用户(低权限)
  4. 设置子进程 IO 重定向到文件
  5. 父进程启动监控,子进程开始执行 Run
  6. 子进程结束,父进程收集信息并返回
  7. 结束

其中部分关键代码如下:

(1)fork() 方法创建子进程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
bool createProcess(pid_t& pid, sigset_t& sigset) {
    sigset_t originalSigset;
    sigemptyset (&sigset);
    sigaddset (&sigset, SIGCHLD);
    if ( sigprocmask(SIG_BLOCK, &sigset, &originalSigset) < 0 ) {
        return false;
    }

    pid = fork();
    if ( pid == -1 ) {
        return false;
    }
    return true;
}

(2)为子进程设置运行用户 uid=1000(这个 Linux 用户提前创建好并查到它的 uid)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
void setupRunUser() {
    while ( setgid(1000) != 0 ) {
        std::cout <<  "[WARN] setgid(1000) failed." << std::endl;
        sleep(1);
    }
    while ( setuid(1000) != 0 ) {
        std::cout <<  "[WARN] setuid(1000) failed." << std::endl;
        sleep(1);
    }
    while ( setresuid(1000, 1000, 1000) != 0 ) {
        std::cout <<  "[WARN] setresuid(1000, 1000, 1000) failed." << std::endl;
        sleep(1);
    }
}

(3)dup2() 方法设置 I/O 重定向到文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void setupIoRedirection(
    const std::string& inputFilePath, const std::string& outputFilePath) {
    if ( inputFilePath != "" ) {
        int inputFileDescriptor = open(inputFilePath.c_str(), O_RDONLY);
        std::cout << "[DEBUG] Setup I/O Redirection: InputFilePath = " << inputFilePath << ", InputFileDescriptor = " << inputFileDescriptor << "." << std::endl;
        dup2(inputFileDescriptor, STDIN);
        close(inputFileDescriptor);
    }
    if ( outputFilePath != "" ) {
        int outputFileDescriptor = open(outputFilePath.c_str(), O_CREAT | O_WRONLY);
        std::cout << "[DEBUG] Setup I/O Redirection: OutputFilePath = " << outputFilePath << ", OutputFileDescriptor = " << outputFileDescriptor << "." << std::endl;
        if (outputFileDescriptor==-1) {
            std::cout << "[ERROR] Setup I/O Redirection: " << strerror(errno) << std::endl;
        }
        chmod(outputFilePath.c_str(), S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
        dup2(outputFileDescriptor, STDOUT);
        dup2(outputFileDescriptor, STDERR);
        close(outputFileDescriptor);
    }
}

对于程序执行时资源占用的监听,方式比较多。我通过父进程访问 /proc/{pid}/statm 文件,可得到子进程的内存占用情况。时间的统计就通过结束时毫秒数减去开始时毫秒数。

由于这部分代码使用 C++写,所以要先单独编译成 .so 文件,然后提供给 Java 通过 JNI 的方式调用。Java 拿到执行结果后,首先根据题目要求判断是否时间超限、内存超限,通过后再逐行比较程序输出的文件 以及 题目测试用例的输出文件,如果不完全一致,那就标记为答案错误。

测试数据处理

教师提供的题目测试用例都是保存在 MySQL 数据库中的,一个题目包含多条测试用例。处理方式是本题收到首次提交时,就把本题的所有测试用例通过一定的路径持久化到评测机硬盘上。题目运行时,首先检查测试用例输入输出文件是否存在,若不存在则创建。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public void save(Submission s, List<TestPoint> testPoints) throws IOException {
    //e.g. /data/tests/7/input7.txt
    String[] strings = {sysTestsPath+s.getId()+ File.separator};
    Path testPath = Paths.get(strings[0]);
    if (Files.exists(testPath)) {
        return;
    }
    Files.createDirectory(testPath);
    for (TestPoint t: testPoints) {
        Path inPath = Paths.get(strings[0] + "input" + t.getIndeex() + ".txt");
        Files.createFile(inPath);
        try(BufferedWriter writer = Files.newBufferedWriter(inPath, StandardCharsets.UTF_8, StandardOpenOption.WRITE)){
            writer.write(t.getInput());
            writer.flush();
        }
        Path outPath = Paths.get(strings[0] + "output" + t.getIndeex() + ".txt");
        Files.createFile(outPath);
        try(BufferedWriter writer = Files.newBufferedWriter(outPath, StandardCharsets.UTF_8, StandardOpenOption.WRITE)){
            writer.write(t.getOutput());
            writer.flush();
        }
    }
}

代码查重

使用开源工具 SIM 来查重,它提供一个命令行工具,可以返回两个文件(文本或源码)的内容相似度,可以检测多种编程语言。

它的项目首页、下载以及文档在此:Similarity Tester

简单来说,它的原理是基于语法树的比较,SIM 把源代码转化成语法树,然后比较两棵树的距离。由于语法树可以得到代码的 Tokens(比如符号,关键字,操作符,变量名,值等等),那么查重的结果应该是非常准确的,因为可以抵抗变量重命名,注释,代码重排等等,就非常合适。(原本还想用字符串匹配算法或者编辑距离算法来做,还是太年轻了,检测检测字符串还行,想做得好还要处理分词,统计什么的,有点难,还是上开源工具吧!)

然后还做了一个相似代码分组的功能。一个班里 30 个人的作业,交上去其实最多也就 5、6 个版本,这个功能可以展示出哪些同学的提交属于同一个组(版本),某个组内原创者是谁。

如 A 和 B 相似度超过 85%,B 和 C 相似度超过 85%,A 和 D 相似度超过 85%,可以简单认为 A、B、C 和 D 属于同一分组,也就是图论算法中的连通,同时它是无向的。所以计算分组可以查询出所有相似度大于 85%的条目并构成无向图,然后计算它的极大连通子图的数量,也就是最大连通分量。这个部分使用深度优先遍历实现。

系统安全保障

其实一开始这个问题我根本没考虑到,如果调皮的学生提交的代码里包含比如关机,删除文件这种会对系统造成破坏的命令,那就非常麻烦了。所以 OJ 一般都会把判题核心做成一个沙箱,代码的运行无法影响到其他地方。经过研究,普遍的实现方式就是设置运行用户权限非常低,并且还维护一个敏感命令、词汇的表,如果代码里包含这些敏感内容,就直接判定为非法提交。

这两年容器技术有点火,为了能跟上时代的步伐,我在开发这个项目的同时,学习了 Docker,并用它来做虚拟化。所有判题机、查重机,包括整个系统各个服务都 Docker 化,这种天然的沙箱机制用在这个项目里非常合适。

用了 Docker,就可以很轻松的实现教师后台直接操作服务集群的功能,直接使用 DockerRemote API,Docker 守护进程会绑定一个所在宿主机的套接字,也就是 unix:///var/run/docker.sock,把守护进程绑定到一个端口上:

docker -d -H unix:///var/run/docker.sock -H 0.0.0.0:9009

访问https://foohost:9009/info,会返回容器的运行信息。

可玩性很高,之后打算把评测节点做成弹性的。

总结

一开始确实低估了它的难度,做起来遇到了各种坎,各种坑,好在都解决了。除了无聊的各种增删改查以外,还是学到了包括许多关于前后端开发、操作系统和进程相关的新知识,带来了不错的个人成长。其中我个人认为的创新点是认证授权部分,感觉自己的方法是可行的,分享一下(我认为是安全的,可以讨论一下):关于用户认证,这个项目是设计成无状态的(因为要做服务集群,不想引入分布式会话问题,所以没有用 Session)。我用了类似 JWT 的一种 Token,比较简单,就是登录时对 #用户身份#用户 ID#过期时间 做加密(AES 算法,服务器保存好密钥),然后用 Base64 编码。用户登陆成功直接签发 Token 并返回,前端把 Token 存到 LocalStorage 里,之后每次用户请求都带在 HTTP Headers 里。注意这个 Token 是不需要统一保存数据库的,服务端只需要负责签发和验证。因为 Token 不仅用来验证,还用来传递身份信息过期信息,还能完全避免 CSRF 攻击。关于权限,只有学生和教师两种角色,资源的划分也比较明显,这个权限粒度已经相当粗了,用户鉴权用 AOP 实现,先编写一个 @TeacherRequired 自定义注解标记需要教师权限的接口,再编写一个 AOP 通知检查当前请求的用户角色,这么做起来感觉很清爽干净。


代码开源:shawnsky/hshe,欢迎 star 和提 issues

Licensed under CC BY-NC-SA 4.0
最后更新于 2022-03-25 21:18
comments powered by Disqus
Site built with Hugo, hosted by Firebase.
Theme Stack designed by Jimmy.