SpringBoot在线编程平台判题沙箱设计与部署实践

发布时间:2026/9/15 15:30:06
SpringBoot在线编程平台判题沙箱设计与部署实践 简介面向计算机相关专业学生与开发者的SpringBoot在线编程实践平台项目完整提供前后端源码与配套文档资料适用于毕业设计、课程设计、项目初期演示也适合个人全栈学习进阶。压缩包共767个文件核心包含303个Java后端代码、122个Vue前端页面另有130个JavaScript脚本、37个XML配置、24个Less样式表等整体仅2.17MB目录按后端、前端、配置等模块划分结构清楚便于模块化查阅与二次开发。该项目答辩评分达95分代码已经测试运行成功功能完整既能帮助初学者理解SpringBootVue前后端交互流程也可支撑开发者在此基础上扩展业务功能同时附带答辩文档、deploy.bat部署脚本和环境变量配置等辅助资料。目前已有56人学习使用对需要完整项目参考和实战演练的同学而言这是一份性价比很高的学习资料。1. 在线编程平台拿到手先别急着跑判题沙箱才是命门拿到这份基于SpringBoot的在线编程实践平台源码时我原本以为核心是编辑器、题单管理、排行版这些页面。真正把工程跑起来才发现后端判断用户代码是否正确的过程才是最值得抠的部分。很多课设项目习惯用Runtime.exec直接执行用户提交的代码等于把整个服务器暴露给任意入侵者一份恶意代码就能让进程死循环、写磁盘甚至删库。这个项目采用JavaCompiler 受限线程 逐用例校验的方案同时附带完整前端 Vue 工程和 95 分答辩文档适合做毕设二次开发也适合想搞懂 OJ 评测原理的开发者。下面按我拆解这个项目的顺序从表结构到判题细节逐层说清。2. SpringBoot在线编程平台的题库表与提交链路设计2.1 题目与提交记录的核心表结构导入资源内的schema.sql后能看到两张最关键的表problem保存题目信息和判题限制submission记录每次用户提交代码的状态。这里的时限和内存限制不只是展示给用户看的后面判题服务会读取这些字段作为硬指标。CREATE TABLE problem ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(100) NOT NULL COMMENT 题目标题, description text COMMENT 题目描述, input_desc text COMMENT 输入说明, output_desc text COMMENT 输出说明, sample_input text COMMENT 样例输入, sample_output text COMMENT 样例输出, time_limit int(11) DEFAULT 1000 COMMENT 时间限制(毫秒), memory_limit int(11) DEFAULT 256 COMMENT 内存限制(MB), difficulty tinyint(4) DEFAULT 1 COMMENT 难度 1简单 2中等 3困难, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE submission ( id bigint(20) NOT NULL AUTO_INCREMENT, user_id bigint(20) NOT NULL, problem_id bigint(20) NOT NULL, code text NOT NULL, language varchar(20) DEFAULT java, status tinyint(4) DEFAULT 0 COMMENT 0排队 1编译中 2运行中 3通过 4编译错误 5运行错误 6超时, execute_time int(11) DEFAULT NULL, error_msg text, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;time_limit和memory_limit是面向题面的限制真正的执行限制会在判题服务里加安全余量例如题目时限 1000ms实际执行时最多给 1200ms防止用户代码恰好卡在边界上。submission表不存中间过程的输入输出只存最终状态和错误信息避免判题时的大量 IO 把数据库表撑大。状态字段用tinyint而不是字符串既节省空间也方便按数字范围做统计。status含义说明0排队中提交成功但未开始编译1编译中调用 JavaCompiler 阶段2运行中用户代码执行阶段3通过所有用例输出均匹配4编译错误语法错误或 JDK 版本不兼容5运行错误抛异常、数组越界、除零等6超时超过 time_limit 或资源受限实际项目里还会加一个memory_used字段但这个高分组没有做内存精细度量只在error_msg里记录OutOfMemoryError。2.2 提交请求从 Controller 到 JudgeService 的调用链打开SubmissionController提交接口的设计很直白先保存 submission 记录然后同步触发判题。代码里没有消息队列这是课设项目常见简化做法好处是便于在断点里追踪完整流程。PostMapping(/api/submission) public Result createSubmission(RequestBody SubmissionDTO dto, RequestAttribute Long userId) { Submission submission new Submission(); submission.setUserId(userId); submission.setProblemId(dto.getProblemId()); submission.setCode(dto.getCode()); submission.setLanguage(java); submission.setStatus(0); submissionService.save(submission); judgeService.judge(submission.getId()); return Result.ok(submission); }RequestAttribute Long userId来自拦截器解析 JWT 后放入 request 的值这里不需要前端再传用户 ID避免越权。judgeService.judge(submission.getId())是个同步方法内部先查题目的time_limit再调用第 3 章的编译执行模块。如果判题时间较长例如死循环代码用户请求会一直挂着所以后续我改成了异步方式但作为演示项目这种设计反而更容易在调试时看清问题定位到编译、运行、比对三个阶段的日志。为什么这样设计在线编程实践平台的提交频率远低于生产环境 OJ同步方式能极大简化代码路径省掉消息队列的部署和幂等处理。对于毕设答辩解释清楚“同步会阻塞请求”这一点并给出异步优化方案还能成为加分项。3. 在线判题核心编译、类加载与受限执行3.1 为什么不用 Runtime.exec 裸跑用户代码最容易想到的判题方式是用Runtime.exec(javac Solution.java)编译再java Solution执行。但裸跑有三个致命问题一是没有超时机制用户代码里写个while(true){}就能让判题服务永久卡死二是没有内存保护new int[Integer.MAX_VALUE]直接触发整个 JVM 的OutOfMemoryError三是无法拦截危险操作用户代码里System.exit(0)会把判题进程直接干掉如果写Files.deleteIfExists甚至可以删服务器文件。所以这个项目没有走那条路而是用JavaCompiler在内存中编译再通过自定义线程池和 Future 做超时限制。3.2 用 JavaCompiler 编译并加载用户类判题的第一步是把用户提交的源码写到临时目录然后用 JDK 内置的编译器编译成.class文件。这里要求服务器必须安装完整 JDK不能只装 JRE因为ToolProvider.getSystemJavaCompiler()在纯 JRE 环境会返回 null。public Class? compileAndLoad(String sourceCode) throws Exception { // 固定类名保证与提交页面约定一致 Path src Path.of(/tmp/oj-judge/Solution.java); Files.writeString(src, sourceCode); JavaCompiler compiler ToolProvider.getSystemJavaCompiler(); int result compiler.run(null, null, null, src.toString()); if (result ! 0) { throw new CompileErrorException(编译失败请检查语法); } // 每次用新的 URLClassLoader避免类被 JVM 缓存 URLClassLoader loader new URLClassLoader( new URL[]{ Path.of(/tmp/oj-judge).toUri().toURL() }, ClassLoader.getSystemClassLoader() ); return loader.loadClass(Solution); }compiler.run的返回值是 0 表示编译成功非 0 表示语法错误或 JDK 版本问题。这里把源码文件名固定为Solution.java是因为 Java 要求 public 类名必须与文件名一致。URLClassLoader每次判题后必须close()否则同一个类名多次加载会触发ClassNotFoundException同时 Metaspace 会被撑满。更合理的做法是每个用户提交都使用独立的临时目录判题结束后递归删除整个目录避免类文件残留。3.3 用 Future 限制执行时间并捕获运行结果限制用户代码执行时间常见做法是丢给一个线程池再用Future.get(timeout)等待结果。项目里核心逻辑如下ExecutorService pool Executors.newFixedThreadPool(4); FutureInteger future pool.submit(() - { Class? clazz loader.loadClass(Solution); Method main clazz.getMethod(main, String[].class); main.invoke(null, (Object) new String[0]); return exitCode.get(); }); try { future.get(timeLimitMs, TimeUnit.MILLISECONDS); judgeResult.setStatus(3); // 通过 } catch (TimeoutException e) { judgeResult.setStatus(6); // 超时 future.cancel(true); } catch (InvocationTargetException e) { judgeResult.setStatus(5); // 运行错误记录原始异常 judgeResult.setErrorMsg(e.getCause().toString()); }future.cancel(true)严格来说不能保证中断用户线程因为如果用户代码里没有响应中断的循环线程会继续运行。这个项目只在那台机器上做演示够用上线前必须升级为进程级隔离。更稳妥的方案是启动一个独立 Java 进程判题超时后直接Process.destroyForcibly()将其杀死配合ulimit限制进程可消耗的内存这样才能做到真正的隔离。隔离方案超时控制内存限制防 System.exit实现复杂度推荐场景Runtime.exec 裸跑无无无低本地演示JavaCompiler Future有但不彻底无部分中课设、小并发独立进程 定时销毁可靠可设置可靠中高真实 OJDocker 容器可靠可靠可靠高生产环境表里可以看到Runtime.exec一行基本都是“无”原因在于子进程脱离主进程的控制范围你很难精确掌握它到底占了多少资源。Docker 方案最完整但启动一个容器耗时约几百毫秒如果每次判题都创建容器并发能力会下降。这个项目最终选择了中间档代码量小处理 50 以内的并发判题完全足够。4. Vue 前端联调与 SpringBoot 部署参数设置4.1 环境变量文件与接口路径约定项目根目录的.env.development是 Vite 环境变量文件内容是VITE_API_BASE_URLhttp://localhost:8080/api。前端所有请求都通过 axios 实例发出统一读取这个变量避免把接口地址写死在页面组件里。import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) export default request注意 Vite 环境变量名必须以VITE_开头否则不会注入到import.meta.env这是新手最容易踩的坑。后端接口统一带/api前缀所以baseURL写到了/apiController 里的/api/submission实际上请求路径就是http://localhost:8080/api/api/submission如果发现 404先检查是否重复拼接。我一般建议后端把context-path设置为/api而前端 baseURL 只写域名和端口。4.2 跨域配置与 JWT 拦截器前后端分离开发时浏览器会拦截跨域请求。SpringBoot 侧需要显式放开允许的源项目里的WebConfig可以抄作业Configuration public class WebConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register); } }allowedOrigins不能和allowCredentials(true)一起使用*会被浏览器拒绝。LoginInterceptor负责从请求头解析Authorization如果 token 过期或伪造直接返回 401。在线编程平台至少要保证用户只能看到自己的提交记录否则任意人传一个userId就能查到别人的代码这会成为答辩时的硬伤。4.3 SpringBoot 与 JVM 的关键参数application.yml是部署时必须调整的地方重点不是端口而是连接池和临时目录server: port: 8080 servlet: context-path: /api spring: datasource: url: jdbc:mysql://localhost:3306/oj?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 servlet: multipart: max-file-size: 10MB judge: temp-dir: /tmp/oj-judge max-threads: 4 default-time-limit: 1000maximum-pool-size配置为 10 在判题场景已经足够因为判题是 CPU 密集型任务数据库连接只用于保存结果而不是每个测试用例都查库。judge.temp-dir如果指向/tmp系统重启后目录会自动重建但里面的 class 残留会干扰下一次编译所以判题前要用FileUtils.cleanDirectory(tempDir)清理。max-threads直接控制并发判题数本机 CPU 是 4 核就写 4写多了反而因为上下文切换降低吞吐。参数值作用maximum-pool-size10数据库连接池上限防止判题并发打爆 MySQLtimeout10000axios 超时避免用户等待过久max-threads4判题线程数CPU 核数一致即可server.servlet.context-path/api统一接口前缀5. 多用例批量评测的并发控制与结果校验技巧5.1 用 CompletableFuture 做逐用例异步评测单个题目通常有 5~10 个测试用例如果串行执行一个用例超时 1 秒整体就要 10 秒。这个项目在批量评测上用了CompletableFuture每个用例独立提交到判题线程池最后汇总结果。这样能把多核 CPU 用起来但要注意线程池必须单独声明不能使用默认的ForkJoinPool.commonPool()否则其他业务线程会被长耗时判题任务阻塞。ListTestCase cases testCaseService.listByProblemId(problemId); ListCompletableFutureJudgeResult futures cases.stream() .map(tc - CompletableFuture.supplyAsync(() - runSingleCase(tc), judgeExecutor)) .toList(); // 等待所有用例完成某个用例超时不会影响其他用例 ListJudgeResult results futures.stream() .map(CompletableFuture::join) .toList();runSingleCase(tc)每次都会重新加载 Solution 类这是为了隔离用户代码里的static变量。如果多个用例复用同一个 ClassLoader第一个用例设置的静态字段会污染第二个用例导致结果依赖执行顺序。5.2 输出对比忽略行尾空格与换行差异判题正确性最容易翻车的就是输出比对。用户程序输出1 2 3末尾多一个空格Win 平台回车和 Linux 换行不同这些都不能简单用String.equals判断。项目里的比对工具做了规范化public boolean compareOutput(String expected, String actual) { String normE expected.replace(\r\n, \n).stripTrailing(); String normA actual.replace(\r\n, \n).stripTrailing(); String[] expectedLines normE.lines().map(String::stripTrailing).toArray(String[]::new); String[] actualLines normA.lines().map(String::stripTrailing).toArray(String[]::new); return Arrays.equals(expectedLines, actualLines); }先把\r\n转成\n再移除整个字符串末尾的空白行最后逐行去掉行尾空格。这里采用的策略是“行尾空格忽略、空行保留”也就是用户多了个换行符但答案没多仍判正确。如果你的题目要求严格匹配空格需要用另一套精确比对逻辑不能在一处写死。5.3 从日志快速定位判题失败的四种模式部署完成后观察日志是排错的主要手段。这个项目的日志会记录每次判题的完整生命周期贴在logs/oj.log里。常见失败模式有四种# 编译错误日志里出现 CompileErrorException 2025-01-10 10:23:01 ERROR judge:45 - userId123 problemId7 CompileError: 编译失败请检查语法 # 超时status6execute_time 等于题目时限 2025-01-10 10:23:45 WARN judge:78 - submissionId899 status6 executeTime1000 # 运行错误InvocationTargetException 包着实际异常 2025-01-10 10:24:12 ERROR judge:90 - Exception in thread main java.lang.ArithmeticException: / by zero # 输出不匹配预期输出和实际输出都打印出来 2025-01-10 10:25:33 INFO judge:120 - case#3 mismatch, expected[1, 2, 3] actual[1,2,3]编译错误要和运行错误区别看待编译错误通常是 JDK 版本或用户语法问题运行错误则要重点查看异常堆栈。输出不匹配时如果日志里看不出差异把expected和actual的字节数组打十六进制空格的 ASCII 是 0x20换行是 0x0a 0x0d一看便知。本文还有配套的精品资源点击获取