思路和技术选型
系统面向的用户群体是高校老师和学生。老师以班级为单位,操作系统后台布置作业(添加若干个编程题);学生登录系统前台,浏览班级作业,在线提交源代码。待作业提交截止时,老师可以登录系统后台,浏览班级作业的完成情况,以及作业完成的质量。
基本功能需求如下:
- 实现班级、用户、作业、题目、源代码等信息的前台展示和后台管理
- 实现源代码在线提交、实时运行判题,实现进程资源占用监控和答案比对
- 实现源代码相似度检测,防止抄袭
学生/教师 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
,生成类文件:
|
|
这个方法可以返回目标文件的编译结果,返回 0 代表通过编译。
其实有一个现成的方法,是通过使用 javax.tools.JavaCompiler
类来处理文件编译,会方便许多,不过通过 shell 的方式也不复杂,两种方式都可以。
运行
除了要处理可执行程序的调用,还需要处理程序的输入输出,使用比较贴近操作系统的方式比较好,所以用了 C++来写,sys 库有许多现成系统 API 可供调用,方便处理输入输出、耗时内存监控等等。(一开始其实还是用 Java 做的,需要处理输入输出流以及父子进程,太不方便就放弃了)
处理过程:
- 开始
- 创建子进程
- 设置进程运行用户(低权限)
- 设置子进程 IO 重定向到文件
- 父进程启动监控,子进程开始执行 Run
- 子进程结束,父进程收集信息并返回
- 结束
其中部分关键代码如下:
(1)fork()
方法创建子进程
|
|
(2)为子进程设置运行用户 uid=1000(这个 Linux 用户提前创建好并查到它的 uid)
|
|
(3)dup2()
方法设置 I/O 重定向到文件
|
|
对于程序执行时资源占用的监听,方式比较多。我通过父进程访问 /proc/{pid}/statm
文件,可得到子进程的内存占用情况。时间的统计就通过结束时毫秒数减去开始时毫秒数。
由于这部分代码使用 C++写,所以要先单独编译成 .so 文件,然后提供给 Java 通过 JNI 的方式调用。Java 拿到执行结果后,首先根据题目要求判断是否时间超限、内存超限,通过后再逐行比较程序输出的文件 以及 题目测试用例的输出文件,如果不完全一致,那就标记为答案错误。
测试数据处理
教师提供的题目测试用例都是保存在 MySQL 数据库中的,一个题目包含多条测试用例。处理方式是本题收到首次提交时,就把本题的所有测试用例通过一定的路径持久化到评测机硬盘上。题目运行时,首先检查测试用例输入输出文件是否存在,若不存在则创建。
|
|
代码查重
使用开源工具 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