跳至主要內容

OJ 判题服务开发

holic-x...大约 17 分钟项目oj-platform

05-判题服务开发

判题模块预开发

​ 如何理解预开发概念:指的是先梳理开发框架和整体开发思路、架构设计,先把业务流程的架子先搭好,调通整个业务流程和基本逻辑,然后再实现优化

1.梳理判题模块和代码沙箱的关系

​ 构建思路:最简单的实现方式就是在同一个项目中通过代码调用的方式实现,为了让代码沙箱更具备通用性,将代码沙箱服务抽离出来,判题模块与其交互则通过http调用接口的方式进行验证,让这两个模块完全解耦

​ 判题模块:调用代码沙箱,把代码和输入交给代码沙箱去执行

​ 代码沙箱:只负责接受代码和输入,返回编译运行的结果,不负责判题(可以作为独立的项目/服务,提供给其他的需要执行代码的项目去使用)

image-20240502202402847

思考:为什么代码沙箱要接受和输出一组运行用例

​ 前提:每道题目有多组测试用例

​ 如果是每个用例单独调用一次代码沙箱,会调用多次接口、需要多次网络传输、程序要多次编译、记录程序的执行状态(重复的代码不重复编译),这是一种常见的性能优化方法(类似于批处理

​ 常见性能优化手段:设计程序的时候考虑优化:当要调用远程外部接口的时候,能不能考虑只调用一次,而不反复多次调用接口

2.代码沙箱开发思路

基础构建参考

【1】🚀定义代码沙箱的接口,提高通用性(常用的设计思路,参考聚合搜索模式),目的是先构建基础架构,然后再定义具体的实现

  • 之后项目代码只调用接口,不调用具体的实现类,这样在使用其他的代码沙箱实现类时,就不用去修改名称了,便于扩展。
  • 代码沙箱的请求接口中,timeLimit 可加可不加,可以扩展,及时中断程序(根据实际情况调整),目前项目构建思路是调用代码沙箱服务去获取结果,然后再在判题服务中进行判题(判断时间响应是否超出预期)
  • todo:扩展思路:增加一个查看代码沙箱状态的接口

涉及实现说明:CodeSandbox(沙箱接口)、ExecuteCodeRequest、ExecuteCodeResponse、JudgeInfo

原有的通过get、set实现,现通过Builder的方式进行构建(参考Lombok应用)

// 代码沙箱接口定义
public interface CodeSandbox {

    /**
     * 执行代码
     * @param executeCodeRequest
     * @return
     */
    ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest);


    /**
     * 获取代码沙箱状态 todo 获取代码沙箱的状态信息
     * @return
     */
    // int getCodeSandbox();

}

【2】定义多种不同的代码沙箱实现(构建思路参考聚合搜索的适配器模式,一种便于扩展的实现方式)

  • 示例代码沙箱:仅为了跑通业务流程(本地校验)
  • 远程代码沙箱:实际调用接口的沙箱
  • 第三方代码沙箱:调用网上现成的代码沙箱 https://github.com/criyle/go-judge
// 示例代码沙箱实现
public class ExampleCodeSandbox implements CodeSandbox {
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        System.out.println("示例代码沙箱");
        return null;
    }
}

// 远程代码沙箱实现
public class RemoteCodeSandbox implements CodeSandbox {
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        System.out.println("远程代码沙箱");
        return null;
    }
}

// 第三方代码实现
public class ThirdPartyCodeSandbox implements CodeSandbox {
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        System.out.println("第三方代码沙箱");
        return null;
    }
}

【3】编写单元测试,验证单个代码沙箱的执行

@SpringBootTest
public class CodeSandboxTest {

    @Test
    void executeCode() {
        CodeSandbox codeSandbox = new RemoteCodeSandbox();
        String code = "int main() { }";
        String language = QuestionSubmitLanguageEnum.JAVA.getValue();
        List<String> inputList = Arrays.asList("1 2", "3 4");
        ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
                .code(code)
                .language(language)
                .inputList(inputList)
                .build();
        ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
        Assertions.assertNotNull(executeCodeResponse);
    }

}

image-20240503102923267

设计模式改造

改造1:通过不同配置参数选择调用不同的沙箱

​ 现存问题:目前通过new 方式把调用某个代码的方式写死了,如果后面要改用其他沙箱,则可能会涉及到很多代码改变

​ 基于上述内容:可以实现一个代码沙箱的调用,但是这种调用方式实现是直接new一个对象实现类实现不同代码沙箱的调用。如果进一步思考可以考虑通过设定不同的参数配置然后生成不同的代码沙箱实现,最常见的改造就是通过if...else...对参数配置进行判断然后根据参数生成对应实现类

String sandboxType = "remote";
CodeSandbox codeSandbox ;
if("remote".equals(sandboxType)){
   codeSandbox = new RemoteCodeSandbox();
}else{
    ....... 其他不同沙箱接入 ........
}

​ 基于设计模式改造:引入工厂模式进行构建(区分抽象工厂的应用场景,不要过度使用设计模式)

​ 此处使用静态工厂模式构建即可(todo:如何确定代码沙箱示例不会出现线程安全问题、可复用,则可使用单例工厂模式优化)

/**
 * 代码沙箱工厂(根据字符串参数创建指定的代码沙箱实例)
 */
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 "thirdParty":
                return new ThirdPartyCodeSandbox();
            default:
                // 如果用户设定type不在预期,则返回一个默认值或者报错提示
                return new ExampleCodeSandbox();
        }
    }
}
// 工厂模式方式执行代码
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNextLine()) {
            String type = scanner.nextLine();
            // 根据type动态生成相应的实现
            CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(type);
            String code = "int main() { }";
            String language = QuestionSubmitLanguageEnum.JAVA.getValue();
            List<String> inputList = Arrays.asList("1 2", "3 4");
            ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
                    .code(code)
                    .language(language)
                    .inputList(inputList)
                    .build();
            // 调用沙箱接口
            ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
            System.out.println(executeCodeResponse);
        }
    }

​ 此处测试的时候需注意单元测试可能不允许通过Scanner进行操作,因此可以单独把方法拉出来在main方法中执行

image-20240503105603924

改造2:参数配置化

​ 扩展思路分析:参考网上开源业务系统的配置文件,不是让用户在代码中去更改代码,而是通过配置引入的方式让用户更加灵活地接入系统。回归自身项目实现,把一些可以动态配置的属性放在项目配置中

# openAI 配置
# https://platform.openai.com/docs/api-reference
openai:
  model: ${OPENAI_MODEL:text-davinci-003}
  apiKey: ${OPENAI_API_KEY:你的apiKey}

​ 把项目中的一些可以交给用户自定义的选型、字符串写到配置中,这样开发者只需要关注配置文件的修改,而不需要关心具体的实现就能使用项目更多的功能。例如此处把这个type接入到配置文件application.yml中,然后在Spring的Bean中通过@Value注解读取

# 代码沙箱配置
codesandbox:
  type: example

​ 然后再在项目中进行引用,如此一来可通过配置来灵活选择调用不同的代码沙箱渠道

@SpringBootTest
public class CodeSandboxTest {

    // 沙箱参数:沙箱类型(如果没有指定则默认为example)
    @Value("${codesandbox.type:example}")
    private String sandboxType;
    
    // 参数配置化
    @Test
    void test2(){
        System.out.println("当前沙箱配置参数:" + sandboxType);
        // 根据type动态生成相应的实现
        CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(sandboxType);
        String code = "int main() { }";
        String language = QuestionSubmitLanguageEnum.JAVA.getValue();
        List<String> inputList = Arrays.asList("1 2", "3 4");
        ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
                .code(code)
                .language(language)
                .inputList(inputList)
                .build();
        // 调用沙箱接口
        ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
        System.out.println(executeCodeResponse);
    }
}
改造3:代理模式引入,处理响应日志

比如:需要再调用代码沙箱前,输出请求参数日志;在代码沙箱调用后,输出响应结果日志,便于管理员去分析。

每个代码沙箱类都写一遍 log.info? 难道每次调用代码沙箱前后都执行 log?

使用代理模式,提供一个 Proxy,来增强代码沙箱的能力(代理模式的作用就是增强能力)

使用代理后:不仅不用改变原本的代码沙箱实现类,而且对调用者来说,调用方式几乎没有改变,也不需要在每个调用沙箱的地方去写统计代码。

image-20240503123349085

​ 新增一个代理类CodeSandboxProxy,通过这个代理类实现对方法的增强。**使用代理后:不仅不用改变原本的代码沙箱实现类,而且对调用者来说,调用方式几乎没有改变,也不需要在每个调用沙箱的地方去写统计代码。**其实现原理分析如下:

1)创建代理类CodeSandboxProxy实现被代理的接口CodeSandbox

2)通过构造函数接收被代理的接口实现类

3)调用被代理的接口实现类,在调用方法的前后增加对应的操作

// 1.创建代理类
@Slf4j
public class CodeSandboxProxy implements CodeSandbox {

    private final CodeSandbox codeSandbox;


    public CodeSandboxProxy(CodeSandbox codeSandbox) {
        this.codeSandbox = codeSandbox;
    }

    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {
        log.info("代码沙箱请求信息:" + executeCodeRequest.toString());
        ExecuteCodeResponse executeCodeResponse = codeSandbox.executeCode(executeCodeRequest);
        log.info("代码沙箱响应信息:" + executeCodeResponse.toString());
        return executeCodeResponse;
    }
}
// 2.使用代理类
// 根据type动态生成相应的实现
CodeSandbox codeSandbox = CodeSandboxFactory.newInstance(sandboxType);
// 代理增强
codeSandbox = new CodeSandboxProxy(codeSandbox);

image-20240503124225291

扩展知识点:Lombok Builder注解的使用

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecuteCodeRequest {

    private List<String> inputList;

    private String code;

    private String language;

}

可以使用链式的方式更方便的给对象赋值:

    	ExecuteCodeRequest executeCodeRequest = ExecuteCodeRequest.builder()
                .code(code)
                .language(language)
                .inputList(inputList)
                .build();

沙箱实现

示例沙箱

​ 修改ExampleCodeSandbox,模拟一个示例沙箱功能,其中QuestionSubmitStatusEnum(问题提交状态枚举)、JudgeInfoMessageEnum(判题信息枚举)分别为对应枚举类型定义

@Slf4j
public class ExampleCodeSandbox implements CodeSandbox {
    @Override
    public ExecuteCodeResponse executeCode(ExecuteCodeRequest executeCodeRequest) {

        System.out.println("示例代码沙箱");
        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(100L);
        executeCodeResponse.setJudgeInfo(judgeInfo);
        return executeCodeResponse;
//        System.out.println("示例代码沙箱");
//        return null;

    }
}

3.判题服务开发

​ 定义单独的JudgeService(区分原有QuestionSubmitService:问题提交Service),将判题相关的代码写到JudgeService中,便于后续模块的抽离、微服务改造

​ 首先要梳理业务流程:用户提交题目,后台响应接收=》调用沙箱=》根据沙箱响应结果进行判题=》保存问题提交结果

​ 传统的实现方式:所有的业务逻辑都在QuestionSubmitService中实现

​ 现优化结构:将判题服务拆分出来,调用沙箱和响应结果判断,然后最终再保存提交结果,基于这种方式构建能够更好地划分功能职责,便于后续代码优化和改造

​ 基于上述思路构建,将判题服务进行抽离,梳理业务流程=》JudgeService的doJudge方法

1)传入题目的提交id,获取到对应的题目、提交信息(包含代码、编程语言等)

2)校验:如果题目提交状态不为等待中,就不用重复执行了

3)防止重复操作:更改判题(题目提交)的状态为“判题中”,防止重复执行,也能让用户及时看到状态

4)调用沙箱:调用沙箱,获取到执行结果

5)判题:根据沙箱的执行结果,设置题目的判题状态和信息(🚀设计模式改造:引入策略模式

  • 判题核心逻辑:

    • 先判断沙箱执行的结果输出数量是否和预期输出数量相等
    • 依次判断每一项输出和预期输出是否相等
    • 判断题目的限制是否符合要求
    • 可能还有其他的异常情况
  • 判题优化:(根据不同的语言类型可能涉及到不同的判题逻辑,引入设计模式进行优化)

​ 判题策略可能会有很多种,比如:如果代码沙箱本身执行程序需要消耗时间,这个时间可能不同的编程语言是不同的,假设沙箱执行 Java 要额外花10秒,采用策略模式,针对不同的情况,定义独立的策略,而不是把所有的逻辑、if...else...代码全部混在一起写

​ 如果选择某种判题策略的过程比较复杂,都写在调用判题服务的代码中,代码会越来越复杂,会有很多 if....else....,因此引入策略模式,针对不同的判题策略进行校验

if(language.equals("JAVA")){
	..... 执行判题逻辑 .......
}else if(language.equals("PYTHON")){
	..... 执行判题逻辑 .......
}

​ 先确认基本的判题逻辑,然后再进行设计模式优化

		// 5)根据沙箱的执行结果,设置题目的判题状态和信息
        List<String> outputList = executeCodeResponse.getOutputList();
        JudgeInfoMessageEnum judgeInfoMessageEnum = JudgeInfoMessageEnum.ACCEPTED;
        if (outputList.size() != inputList.size()) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;
        }
        // 循环校验判题列表的output和调用沙箱获取到的输出是否一致
        for (int i = 0; i < judgeCaseList.size(); i++) {
            JudgeCase judgeCase = judgeCaseList.get(i);
            if (!judgeCase.getOutput().equals(outputList.get(i))) {
                judgeInfoMessageEnum = JudgeInfoMessageEnum.WRONG_ANSWER;
                return null;
            }
        }

        // 判断题目限制(todo 后期完善多补充一些判题逻辑信息)
        String message = executeCodeResponse.getMessage();
//        Integer status = executeCodeResponse.getStatus();
        JudgeInfo judgeInfo = executeCodeResponse.getJudgeInfo();
//        String message = judgeInfo.getMessage();
        Long memory = judgeInfo.getMemory();
        Long time = judgeInfo.getTime();
        // 获取判题配置
        String judgeConfigStr = question.getJudgeConfig();
        JudgeConfig judgeConfig = JSONUtil.toBean(judgeConfigStr, JudgeConfig.class);
        Long needMemoryLimit = judgeConfig.getMemoryLimit();
        Long needTimeLimit = judgeConfig.getTimeLimit();
        if (memory > needMemoryLimit) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.MEMORY_LIMIT_EXCEEDED;
        }
        if (time > needTimeLimit) {
            judgeInfoMessageEnum = JudgeInfoMessageEnum.TIME_LIMIT_EXCEEDED;
        }

引入策略模式优化判题逻辑

1)定义一个判题策略接口(这个策略中可以定义多个执行方法,例如此处是执行判题逻辑)

​ 其中JudgeContext是策略中要使用的

/**
 * 判题策略
 */
public interface JudgeStrategy {

    /**
     * 执行判题
     * @param judgeContext
     * @return
     */
    JudgeInfo doJudge(JudgeContext judgeContext);
}

2)编写默认判题模块(DefaultJudgeStrategy)实现JudgeStrategy接口

​ 改造上面的步骤5)中的判题逻辑,将这段逻辑放在DefaultJudgeStrategy中实现,将这段代码中所要引用到的变量通过定义一个JudgeContext(上下文(用于定义在策略中传递的参数))进行统一接收处理。(当到调用判题策略的时候只需要将已有参数封装为JudgeContext传递过来即可,不需要一个个参数逐个定义)

image-20240503150047654

​ 基于上述构建,调用默认策略完成判题逻辑

3)基于不同的模式编写对应的策略(引入不同策略,例如Java逻辑)

​ 假设Java需要额外执行10s(或者其他逻辑操作)

public class JavaLanguageJudgeStrategy implements JudgeStrategy {

    /**
     * 执行判题
     * @param judgeContext
     * @return
     */
    @Override
    public JudgeInfo doJudge(JudgeContext judgeContext) {
        ------ JAVA判题逻辑设定 ------
    }
}

如果需要根据不同的language使用不同的策略,最简单的方式可通过if...else...方式进行选择

// 选定策略传入上下文参数执行判题逻辑
JudgeStrategy judgeStrategy = new DefaultJudgeStrategy();
// 根据language选择不同的language
if("java".equals(language)){
	judgeStrategy = new JavaLanguageJudgeStrategy();
}

​ 如果说某种判题策略的选择过程比较复杂,如果在调用判题服务的代码中写这些判题逻辑的选择,则代码会越来越复杂(if...else...会存在大量),因此策略模式优化也要搭配把策略模式的选择抽成一个JudgeManager进行使用,便于程序扩展

4)将策略模式的选择也单独定义出来:编写一个判断策略的类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);
    }

}

​ 在JudgeService中只需要一句话就可以完成策略模式选择(其中language也要通过JudgeContext传入,这样JudgeManager才能根据language进行判断)

// 方式2:通过JudgeManager进行选择
JudgeInfo judgeInfo = judgeManager.doJudge(judgeContext);

4.判题服务引入调用

​ 用户提交题目的逻辑:触发提交题目操作,然后调用判题服务(基于前面定义的JudgeService进行服务调用)

​ 如果启动出错提示依赖循环(judgeServiceImpl、questionSubmitServiceImpl),则借助@Lazy解决循环依赖

image-20240503154135490

​ 提交题目接口实现参考:QuestionController的doQuestionSubmit方法,其实现逻辑是用户点击提交=》后台添加提交题目信息=》异步调用判题服务(判题服务中校验参数信息、调用沙箱获取响应结果,然后根据响应结果封装判题结果)

​ 判题服务调用完成,则测试接口是否正常调通,确认数据库状态:

​ 如果数据库用户提交信息的status为1则说明调用沙箱这块可能出现了问题(还在等待调用沙箱返回结果中,需要排查沙箱配置或者远程连接是否调通,排查代码日志、代码沙箱配置等),异步调用沙箱出错日志没有提示,需要断点进行跟踪

​ 如果status为2则模拟调用成功,模拟调用沙箱返回结果并封装响应数据

🚀常见问题

数据正常提交,但是提交状态变更失败

🤡🤡bug排查:使用example调用沙箱有响应数据,但是在JudgeService接收拿到的确是null

​ 首先先梳理提交逻辑:用户提交=》生成提交记录并保存=》异步调用判题服务=》返回提交记录ID

​ 此处的执行逻辑是后台生成提交提交记录,异步调用判题服务(可能需要一定时间),此时系统会继续向下执行返回提交记录ID并告诉前台记录已经提交成功了。如果单纯从【提交成功】这个业务提示可能会陷入误区,认为所有都已正常执行,但是其实异步调用判题服务这块的逻辑还没真正反馈,需要额外关注其具体实现的效果

​ 由于异步调用没有日志打印在控制台,因此需要一步步设置断点进行排查,确认每个环节,尤其是要处理空指针异常

​ 一开始问题是出现在沙箱调用成功,但是数据状态并没有改变为已成功,因此需要排查沙箱调用后的处理逻辑,发现在处理沙箱响应结果的时候拿到的executeCodeResponse为null,进而导致无法进入下一步的结果比较,从而直接断掉后面的逻辑链路

​ 首先排查对应的ExampleCodeSandbox沙箱是否有正常的返回值,随后确认JudgeServiceImpl接受的是什么内容。

​ 排查过程中发现ExampleCodeSandbox的executeCode方法执行有executeCodeResponse内容,但是JudgeServiceImpl接收却为null,导致后续空指针异常,由于是异步服务调用,控制台没有打印相应的数据,后面重新梳理业务逻辑,发现CodeSandbox经由CodeSandboxProxy代理转了一道(相当于CodeSandboxProxy拦截了方法并进行处理),后面进一步排查CodeSandboxProxy的处理,发现其虽然拦截做了日志打印,但是return null(导致经由这个代理的执行返回的都是null,进而导致这个错误问题)

image-20240503162506471

用户重复提交问题如何避免(一个用户要多次做题如何控制)

​ 此外,针对已经提交过的题目,还需考虑重复提交的问题(如果用户多次点击提交则可能造成数据库数据特别赘余)以及提交记录状态在这个环节中的转化

  • 生成问题提交记录:初始化状态【0-等待中】

  • 异步调用判题服务:

    • 校验状态如果不为【0-等待中】则不需要进行判题
    • 如果需要进行判题,则先锁定题目提交状态为【1-判题中】,然后在执行完判题操作成功之后在修改判题状态为【2-成功】;【3-失败】

    进而确保一次只能提交一条记录,避免重复提交、重复校验(todo:后续考虑如果是一个用户要多次做题的话,这块细节需要进一步进行优化

​ 因为此处是通过创建的questionSubmitId进行限定,所以确保对于同一条记录不能重复提交执行操作,但这里要注意一个问题,questionSubmitId如果每次传入的都是新建的ID,则此处的设定是失效的,就会不断地创建新的提交记录,只有引入修改操作场景下这个设定才可能满足(例如新增成功之后要返回给前端一个新增的ID,下次触发提交操作则要根据是否传入ID来校验是新增逻辑还是修改逻辑

image-20240503165248657

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3