简介:这是一套面向计算机专业本科生与课程设计学习者的Web在线评测系统完整实现,基于Java技术栈构建,适用于算法训练、编程教学及OJ平台二次开发等场景。系统采用Spring MVC+Hibernate+MySQL架构,集成Lucene实现题目检索,支持用户答题评测、师生课程管理、作业布置与统计分析,并内置论坛模块,具备良好的可扩展性与工程实践参考价值。资源包共872个文件,含161个Java源码、191个JS脚本、44个JSP页面、31个CSS样式及68个PNG/114个GIF等前端资源,另有XML配置、CLASS编译文件及少量文档(PDF/DOC/MD),整体21.11MB,结构完整、模块清晰。目前已有204人学习下载,提供可直接运行的完整后端逻辑、前后端交互示例(Ajax+JSON)、典型控制器与服务类(如HomeworkController、POJAjax)及IDE项目配置文件(.asta),便于理解MVC分层设计与在线判题核心流程。
1. 为什么一个“可扩展的程序在线评测系统”必须用 Java + MySQL 而不是 Python + SQLite?
你见过学生交代码后等 3 分钟才出结果的 OJ 吗?见过 200 人同时提交,判题队列崩成红色告警的后台吗?见过老师想加个“内存限制动态调整”功能,却要重写整个判题核心、连带改掉前端渲染逻辑的绝望吗?——这不是教学平台的偶然故障,而是架构选型失当的必然结果。基于 Java + MySQL 实现(Web)可扩展的程序在线评测系统,本质是在高并发提交、多语言沙箱隔离、细粒度资源控制、长期运行稳定性这四重压力下,对工程落地能力的一次综合校验。它不追求炫技,但拒绝妥协:Java 的强类型、线程模型、JVM 生态(如 Spring Boot 的自动装配、MyBatis 的 SQL 映射、Quartz 的定时调度)天然支撑服务分层与横向扩容;MySQL 的事务一致性、行级锁、主从复制能力,是题库管理、用户状态、判题日志、排行榜更新等强一致性场景不可替代的底座。这个系统不是给单机练手用的玩具,而是能承载校级 ACM 训练营、企业内部编程考核、甚至省级竞赛初赛的真实负载。适合正在从 Spring Boot 单体项目迈向分布式判题服务的中级 Java 工程师,也适合需要把课程设计升级为可持续维护教学平台的高校教师团队——它不教你怎么写冒泡排序,但会告诉你:当第 1001 次提交撞上数据库连接池耗尽时,该看哪三行日志、调哪两个参数、加哪一层缓存。
2. 从零搭建最小可运行骨架:Spring Boot + MyBatis + Thymeleaf 三件套落地实录
一个真正能跑起来的 OJ,不是先堆判题沙箱,而是先让“用户登录 → 查看题目 → 提交代码 → 看到 AC/RE/TLE”这条主链路在本地秒级闭环。我们跳过所有炫技组件,用最稳、最易调试、文档最全的组合打地基:Spring Boot 2.7.x(兼容 JDK 8,避开 Spring Boot 3 的 Jakarta EE 9 迁移坑)、MyBatis-Plus 3.5.x(简化 CRUD,避免手写 XML)、Thymeleaf 3.1(服务端模板,规避前后端分离带来的跨域和部署复杂度)。这套组合不追求“最新”,但保证你在 Windows 开发机、CentOS 7 服务器、Docker 容器里都能一键拉起,且所有依赖在 Maven Central 可查、可审计。
2.1 初始化工程与关键依赖注入
用 Spring Initializr 创建基础项目(勾选 Spring Web、Lombok、MySQL Driver、Validation),然后手动补全 MyBatis-Plus 和 Thymeleaf:
<!-- pom.xml 片段 --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>注意:MyBatis-Plus 3.5.x 是最后一个支持 JDK 8 的大版本,若你用 JDK 17,请切到 3.5.5+ 并确认
@TableField注解行为无变化。Thymeleaf 默认开启 HTML 模板缓存(spring.thymeleaf.cache=true),开发阶段务必设为false,否则改完 HTML 刷新页面没反应——这是新手第一坑。
2.2 数据库建模:紧扣 OJ 核心实体的 5 张表设计
OJ 不是博客系统,它的数据模型必须围绕“题、人、交、判、榜”五要素展开。我们放弃过度设计,用 5 张表撑起全部业务:
| 表名 | 主要字段 | 说明 |
|---|---|---|
problem | id,title,description,time_limit,memory_limit,input_sample,output_sample,visible | 题目表,visible=0表示未公开,用于教师组卷 |
user | id,username,password,email,role('student'/'teacher'/'admin') | 用户表,密码必须 BCrypt 加密存储,role字段驱动权限路由 |
submission | id,user_id,problem_id,code,language,status,execute_time,memory_used,created_at | 提交记录表,status枚举值:PENDING,JUDGING,AC,WA,TLE,MLE,RE,CE |
judge_log | id,submission_id,log_content,error_output | 判题日志表,存储编译错误详情或运行时 stderr,便于 debug |
ranklist | user_id,solved_count,total_submit,last_submit_time | 排行榜快照表,避免每次请求都GROUP BY统计,用定时任务每 5 分钟刷新 |
建表 SQL 必须显式指定ENGINE=InnoDB和CHARSET=utf8mb4(支持 emoji 题目描述),并为高频查询字段加索引:
-- submission 表关键索引 CREATE INDEX idx_sub_user_prob ON submission(user_id, problem_id); CREATE INDEX idx_sub_status ON submission(status); CREATE INDEX idx_sub_pending ON submission(status, created_at) WHERE status = 'PENDING';逻辑说明:
idx_sub_pending是个条件索引(MySQL 8.0+ 支持),专为判题服务拉取待处理任务而设。它让SELECT * FROM submission WHERE status='PENDING' ORDER BY created_at LIMIT 10的执行计划从全表扫描降为索引范围扫描,QPS 提升 3 倍以上。别小看这个索引——当并发提交量超过 500 TPS 时,它就是数据库不告警的底线。
2.3 用户认证与题目列表页:Thymeleaf 模板与 Controller 的精准对接
先实现最朴素的首页:未登录用户看到题目列表(仅标题和难度),登录后显示“提交”按钮和已通过标记。Controller 层只做两件事:查题、查用户状态。
@Controller public class ProblemController { @Autowired private ProblemService problemService; @Autowired private UserService userService; @GetMapping("/problems") public String listProblems(Model model, HttpServletRequest request) { // 从 session 获取当前用户(实际应走 Spring Security,此处简化) User currentUser = (User) request.getSession().getAttribute("currentUser"); model.addAttribute("currentUser", currentUser); // 查询所有 visible=1 的题目 List<Problem> problems = problemService.list( new QueryWrapper<Problem>().eq("visible", 1) ); model.addAttribute("problems", problems); return "problem/list"; // 对应 templates/problem/list.html } }对应 Thymeleaf 模板list.html中的关键片段:
<!-- templates/problem/list.html --> <div class="problem-card" th:each="p : ${problems}"> <h3 th:text="${p.title}">题目标题</h3> <p th:text="${#strings.abbreviate(p.description, 100)}">题目描述摘要</p> <div class="status"> <!-- 登录用户才显示状态 --> <span th:if="${currentUser != null}" th:classappend="${p.solvedByUser.containsKey(currentUser.id)} ? 'solved' : 'unsolved'"> <span th:if="${p.solvedByUser.containsKey(currentUser.id)}">✓ 已通过</span> <span th:unless="${p.solvedByUser.containsKey(currentUser.id)}">○ 未尝试</span> </span> </div> <a th:if="${currentUser != null}" th:href="@{/submit/{id}(id=${p.id})}" class="btn btn-primary">提交代码</a> </div>参数说明:
th:each是 Thymeleaf 的循环指令,th:if控制元素显隐,th:href自动生成带路径参数的 URL。这里刻意不引入 Spring Security,因为真实 OJ 的权限模型比 RBAC 复杂得多(例如:某题只对特定班级开放、某次比赛限时解锁),过早引入框架反而模糊了业务边界。记住:模板里不写 Java 逻辑,只做展示映射;Controller 不做判题,只做数据搬运——这是保持可测试性的铁律。
3. 判题核心引擎:Java 进程沙箱 + MySQL 状态同步的双保险机制
判题不是“执行一段代码”,而是“在受控环境中执行、捕获输出、比对结果、记录资源消耗、更新状态”的原子操作。它必须满足:隔离性(防止恶意代码删库)、可观测性(知道是 TLE 还是 RE)、可重入性(失败后能重试)、可监控性(CPU/内存使用率实时上报)。我们不用 Docker 或 Kubernetes 做沙箱(初期太重),而用 Java 自带的ProcessBuilder+ Linuxcgroups(通过 shell 脚本调用)构建轻量级进程沙箱,并用 MySQL 的FOR UPDATE锁保证状态更新不丢。
3.1 判题服务的启动与任务拉取:基于轮询的可靠消费模式
判题服务是一个独立的 Spring Boot@Component,它不依赖 Web 层,只专注一件事:从submission表中捞PENDING状态的记录,判完后更新为最终状态。我们采用“简单轮询 + 乐观锁”而非消息队列,因为初期 QPS < 100,消息中间件反而增加运维负担。
@Component public class JudgeScheduler { @Autowired private SubmissionService submissionService; @Autowired private JudgeEngine judgeEngine; // 判题核心逻辑 @Scheduled(fixedDelay = 1000) // 每秒扫一次 public void pollAndJudge() { // 用 SELECT ... FOR UPDATE 锁住一条待判记录 Submission pending = submissionService.getOne( new QueryWrapper<Submission>() .eq("status", "PENDING") .orderByAsc("created_at") .last("LIMIT 1 FOR UPDATE") ); if (pending == null) return; try { // 执行判题(含超时控制) JudgeResult result = judgeEngine.judge(pending); // 原子更新:只更新 status、execute_time、memory_used,且仅当原 status 仍为 PENDING boolean updated = submissionService.update( new UpdateWrapper<Submission>() .eq("id", pending.getId()) .eq("status", "PENDING") // 乐观锁条件 .set("status", result.getStatus()) .set("execute_time", result.getExecuteTime()) .set("memory_used", result.getMemoryUsed()) .set("judge_log_id", result.getLogId()) ); if (!updated) { // 被其他判题进程抢先更新,放弃本次判题 log.warn("Submission {} already judged by another process", pending.getId()); } } catch (Exception e) { log.error("Judge failed for submission {}", pending.getId(), e); // 更新为系统错误状态,便于人工介入 submissionService.updateStatus(pending.getId(), "SE"); } } }逻辑说明:
FOR UPDATE在 MySQL 中对选中的行加写锁,确保同一时间只有一个判题进程能拿到同一条记录。UPDATE ... WHERE id=? AND status='PENDING'是典型的乐观锁写法——如果另一进程已更新该记录,updated返回false,当前进程直接放弃,避免重复判题。这种设计牺牲了极少量吞吐(锁等待),但换来 100% 的状态一致性,比最终一致性的消息队列更适合 OJ 这种强状态场景。
3.2 Java 进程沙箱:用 ProcessBuilder + cgroups 限制资源的实操脚本
判题的核心是执行用户代码。我们以 C++ 为例(Java/Python 同理),用g++编译后,在cgroups限制下运行:
#!/bin/bash # /opt/oj/judge.sh SUBMISSION_ID=$1 CODE_PATH="/opt/oj/code/$SUBMISSION_ID.cpp" BIN_PATH="/opt/oj/bin/$SUBMISSION_ID" INPUT_PATH="/opt/oj/input/$SUBMISSION_ID.in" OUTPUT_PATH="/opt/oj/output/$SUBMISSION_ID.out" TIME_LIMIT=1000 # ms MEMORY_LIMIT=65536 # KB # 创建临时 cgroup CGROUP_NAME="oj_$SUBMISSION_ID" sudo cgcreate -g cpu,memory:$CGROUP_NAME # 设置 CPU 时间配额(防止死循环占满 CPU) sudo cgset -r cpu.cfs_quota_us=100000 $CGROUP_NAME # 100ms sudo cgset -r cpu.cfs_period_us=100000 $CGROUP_NAME # 设置内存上限(单位:bytes) sudo cgset -r memory.max=$((MEMORY_LIMIT * 1024)) $CGROUP_NAME # 编译 g++ -o $BIN_PATH $CODE_PATH 2>/opt/oj/log/$SUBMISSION_ID.compile.err if [ $? -ne 0 ]; then echo "CE" > /opt/oj/result/$SUBMISSION_ID exit 1 fi # 执行并重定向 IO sudo cgexec -g cpu,memory:$CGROUP_NAME \ timeout --signal=KILL $((TIME_LIMIT/1000)).$(printf "%03d" $((TIME_LIMIT%1000))) \ $BIN_PATH < $INPUT_PATH > $OUTPUT_PATH 2>/opt/oj/log/$SUBMISSION_ID.run.err EXIT_CODE=$? case $EXIT_CODE in 0) echo "AC" ;; 124) echo "TLE" ;; # timeout 137) echo "MLE" ;; # OOM killed *) echo "RE" ;; # 其他非零退出码 esac > /opt/oj/result/$SUBMISSION_IDJava 侧调用此脚本:
public JudgeResult executeCpp(Submission sub) { String scriptPath = "/opt/oj/judge.sh"; ProcessBuilder pb = new ProcessBuilder(scriptPath, String.valueOf(sub.getId())); pb.redirectErrorStream(true); // 合并 stderr 到 stdout try { Process p = pb.start(); int exitCode = p.waitFor(5, TimeUnit.SECONDS); // 最多等 5 秒 if (exitCode != 0) { throw new RuntimeException("Judge script failed with exit code " + exitCode); } // 读取判题结果 String result = Files.readString(Paths.get("/opt/oj/result/", sub.getId().toString())); return parseResult(result, sub); } catch (Exception e) { return new JudgeResult("SE", 0, 0); } }参数说明:
timeout --signal=KILL是关键,它确保即使程序忽略SIGTERM,也会被SIGKILL强制终止;cfs_quota_us/cfs_period_us控制 CPU 时间片,比ulimit -t更精准;memory.max是 cgroups v2 的写法(CentOS 7 默认 v1,需用memory.limit_in_bytes)。血泪经验:不要用Runtime.getRuntime().exec()直接拼字符串调用脚本,极易被用户代码中的; rm -rf /注入——必须用ProcessBuilder并显式传参。
3.3 结果比对与状态回写:AC/WA/TLE 的判定逻辑与 MySQL 事务保障
判题脚本只返回粗粒度状态(AC/TLE/RE),真正的“是否正确”由 Java 层比对标准输出与用户输出完成。这一步必须放在数据库事务内,确保“比对成功”与“状态更新”原子性:
@Transactional public JudgeResult compareAndSave(Submission sub, String userOutputPath) { String stdOutput = readFile("/opt/oj/stdout/" + sub.getProblemId() + ".out"); String userOutput = readFile(userOutputPath); // 忽略行末空格、多余空行(OJ 经典比对规则) String cleanStd = normalizeOutput(stdOutput); String cleanUser = normalizeOutput(userOutput); if (cleanStd.equals(cleanUser)) { // AC:更新 submission,同时更新用户 solved_count submissionService.updateStatus(sub.getId(), "AC"); ranklistService.incrementSolvedCount(sub.getUserId(), sub.getProblemId()); return new JudgeResult("AC", getExecuteTime(), getMemoryUsed()); } else { submissionService.updateStatus(sub.getId(), "WA"); return new JudgeResult("WA", getExecuteTime(), getMemoryUsed()); } } private String normalizeOutput(String s) { return s.replaceAll("\\s+$", "").replaceAll("\\n+", "\n").trim(); }避坑点:
normalizeOutput必须处理 Windows 换行符\r\n,否则 Linux 下判题会误判 WA;ranklistService.incrementSolvedCount内部用UPDATE ranklist SET solved_count = solved_count + 1 WHERE user_id = ?,避免先查再更新的并发问题;事务注解@Transactional必须作用于 Service 方法,不能放在 Controller——这是 Spring 事务生效的前提。
4. 可扩展性落地:水平拆分判题节点、读写分离、排行榜缓存的三步演进
“可扩展”不是一句口号,而是当单台服务器扛不住时,你能用 3 小时加一台机器就让吞吐翻倍。我们不预设微服务,而是从单体出发,用最务实的三步法实现平滑扩容:第一步,判题服务无状态化,支持多实例部署;第二步,MySQL 主从分离,读操作走从库;第三步,排行榜用 Redis 缓存,降低数据库压力。每一步都可独立上线,互不影响。
4.1 判题服务无状态化:用 Redis 分布式锁替代单机轮询
原@Scheduled方案在多实例下会重复判题。解决方案:用 Redis 的SET key value NX EX seconds命令实现分布式锁,确保同一时刻只有一个判题实例在工作:
@Component public class DistributedJudgeScheduler { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private SubmissionService submissionService; @Scheduled(fixedDelay = 1000) public void distributedPoll() { String lockKey = "judge:lock"; String lockValue = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 执行原判题逻辑 pollAndJudgeOnce(); } finally { // Lua 脚本安全释放锁(防止误删其他实例的锁) String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), lockValue); } } } private void pollAndJudgeOnce() { // 同前,但去掉 @Transactional(由调用方保证) Submission pending = submissionService.getPendingSubmission(); if (pending != null) { judgeEngine.judge(pending); } } }逻辑说明:
setIfAbsent的NX(Not eXists)保证锁唯一性,EX 30防止死锁。释放锁必须用 Lua 脚本,因为“读锁值→判断→删除”是三个命令,非原子操作——若 A 读到锁值后崩溃,B 就可能删掉 A 的锁。这个方案让判题服务从 1 台扩到 N 台,只需部署相同代码,无需改一行业务逻辑。
4.2 MySQL 读写分离:用 ShardingSphere-JDBC 实现透明路由
当SELECTQPS 超过 2000,主库 CPU 常驻 90%,就必须读写分离。我们选用 ShardingSphere-JDBC(轻量、无代理、纯 Java),它在应用层做 SQL 路由,对业务代码零侵入:
# application.yml spring: shardingsphere: props: sql-show: false datasource: names: ds-master,ds-slave-0 ds-master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://master:3306/oj?useSSL=false&serverTimezone=Asia/Shanghai username: root password: pwd ds-slave-0: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://slave0:3306/oj?useSSL=false&serverTimezone=Asia/Shanghai username: root password: pwd rules: - !READWRITE_SPLITTING type: STATIC dataSources: pr_ds: write-data-source-name: ds-master read-data-source-names: ds-slave-0配置后,所有@Select方法自动走从库,@Update/@Insert走主库。验证方法:在从库执行SHOW PROCESSLIST,能看到大量SELECT连接;主库则只有INSERT/UPDATE。ShardingSphere 的优势在于:它不修改你的 SQL,也不要求你改 DAO 层——你甚至可以先只配一个从库,观察流量分发效果,再逐步加从库。
4.3 排行榜缓存:用 Redis Sorted Set 实现毫秒级排名更新
ranklist表的ORDER BY solved_count DESC LIMIT 100查询在百万用户时会变慢。解决方案:用 Redis 的ZADD维护有序集合,ZRANGE直接取 Top 100:
@Service public class RanklistService { @Autowired private RedisTemplate<String, String> redisTemplate; // 用户 AC 一道题时调用 public void updateRanking(Long userId, Integer score) { String key = "ranklist:global"; redisTemplate.opsForZSet().add(key, String.valueOf(userId), score.doubleValue()); // 设置过期时间,避免内存无限增长 redisTemplate.expire(key, Duration.ofHours(24)); } // 前端调用 public List<RankItem> getTop100() { Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores("ranklist:global", 0, 99); return tuples.stream() .map(t -> new RankItem(Long.parseLong(t.getValue()), t.getScore().longValue())) .collect(Collectors.toList()); } }参数说明:
ZADD的score是solved_count,value是user_id;reverseRangeWithScores按分数降序取;expire防止冷数据堆积。关键技巧:排行榜缓存不是完全取代数据库,而是“读缓存,写双写”。每次updateRanking同时更新 Redis 和 MySQLranklist表,保证最终一致性——当 Redis 故障时,前端降级读 DB,体验略有延迟但功能不丢。
5. 避坑指南:10 个让 OJ 项目半途而废的真实陷阱与解法
做 OJ 最痛苦的不是写不出判题逻辑,而是卡在某个看似 trivial 的细节上,查三天文档无果,最后发现是某个默认配置在作祟。以下是我在 3 所高校、2 家企业的 OJ 项目中踩过的坑,按发生频率排序,每条都附带复现方式和根因定位法。
5.1 现象:用户提交 C++ 代码,判题脚本返回 CE,但compile.err文件为空
原因:g++编译错误输出到了stderr,而脚本中2>/opt/oj/log/xxx.compile.err重定向了stderr,但g++在某些错误(如头文件缺失)时会把部分信息打到stdout。
解决:统一重定向&>捕获所有输出:
g++ -o $BIN_PATH $CODE_PATH &>/opt/oj/log/$SUBMISSION_ID.compile.err5.2 现象:MySQL 主从延迟导致排行榜页面显示“已通过”但数据库里还是PENDING
原因:ranklist表更新和submission状态更新不在同一事务,主库写完后从库同步有延迟,而排行榜读的是从库。
解决:排行榜缓存更新必须在submission状态变为AC后触发,且用@Transactional保证两者原子性;或排行榜页面加readPreference=primary(MongoDB)/强制走主库(ShardingSphere 可配置hint)。
5.3 现象:Thymeleaf 模板中th:if="${user.role == 'admin'}"总是false,但数据库里role确实是'admin'
原因:MySQL 的ENUM类型在 JDBC 中默认映射为Integer,而不是String。user.role实际是1,不是"admin"。
解决:在user表中将role改为VARCHAR(20),或在实体类User中用@Enumerated(EnumType.STRING)注解。
5.4 现象:@Scheduled判题任务在生产环境不执行,本地 IDE 却正常
原因:Spring Boot 默认不启用定时任务,需显式添加@EnableScheduling到主启动类。
解决:在Application.java上加注解:
@SpringBootApplication @EnableScheduling // ← 必加! public class OjApplication { ... }5.5 现象:用户上传含中文的代码文件,Linux 下g++编译报invalid byte sequence
原因:文件编码是 GBK,而g++默认按 UTF-8 解析。
解决:统一代码存储为 UTF-8,并在编译时指定编码:
g++ -finput-charset=UTF-8 -o $BIN_PATH $CODE_PATH额外提示:所有用户代码文件必须用
FileUtils.writeStringToFile(file, content, "UTF-8")保存,不能用new FileWriter()(默认用系统编码)。这是 Java Web 项目处理中文的通用铁律。
6. 进阶技巧:用 JUnit 5 + Testcontainers 实现判题逻辑的 100% 自动化回归测试
OJ 的核心价值在于“判得准”,而“判得准”必须靠测试兜底。手工测 10 道题,漏掉一个边界 case 就可能引发线上事故。我们用 JUnit 5 + Testcontainers 构建一套可复现、可 CI 的判题测试体系:每个题目类型(C++/Java/Python)都有独立测试类,每个测试用例包含标准输入、期望输出、时限、内存限制,且全程在真实 MySQL 和 Redis 容器中运行。这不是单元测试,而是端到端的契约测试。
6.1 测试环境初始化:用 Testcontainers 启动 MySQL 和 Redis
@SpringBootTest @Testcontainers public class JudgeIntegrationTest { @Container static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0") .withDatabaseName("oj_test") .withUsername("test") .withPassword("test"); @Container static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine") .withExposedPorts(6379); @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", mysql::getJdbcUrl); registry.add("spring.datasource.username", mysql::getUsername); registry.add("spring.datasource.password", mysql::getPassword); registry.add("spring.redis.host", () -> redis.getHost()); registry.add("spring.redis.port", () -> redis.getFirstMappedPort()); } }逻辑说明:
@Testcontainers注解让 JUnit 在测试前自动拉起 MySQL 和 Redis 容器;@DynamicPropertySource动态覆盖application.yml中的数据源配置,确保测试用独立数据库,不污染开发环境。容器启动时间约 8 秒,但换来的是 100% 环境一致性。
6.2 判题逻辑测试:用真实输入输出验证 AC/WA/TLE
为 C++ 题目编写测试用例:
@Test void shouldReturnAC_whenInputMatchesExpectedOutput() { // Given: 插入题目、用户、提交记录 Problem problem = new Problem().setTitle("A+B").setTimeLimit(1000).setMemoryLimit(65536); problemService.save(problem); User user = new User().setUsername("test").setPassword("123"); userService.save(user); Submission sub = new Submission() .setUserId(user.getId()) .setProblemId(problem.getId()) .setCode("#include <iostream>\nusing namespace std;\nint main(){int a,b;cin>>a>>b;cout<<a+b;return 0;}") .setLanguage("cpp"); submissionService.save(sub); // When: 执行判题 judgeScheduler.pollAndJudgeOnce(); // 触发单次判题 // Then: 检查 submission 状态和输出 Submission result = submissionService.getById(sub.getId()); assertThat(result.getStatus()).isEqualTo("AC"); assertThat(result.getExecuteTime()).isLessThan(500L); // 小于时限 } @Test void shouldReturnTLE_whenLoopExceedsTimeLimit() { // Given: 一个死循环的 C++ 代码 String infiniteLoopCode = "#include <iostream>\nint main(){while(1);return 0;}"; Submission sub = new Submission() .setUserId(1L) .setProblemId(1L) .setCode(infiniteLoopCode) .setLanguage("cpp"); submissionService.save(sub); // When judgeScheduler.pollAndJudgeOnce(); // Then Submission result = submissionService.getById(sub.getId()); assertThat(result.getStatus()).isEqualTo("TLE"); }参数说明:每个测试用例都是一个完整业务闭环:插数据 → 触发判题 → 查结果。
pollAndJudgeOnce()是判题服务的裸方法,绕过定时调度,确保测试可控。关键技巧:测试数据用@Sql脚本初始化(如init-test-data.sql),比硬编码更清晰;所有测试用例加@Tag("integration"),CI 时可单独执行。
6.3 持续集成流水线:GitHub Actions 中的自动化测试与部署
把测试融入 CI,是防止 regressions 的最后一道防线。.github/workflows/ci.yml示例:
name: OJ CI Pipeline on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK 11 uses: actions/setup-java@v3 with: java-version: '11' distribution: 'temurin' - name: Build with Maven run: mvn -B clean package -DskipTests - name: Run Integration Tests run: mvn test -Dgroups=integration env: DOCKER_HOST: unix:///var/run/docker.sock - name: Upload coverage to Codecov uses: codecov/codecov-action@v3落地细节:
mvn test -Dgroups=integration只运行@Tag("integration")的测试;DOCKER_HOST环境变量让 Testcontainers 能在 GitHub Runner 中调用 Docker;Codecov 生成覆盖率报告,重点关注JudgeEngine和SubmissionService的分支覆盖。我的习惯:每天早上第一件事是看 CI 报告,如果 integration test 红了,当天不 merge 任何 PR——宁可慢,不能错。OJ 的判题逻辑一旦出错,影响的是几百学生的作业成绩,没有后悔药。希望帮到你。
本文还有配套的精品资源,点击获取