耗时一个月开发的OJ在线判题系统,文末有项目地址,目前还在更新代码~
今天我们来开发OJ系统后端核心流程之一的判题模块
文章目录
- 判题机模块与代码沙箱的关系
- 代码沙箱架构开发
- 判题服务开发
- 判题服务业务流程
- 判断逻辑
- 策略模式优化
- 小知识-Lombox Builder 注解
- 小知识-Mybatis-plus updateById方法
- 项目地址
判题机模块与代码沙箱的关系
判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行
代码沙箱:只负责接收代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目/服务,提供给其他的需要执行代码的项目去使用)
这两个模块完全解耦:
注意:每次代码沙箱要接受和输出一组运行用例
这是一种很常见的性能优化方法(批处理)
因为如果是每个用例单独调用一次代码沙箱,会调用多次接口,需要多次网络传输,程序要多次编译、记录程序的执行状态
代码沙箱架构开发
1)定义代码沙箱的接口,提高通用性
public interface CodeSandbox {/*** 执行代码* @param excodeCodeRequest* @return*/ExecutecodeResponse executeCode(ExecutecodeCodeRequest excodeCodeRequest);
}
之后我们的项目代码只调用接口,不调用具体的实现类,这样在你使用其他的代码沙箱实现类时,就不用去修改名称了,便于扩展
代码沙箱的求接口中,timeLimit可加可不加,可自行扩展,即时中断程序
扩展思路:增加一个查看代码沙箱状态的接口
2)定义多种不同的代码沙箱实现
示例代码沙箱:仅为了跑通业务流程
远程代码沙箱:实际调用接口的沙箱
第三方代码沙箱:调用网上现成的代码沙箱:https://github.com/criyle/go-judge
3)编写单元测试:验证单个代码沙箱的执行
@Testvoid executeCode(){CodeSandbox codesandbox = new ExampleCodeSandbox();String code = "int main(){}";String language = QuestionSubmitLanguageEnum.JAVA.getValue();List<String> inputList = Arrays.asList("1 2","3 4");ExecutecodeCodeRequest executecodeCodeRequest = ExecutecodeCodeRequest.builder().code(code).language(language).inputList(inputList).build();ExecutecodeResponse executecodeResponse = codesandbox.executeCode(executecodeCodeRequest);Assertions.assertNotNull(executecodeResponse);}
但现在的问题是,我们把new某个沙箱的代码写死了,如果后面项目要改用其他沙箱,可能要改很多地方的代码。
4)使用工厂模式,根据用户传入的字符串参数(沙箱类别),来生成对应的代码沙箱实现类,此处使用静态工厂模式,实现比较简单,符合我们的需求。
/*** 代码沙箱创建工厂:根据指定的字符串参数,创建指定的代码沙箱实例*/
public class CodeSandboxFactory {/*** 创建代码沙箱实例* @param type 沙箱类型* @return 返回的是接口,而不是具体的实现类*/public static CodeSandbox newInstance(String type){switch (type){case "example":return new ExampleCodeSandbox();case "remote":return new RemoteCodeSandbox();case "Third":return new ThirdPartyCodeSandbox();default:return new ExampleCodeSandbox();}}}
测试类可改成下面
@Testvoid executeCode(){String type = "remote";CodeSandbox codesandbox = CodeSandboxFactory.newInstance(type);String code = "int main(){}";String language = QuestionSubmitLanguageEnum.JAVA.getValue();List<String> inputList = Arrays.asList("1 2","3 4");ExecutecodeCodeRequest executecodeCodeRequest = ExecutecodeCodeRequest.builder().code(code).language(language).inputList(inputList).build();ExecutecodeResponse executecodeResponse = codesandbox.executeCode(executecodeCodeRequest);Assertions.assertNotNull(executecodeResponse);}
5)参数配置化,把项目中的一些可以交给用户去自定义的选项或字符串,写到配置文件中,这样开发者只需要改配置文件,而不需要去看你的项目代码,就能够自定义使用你项目的更多功能
application.yml配置文件中指定变量:
# 代码沙箱配置
codesandbox:type: example
在Spring的Bean中通过@Value 注解读取 :
@Value("${codesandbox.type:example}")
private String type;
6)代理模式优化
比如:我们需要在调用沙箱代码前,输出请求参数日志,在调用沙箱代码后,输出响应结果日志,便于管理员去分析
难道每个代码沙箱类都写一遍log.info?难道每次调用代码沙箱前后都执行log?
使用代理模式,提供一个Proxy,来增强代码沙箱的能力(代理模式的作用就是增强能力)
原本:需要用户自己去调用多次
使用代理后:不仅不用改变原本的代码沙箱实现类,而且对调用者来说,调用方式几乎没有改变,也不需要再每个调用沙箱的地方去写统计代码
代理模式的实现原理:
1、实现被代理的接口
2、通过构造函数接受一个被代理的接口实现类
3、通过被代理的接口实现类,在调用前后增加对应的操作
CodeSandboxProxy 示例代码:
@Slf4j
public class CodeSandboxProxy implements CodeSandbox {private final CodeSandbox codeSandbox;public CodeSandboxProxy(CodeSandbox codeSandbox) {this.codeSandbox = codeSandbox;}@Overridepublic ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {log.info("代码沙箱请求信息:" + executeCodeRequest.toString());ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);log.info("代码沙箱响应信息:" + executeCodeResponse.toString());return executeCodeResponse;}
}
使用方式:
CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(type);
codeSandbox = new CodeSandboxProxy(codeSandbox);
7)实现示例的代码沙箱
/*** 示例代码沙箱(仅为了跑通业务流程)*/
@Slf4j
public class ExampleCodeSandbox implements CodeSandbox {@Overridepublic ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {List<String> inputList = executeCodeRequest.getInputList();ExecuteCodeResponse executeCodeResponse = new ExecuteCodeResponse();executeCodeResponse.setOutputList(inputList);executeCodeResponse.setMessage("测试执行成功");executeCodeResponse.setStatus(QuestionSubmitStatusEnum.SUCCEED.getValue());JudgeInfo judgeInfo = new JudgeInfo();judgeInfo.setMessage(JudgeInfoMessageEnum.ACCEPTED.getText());judgeInfo.setMemory(100L);judgeInfo.setTime();executeCodeResponse.setJudgeInfo(judgeInfo);return executeCodeResponse;}
}
判题服务开发
定义单独的judgeService 类,而不是把所有判题相关的代码写到questionSubmitService 里,有利于后续的模块抽离,微服务改造
判题服务业务流程
1)传入题目的提交id,获取到对应的题目,提交信息(包含代码,编程语言等)
2)如果题目提交状态不为等待中,就不用重复执行了,只执行等待中的题目
3)更改题目提交状态为“判题中”,防止重复执行,也能让用户即时看到状态
4)调用沙箱,获取到执行结果
5)根据沙箱的执行结果,设置题目的判题状态和信息
判断逻辑
1、先判断沙箱执行的结果输出数量是否和预期输出数量相等
2、依次判断每一项输出和预期输出结果是否相等
3、判题题目的限制是否符合要求
4、可能还有其他的异常情况
坐标:yoj-backend\src\main\java\com\yupi\yoj\judge\JudgeServiceImpl.java
@Service
public class JudgeServiceImpl implements JudgeService {@Value("${codeSandbox.type:example}")private String type;@Resourceprivate QuestionSubmitService questionSubmitService;@Resourceprivate QuestionService questionService;@Overridepublic QuestionSubmitVO doJudge(long questionSubmitId) {
// 1)传入题目的提交id,获取到对应的题目,提交信息(包含代码,编程语言等)QuestionSubmit questionSubmit = questionSubmitService.getById(questionSubmitId);if(questionSubmit == null){throw new BusinessException(ErrorCode.NOT_FOUND_ERROR,"题目提交信息不存在");}Long questionId = questionSubmit.getQuestionId();Question question = questionService.getById(questionId);if(question == null){throw new BusinessException(ErrorCode.NOT_FOUND_ERROR,"题目不存在");}
// 2)如果题目提交状态不为等待中,就不用重复执行了,只执行等待中的题目Integer status = questionSubmit.getStatus();if(!Objects.equals(status, QuestionSubmitStatusEnum.WAITING.getValue())){throw new BusinessException(ErrorCode.OPERATION_ERROR,"题目正在判題中");}
// 3)更改题目提交状态为“判题中”,防止重复执行,也能让用户即时看到状态QuestionSubmit questionSubmitUpdate = new QuestionSubmit();questionSubmitUpdate.setId(questionSubmit.getId());questionSubmitUpdate.setStatus(QuestionSubmitStatusEnum.RUNNING.getValue());boolean save = questionSubmitService.updateById(questionSubmitUpdate);if(!save){throw new BusinessException(ErrorCode.SYSTEM_ERROR,"题目状态更新失败");}
// 4)调用沙箱,获取到执行结果CodeSandbox codesandbox = CodeSandboxFactory.newInstance(type);codesandbox = new CodeSandboxProxy(codesandbox);String code = questionSubmit.getCode();String language = questionSubmit.getLanguage();//输入用例转列表String judgeCaseStr = question.getJudgeCase();List<JudgeCase> judgeCaseList = JSONUtil.toList(judgeCaseStr, JudgeCase.class);//把判题用例中的输入用例过滤出来,喂给需要的inputList,得到了输入列表List<String> inputList = judgeCaseList.stream().map(JudgeCase::getInput).collect(Collectors.toList());ExecutecodeCodeRequest executecodeCodeRequest = ExecutecodeCodeRequest.builder().code(code).language(language).inputList(inputList).build();
// 5)根据沙箱的执行结果,设置题目的判题状态和信息
// 1、先判断沙箱执行的结果输出数量是否和预期输出数量相等
// 2、依次判断每一项输出和预期输出结果是否相等
// 3、判题题目的限制是否符合要求
// 4、可能还有其他的异常情况ExecutecodeResponse executecodeResponse = codesandbox.executeCode(executecodeCodeRequest);List<String> outputList = executecodeResponse.getOutputList();JudgeInfoMessageEnum judgeInfoMessageEnum = JudgeInfoMessageEnum.WAITING;if(inputList.size() != outputList.size()){judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;return null;}for(int i = 0; i < outputList.size();i ++){if(!Objects.equals(outputList.get(i), inputList.get(i))){judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;return null;}}//判断题目限制是否符合要求JudgeInfo judgeInfo = executecodeResponse.getJudgeInfo();Long time = judgeInfo.getTime();Long memory = judgeInfo.getMemory();String judgeConfigStr = question.getJudgeConfig();JudgeConfig judgeConfig = JSONUtil.toBean(judgeConfigStr, JudgeConfig.class);Long needtimeLimit = judgeConfig.getTimeLimit();Long needmemoryLimit = judgeConfig.getMemoryLimit();if(time > needtimeLimit){judgeInfoMessageEnum = JudgeInfoMessageEnum.TIME_LIMIT_EXCEEDED;return null;}if(memory > needmemoryLimit){judgeInfoMessageEnum = JudgeInfoMessageEnum.MEMORY_LIMIT_EXCEEDED;return null;}return null;}
}
策略模式优化
我们的判题策略可能会有很多种,比如:我们的代码沙箱本身执行程序需要消耗时间,这个时间可能不同的编程语言是不同的,比如沙箱执行java要额外话10s
我们可以采用策略模式,针对不同的情况,定义独立的策略,便于修改策略和维护。而不是把所有的判题逻辑,if …else…代码全部混在一起写
实现步骤
1)定义判题策略接口,让代码更加通用化
public interface JudgeStrategy {/*** 执行判题* @param judgeContext* @return*/JudgeInfo doJudge(JudgeContext judgeContext);
}
2)定义判题上下文对象,用于定义在策略中传递的参数(可以理解为一种DTO)
@Data
public class JudgeContext {private JudgeInfo judgeInfo;private List<String> inputList;private List<String> outputList;private List<JudgeCase> judgeCaseList;private Question question;private QuestionSubmit questionSubmit;}
3)实现默认判题策略,先把judgeService中的代码搬运过来
public class DefaultJudgeStrategy implements JudgeStrategy {@Overridepublic JudgeInfo doJudge(JudgeContext judgeContext) {// 1、先判断沙箱执行的结果输出数量是否和预期输出数量相等
// 2、依次判断每一项输出和预期输出结果是否相等
// 3、判题题目的限制是否符合要求
// 4、可能还有其他的异常情况JudgeInfo judgeInfo = judgeContext.getJudgeInfo();Long time = judgeInfo.getTime();Long memory = judgeInfo.getMemory();List<String> inputList = judgeContext.getInputList();List<String> outputList = judgeContext.getOutputList();List<JudgeCase> judgeCaseList = judgeContext.getJudgeCaseList();Question question = judgeContext.getQuestion();QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();JudgeInfoMessageEnum judgeInfoMessageEnum = JudgeInfoMessageEnum.ACCEPTED;JudgeInfo judgeInfoResponse = new JudgeInfo();judgeInfoResponse.setTime(time);judgeInfoResponse.setMemory(memory);if(inputList.size() != outputList.size()){judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}for(int i = 0; i < outputList.size();i ++){if(!Objects.equals(outputList.get(i), inputList.get(i))){judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}}//判断题目限制是否符合要求String judgeConfigStr = question.getJudgeConfig();JudgeConfig judgeConfig = JSONUtil.toBean(judgeConfigStr, JudgeConfig.class);Long needtimeLimit = judgeConfig.getTimeLimit();Long needmemoryLimit = judgeConfig.getMemoryLimit();if(time > needtimeLimit){judgeInfoMessageEnum = JudgeInfoMessageEnum.TIME_LIMIT_EXCEEDED;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}if(memory > needmemoryLimit){judgeInfoMessageEnum = JudgeInfoMessageEnum.MEMORY_LIMIT_EXCEEDED;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}
}
此时的yoj-backend\src\main\java\com\yupi\yoj\judge\JudgeServiceImpl.java修改的部分
// 5)根据沙箱的执行结果,设置题目的判题状态和信息ExecutecodeResponse executecodeResponse = codesandbox.executeCode(executecodeCodeRequest);List<String> outputList = executecodeResponse.getOutputList();JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();JudgeContext judgeContext = new JudgeContext();judgeContext.setJudgeInfo(executecodeResponse.getJudgeInfo());judgeContext.setInputList(inputList);judgeContext.setOutputList(outputList);judgeContext.setJudgeCaseList(judgeCaseList);judgeContext.setQuestion(question);judgeContext.setQuestionSubmit(questionSubmit);JudgeInfo judgeInfo = judgeStrategy.doJudge(judgeContext);//6)修改数据库中的判题结果questionSubmitUpdate = new QuestionSubmit();questionSubmitUpdate.setId(questionSubmit.getId());questionSubmitUpdate.setJudgeInfo(JSONUtil.toJsonStr(judgeInfo));questionSubmitUpdate.setStatus(QuestionSubmitStatusEnum.SUCCEED.getValue());save = questionSubmitService.updateById(questionSubmitUpdate);if(!save){throw new BusinessException(ErrorCode.SYSTEM_ERROR,"题目状态更新错误");}QuestionSubmit questionSubmitResult = questionSubmitService.getById(questionId);return questionSubmitResult;
4)再新增一种判题策略,通过if…else…的方式选择哪种策略
坐标:yoj-backend\src\main\java\com\yupi\yoj\judge\strategy\JavaLanguageJudgeStrategy.java
public class JavaLanguageJudgeStrategy implements JudgeStrategy {@Overridepublic JudgeInfo doJudge(JudgeContext judgeContext) {// 1、先判断沙箱执行的结果输出数量是否和预期输出数量相等
// 2、依次判断每一项输出和预期输出结果是否相等
// 3、判题题目的限制是否符合要求
// 4、可能还有其他的异常情况JudgeInfo judgeInfo = judgeContext.getJudgeInfo();Long time = judgeInfo.getTime();Long memory = judgeInfo.getMemory();List<String> inputList = judgeContext.getInputList();List<String> outputList = judgeContext.getOutputList();List<JudgeCase> judgeCaseList = judgeContext.getJudgeCaseList();Question question = judgeContext.getQuestion();QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();JudgeInfoMessageEnum judgeInfoMessageEnum = JudgeInfoMessageEnum.ACCEPTED;JudgeInfo judgeInfoResponse = new JudgeInfo();judgeInfoResponse.setTime(time);judgeInfoResponse.setMemory(memory);if(inputList.size() != outputList.size()){judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}for(int i = 0; i < outputList.size();i ++){if(!Objects.equals(outputList.get(i), inputList.get(i))){judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}}//判断题目限制是否符合要求String judgeConfigStr = question.getJudgeConfig();JudgeConfig judgeConfig = JSONUtil.toBean(judgeConfigStr, JudgeConfig.class);Long needtimeLimit = judgeConfig.getTimeLimit();Long needmemoryLimit = judgeConfig.getMemoryLimit();//假设JAVA程序本身需要额外执行10slong JAVA_TIME_COST = 1000L;if(time - JAVA_TIME_COST> needtimeLimit){judgeInfoMessageEnum = JudgeInfoMessageEnum.TIME_LIMIT_EXCEEDED;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}if(memory - JAVA_TIME_COST > needmemoryLimit){judgeInfoMessageEnum = JudgeInfoMessageEnum.MEMORY_LIMIT_EXCEEDED;judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}judgeInfoResponse.setMessage(judgeInfoMessageEnum.getValue());return judgeInfoResponse;}
}
JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
if (language.equals("java")) {judgeStrategy = new JavaLanguageJudgeStrategy();
}
JudgeInfo judgeInfo = judgeStrategy.doJudge(judgeContext);
但是,如果选择某种判题策略过程比较复杂,如果都写在调用判题服务的代码中,代码会越来越复杂,会有大量的if…else…,所以建议单独编写一个判断策略的类
5)定义JudgeManager,目的是尽量简化对判题功能的调用,让调用方写最少的代码,调用最简单。对于判题策略的选取,也是在JudgeManager里处理的
坐标:com.yupi.yoj.judge.JudgeManager
/*** 判题管理(简化调用)*/
@Service
public class JudgeManager {/*** 执行判题** @param judgeContext* @return*/JudgeInfo doJudge(JudgeContext judgeContext) {QuestionSubmit questionSubmit = judgeContext.getQuestionSubmit();String language = questionSubmit.getLanguage();JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();if ("java".equals(language)) {judgeStrategy = new JavaLanguageJudgeStrategy();}return judgeStrategy.doJudge(judgeContext);}}
6)在JudgeServiceImpl中,将judgeStrategy.doJudege() 改为 judgeManager.doJudge()
7)坐标:com.yupi.yoj.service.impl.QuestionSubmitServiceImpl
在题目提交实现类中,在返回结果前,执行判题服务
小知识-Lombox Builder 注解
以前我们是使用new 对象后,再逐行执行set方法的方式来给对象进行赋值的。
还有另外一种可能更方便的方式builder。
1)实体类加上@Builder等注解
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {private List<String> inputList;private String code;private String language;
}
2)可以使用链式的方式更方便地给对象赋值
ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder().code(code).language(language).inputList(inputList).build();
小知识-Mybatis-plus updateById方法
根据id进行更新,但是参数却是为实体类,根据传入参数的类的id进行更新,不会全部更新,只会更新设置了值的属性
项目地址
(求求大佬们赏个star~)
前端:https://github.com/IMZHEYA/yoj-frontend
后端:https://github.com/IMZHEYA/yoj-backend
代码沙箱:https://github.com/IMZHEYA/yoj-code-sandbox