判题机模块-后端
技术选型
Springboot + Docker + Mybatis 实现的判题机服务。
基本架构
为了提升项目的模块化程度,降低耦合度,我们将传统的判题模块分为判题机服务模块和代码沙箱,我们通过约定 key 的形式实现判题模块和代码沙箱的安全通信。我们只将重点,所以我们省去与前端的对接部分,重点说明判题机,以及其与代码沙箱的关系。
判题机模块的作用是查询题目提交和题目信息,调用代码沙箱,把代码的一组输入用例交给代码沙箱去执行,收集输出结果并执行判题逻辑。
代码沙箱的选择
调用代码沙箱
考虑后续,我们可能会选择多个代码沙箱,所以我们使用了设计模式中的工厂模式来通过配置文件自动选择代码沙箱的类型:
- 示例代码沙箱:跑通业务流程;
- 远程代码沙箱:调用自己开发的沙箱接口;
- 第三方代码沙箱:调用现成的代码沙箱,当自己的沙箱出现了问题,就可以转给第三方的代码沙箱来执行,提升了程序的健壮性;
具体来说,我们通过application.yml中的:
codesandbox:
type: remote
来指定调用的代码沙箱。使用方式如下:
@Value("${codesandbox.type}")
private String type;
---
CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(type);
codesandbox.execute(request);
---
代码沙箱的调用代理
有的时候,我们需要对监控代码沙箱的执行,例如在调用沙箱前后,执行日志记录。为了实现这个功能,我们利用了静态代理模式,开发者仅仅只需要按照如下方式调用即可:
- 创建
Proxy类; - 执行相关逻辑;
@Slf4j
public class CodeSandboxProxy implements CodeSandbox {
private CodeSandbox codeSandbox;
public CodeSandboxProxy(CodeSandbox codeSandbox) {
this.codeSandbox = codeSandbox;
}
@Override
public ExecuteCodeResponse execute(ExecuteCodeRequest executeCodeRequest) {
log.info("Executing CodeSandboxProxy: {}", executeCodeRequest.toString());
ExecuteCodeResponse executeCodeResponse = codeSandbox.execute(executeCodeRequest);
log.info("Executed CodeSandboxProxy: {}", executeCodeResponse.toString());
return executeCodeResponse;
}
}
多语言判题逻辑实现
实际情况中,用户提交的代码语言往往是多样,由此对于用户代码提交结果的评判也是多样的,故而我们设计使用了策略模式来处理不同用户不同语言的代码提交结果。从而提高了代码的可拓展性。如果不采用该实现办法,那么就必须得在代码中使用 if-else,这样不仅可读性,日后也不利于新语言判题逻辑的加入。具体的实现方式为:
- 定义了判题策略接口:
JudgeStrategy,提供方法doJudge; - 对于不同的语言只需要实现该接口,就可以实现不同的判题逻辑了。例如,对于 Java 语言,我们可以创建类
JavaLanguageJudgeStrategy(实现了接口JudgeStrategy)。 - 将判题策略所需要的所有信息(上下文)封装为类
JudgeContext,这样简化了该方法的参数。提升了可读性。 - 最后我们设计了
JudgeStrategyManager类用于根据不同的语言调用不同的判题策略。
具体的调用方式为:
@Resource
private JudgeStrategyManager judgeStrategyManager;
---
JudgeInfo newJudgeInfo = judgeStrategyManager.doJudge(judgeContext);
安全管理
考虑用户可能提供一些高危险性的代码从而危害服务器的安全,可能的恶意代码有:
- 超时执行;
- 过量内存占用;
- 读文件,信息泄露;
- 写文件,植入木马;
- 运行其他程序;
- 其他高危操作;
限制敏感关键词
通过字典树检查文件中是否有敏感词,如果有敏感词直接返回错误信息。具体的,我们可以使用 hutool 工具类中的 WordTree 来实现。
Java 安全管理器
在我最初的计划中,是打算使用该安全管理器的,但是实际测试下来,发现了几个问题。一是控制颗粒度不够,该方法只能控制整个程序的行为,而无法对用户执行的程序进行控制,精细控制需要具体到包名;二是,该方法是基于软件层面实现的,所以必然会存在意想不到的漏洞,这也就存在安全隐患。
这个方法在 jdk 17 之前有效,现在这个类已经在 jdk 17 中废除了。不过哪怕在适合的版本,也不推荐使用,因为该类很难用,例如需要通过加白名单的方式来排除对于 jar 包的校验。
经过以上的综合考虑,我决定放弃用 Java SecurityManager 来实现安全管理。转而使用其他方法。
运行环境隔离
在操作系统层面上,将用户的程序封装到 Docker 中,利用 Docker 的安全机制去保证用户程序无法影响到宿主机。这是我们的主要办法,由于我们采用了代码沙箱和判题机分离的架构,所以这种方法最容易实现。我们的具体限制如下:代码沙箱实现。