ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Spring Boot的Java课程实验自动判题与查重系统实践

基于Spring Boot的Java课程实验自动判题与查重系统实践 每学期带Java实验课的老师应该都懂这种痛苦学生把Java源码打包发到群里文件名乱七八糟有人交的是“新建文档.txt”有人直接截图了事还有学生把别人的代码改了变量名就交上来。老师得逐个下载、解压、编译、运行、比对输出结果光是检查“这段代码是不是抄的”就要花掉一晚上更别说给几十份作业逐一批注了。后来我想清楚了一件事——既然学生的代码本质上是“程序”那我们完全可以借用在线判题系统OJ那一套自动编译、自动跑测试用例、自动给结果的方式来管理Java课程的实验环节把老师从重复劳动里解放出来。这篇东西就是我基于Spring Boot设计并实现的一套Java课程实验管理系统的完整复盘包括架构选型动机、业务模块拆解、数据库设计、判题核心的思路、安全防护和上线后的踩坑实录想直接参考的人可以少走很多弯路。1. 为什么需要这个系统Java实验教学里的三个老大难在动手写代码之前我先把现有实验教学流程里的问题列了一遍。不把问题定义清楚直接开做系统十有八九会做成一个“交作业网盘”用起来反而不如微信群方便。第一个老大难是提交格式和命名混乱。微信群收作业学生交上来的东西五花八门有把整个src目录打压缩包的有只贴Main方法片段不给类的有的文件名带“最终版”“新建文件”这类字样。老师下载之后要先改文件名再手动创建项目结构光“整理学生作业”这一步就消耗了大量时间。如果一个系统能让学生在线提交代码文件并强制绑定到指定实验任务、按学号归属这个步骤就能直接消除。第二个老大难是判题标准不统一。同样是“实现一个冒泡排序”有的学生把排序写成工具类但没有main演示有的学生输出里写了一堆提示语句有的学生压根没排对。老师人工判的时候标准很容易因为疲惫而摇摆同一个班级的分数经常被学生质疑。OJ的思路天然能解决这个问题预置一组测试用例对程序做黑盒测试只看输入输出是否匹配标准对所有人一致。第三个老大难是抄袭检测基本靠肉眼。普通课程作业的代码量不大学生之间互相“参考”非常普遍。人工排查只能看逻辑结构像不像效率低、证据也不充分。系统接入代码相似度比对之后可以把疑似雷同的作业自动标记出来老师只需人工复核标记结果工作量降了一个量级。想通这三点之后系统的定位就很清晰了它不是一个传统的“作业提交平台”而是一个带自动评分的实验任务管理平台核心是用OJ的判题能力给Java实验课提供闭环服务。学生端需要能看实验任务、在线写代码、提交判题、查看结果教师端需要能创建课程和实验、配置测试用例、查看成绩分布和查重结果管理员端则负责用户和基础数据维护。这个定位贯穿了后面所有设计和实现决策。2. 整体架构设计Spring Boot 3 Vue3 独立判题核心2.1 技术选型的真实理由技术栈选型这块我并没有刻意追新选型标准就一条生态成熟、部署简单、团队上手快。后端用Spring Boot是目前做这类管理系统最稳的选择。Spring Boot最省心的地方在于自动配置和起步依赖管理一个学生管理模块、一个题目管理模块、一个判题服务模块都能用一套清晰的包结构组织起来不会出现传统SSH那种大量XML配置的繁琐。当前Spring Boot 3.x已经全面支持JDK 17项目里用了Spring MVC作为Web层框架、Spring Data JPA做持久层、Spring Security做认证授权配合Lombok和MapStruct减少样板代码。这个组合在Java课程设计类项目里非常主流网上资料也多遇到问题很容易查到解决方案。前端我选了Vue 3 Element Plus。真正跑起来你会发现Element Plus的表格、表单、弹窗组件对管理后台类界面非常友好加上Vite的构建速度开发体验比老一套的JSPJQuery舒服太多。学生端是一个独立的页面嵌入在线代码编辑器用的Monaco Editor也就是VS Code的编辑器内核教师端则是表格和图表为主的管理页面前后端通过RESTful API交互。MySQL数据库版本用了8.0原因很简单——支持窗口函数和JSON字段后面做排名、做查询统计比较方便。判题核心模块没有做成独立的微服务而是放在同一个Spring Boot应用里用线程池隔离执行。这样做的最主要原因是部署简单一个jar包加一个MySQL就能跑起来在教学场景里不需要引入消息队列和判题节点的复杂度。如果以后并发量上来了判题核心本身是独立接口可以直接拆出去。2.2 模块划分与请求链路系统按业务边界拆成了四个子模块auth登录认证、JWT签发、角色鉴权course课程、实验任务、题目、测试用例的管理submit提交记录、判题结果、成绩统计judge代码编译、沙箱执行、结果比对一条完整的请求链路是这样的学生在前端点开某个实验任务页面加载题目描述学生写完代码点击提交前端把源码文本通过POST请求发送到/api/submit接口后端存入数据库后将判题请求放入一个阻塞队列判题线程池从队列中取出任务执行编译、运行、比对测试用例把结果写回提交记录。前端通过轮询或WebSocket拿到状态更新。核心思路就是异步判题不能让学生在前端页面等同步结果否则遇到死循环程序直接就把请求线程卡死了。2.3 为什么判题必须走沙箱判题的核心安全隐患是学生的代码是不可信代码。如果服务器直接执行学生提交的Java程序等于给了学生服务器命令执行权限。比如学生可以写Runtime.getRuntime().exec(rm -rf /)虽然Java的权限模型有点保护但直接在生产环境裸跑学生代码仍然非常危险。我的方案是使用Docker容器做隔离。每个判题任务启动一个独立的容器容器内部只有JDK环境和固定的工作目录挂载只读的测试用例文件默认容器资源限制为--memory256m --cpus1并设置--networknone禁止网络访问--pids-limit64限制进程数。判题时把学生代码复制进容器执行编译和运行命令超时由docker的timeout接口控制。这种做法在工程质量上比直接进程隔离更安全也比使用SecurityManager那套过时方案更干净。架构层面画成一句话就是Spring Boot管业务Docker管隔离MySQL管持久化测试用例管标准。这就是整个系统运行的地基后续所有功能都在这上面长出来的。3. 核心模块实现题目、提交、判题怎么闭环3.1 实验任务和题目结构的组织方式实验管理的第一步是把“实验”这个业务概念落到数据结构上。一个实验任务对应一组必做题和选做题每道题就是传统OJ里的“题目”。实验发布时老师创建一条Experiment记录设置开始时间、截止时间、可见班级然后往这个实验里添加若干Problem题目。题目本身需要包含以下字段题目标题、题目描述、输入说明、输出说明、样例输入输出、难度标记、分数。另一个关键技术点是测试用例的数据结构。每个题目有多个测试点每个测试点包含输入文本和期望输出文本还要有score权重。判题时按测试点分别判最后加权汇总得出总分这样学生可以清楚地看到自己哪个测试点没过。以下是Problem实体核心字段的简化代码实际项目中还包含创建时间等通用字段Entity public class Problem { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; private String description; private String inputDesc; private String outputDesc; OneToMany(cascade CascadeType.ALL, mappedBy problem) private ListTestCase testCases new ArrayList(); private Integer difficulty; private Integer totalScore; private Long courseId; }测试用例的存储我特意没有用JSON字段塞进Problem表而是单独建表。原因很实际课程实验中经常要局部调整某个测试点的分数权重或者单独给某个测试点增加超时时间独立表管理起来更灵活也能在UI上单独编辑。3.2 学生提交与异步判题流程学生提交的核心接口逻辑并不复杂但有几个细节决定了系统稳不稳定。前端把代码文本和后端保存的题目ID一起POST过来后端先做合法性校验代码长度限制、文件类型限制然后生成一条Submission记录状态为PENDING。紧接着把SubmissionTask包含代码、题目ID、提交记录ID放入阻塞队列立即给前端返回“已提交判题中”。这样前端不必等待判题完成用户体验是顺畅的。判题线程池从队列中消费任务流程是这样的创建临时工作目录并把测试用例输入文件写入只读目录。根据提交的源码生成对应的Java源文件。如果是单个类文件直接用类的名称命名文件如果是多文件项目需要按照压缩包结构还原目录。执行docker run启动容器容器内执行javac编译捕获编译输出的错误信息。编译通过后逐个测试点执行java运行命令设置超时时间。将程序的stdout与期望输出对比记录每个测试点是否通过、运行时间、输出内容。汇总判题结果更新Submission状态为ACCEPTED或WRONG_ANSWER等。判题核心代码如下注意这里面的执行命令都是拼好之后传给Docker的目的就是隔离public JudgeResult judge(SubmissionTask task) throws Exception { Path workdir createWorkdir(); writeSourceFile(workdir, task.getCode(), task.getClassName()); writeCaseFiles(workdir, task.getTestCases()); // 1. compile DockerExecResult compileResult dockerExec( compile- task.getSubmissionId(), workdir.toString(), new String[]{javac, task.getClassName() .java}, 15 ); if (!compileResult.isSuccess()) { return JudgeResult.compileError(compileResult.getStderr()); } // 2. run each test case int passedCount 0; int totalScore 0; ListCaseResult caseResults new ArrayList(); for (TestCase tc : task.getTestCases()) { DockerExecResult runResult dockerExec( run- task.getSubmissionId(), workdir.toString(), new String[]{java, task.getClassName()}, tc.getTimeoutSeconds() ); if (runResult.isTimeout()) { caseResults.add(CaseResult.timeout(tc)); } else { boolean passed normalize(runResult.getStdout()) .equals(normalize(tc.getExpectedOutput())); caseResults.add(new CaseResult(tc, passed)); if (passed) { passedCount; totalScore tc.getScore(); } } } return JudgeResult.create(passedCount, totalScore, caseResults); }这里有个细节——输出比对。学生程序输出时可能有多余空格、换行或者不同操作系统的行尾符不同直接做字符串完全相等比对误伤率很高。我采用了规范化比对先按行拆分再对每行做trim()去除首尾空白最后用\n重新连接再比较。这个策略能避免因为一个尾随空格把正确代码判成WA的情况。3.3 代码编辑器和前端交互体验学生在线写代码的体验直接决定了系统是否真的会被用起来。如果体验太差学生宁可本地用IDEA写完了再回到网页粘贴代码。所以在线编辑器我选了Monaco支持Java语法高亮、自动缩进、代码折叠这些都是标配。实验任务的题目描述放在左侧编辑器和提交按钮放在右侧学生写完可以直接点击提交。提交之后结果展示区显示每个测试点的通过情况失败的测试点会展示期望输出和实际输出的差异方便学生自己排查。这个“提交-反馈-修改-再提交”的循环本质上就是OJ刷题的正反馈机制学生用完是会上瘾的。4. 数据库设计围绕课程组织和代码提交的表结构数据库设计是这类系统最容易翻车的地方。表数量太多让关系变得错综复杂表太少又会冗余严重。下面的表结构是我沉淀后的版本按业务域拆成了三块课程实验域、提交判题域、用户权限域。4.1 课程实验域CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id BIGINT NOT NULL, semester VARCHAR(20), created_at DATETIME ); CREATE TABLE experiment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0, FOREIGN KEY (course_id) REFERENCES course(id) ); CREATE TABLE problem ( id BIGINT PRIMARY KEY AUTO_INCREMENT, experiment_id BIGINT NOT NULL, title VARCHAR(200) NOT NULL, description TEXT, input_desc VARCHAR(500), output_desc VARCHAR(500), difficulty TINYINT, total_score INT, FOREIGN KEY (experiment_id) REFERENCES experiment(id) ); CREATE TABLE test_case ( id BIGINT PRIMARY KEY AUTO_INCREMENT, problem_id BIGINT NOT NULL, input_data TEXT, expected_output TEXT, score INT, timeout_seconds INT DEFAULT 5, is_sample BOOLEAN DEFAULT FALSE, FOREIGN KEY (problem_id) REFERENCES problem(id) );这组表的设计要点是题目不直接挂在课程下而是挂在实验下。刚开始我把题目直接和课程绑定后来发现一个问题不同学期的同一门课程实验内容和题目可能完全不同。题目挂在实验下面实验属于课程课程有学期字段这样既能随学期灵活组织题目也能通过课程ID查历史实验题目。还有一点test_case表里的is_sample字段很有用。样例测试点是用来展示给学生看的不计入总分隐藏测试点才是真正考核用的。老师在配置题目的时候可以先把样例填进去方便学生理解题目要求在说什么隐藏测试点用于防止学生只做表面功夫。4.2 提交判题域CREATE TABLE submission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, problem_id BIGINT NOT NULL, experiment_id BIGINT NOT NULL, code TEXT NOT NULL, status VARCHAR(20), judge_result TEXT, passed_count INT, total_score INT, run_time_ms INT, memory_kb INT, similarity DOUBLE, created_at DATETIME, UNIQUE KEY uk_stu_problem (student_id, problem_id, created_at) ); CREATE TABLE case_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, submission_id BIGINT NOT NULL, test_case_id BIGINT NOT NULL, passed BOOLEAN, actual_output TEXT, run_time_ms INT, FOREIGN KEY (submission_id) REFERENCES submission(id) );提交记录的表是整个系统的核心数据表它记录的是“谁在什么时候交了哪道题的什么代码结果是什么”。里面有两个字段值得特别说明一个是judge_result用来存JSON格式的判题摘要比如每个测试点的通过情况另一个是similarity表示这代码和同班同学提交内容的最高相似度这是给教师端查重列表用的。case_result表保存每个测试点的细粒度结果方便教师查看学生到底败在哪个用例上。状态字段我用的是字符串而不是数字枚举值值是PENDING、COMPILING、JUDGING、ACCEPTED、WRONG_ANSWER、COMPILE_ERROR、TIME_LIMIT_EXCEEDED、RUNTIME_ERROR。用字符串的优点是代码可读性好和判题状态一一对应排查问题时一眼就能看懂。缺点是占用空间稍微大一点但这个量级根本无所谓。4.3 用户权限域CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20), username VARCHAR(50) NOT NULL, password_hash VARCHAR(100) NOT NULL, real_name VARCHAR(50), role VARCHAR(20), email VARCHAR(100) ); CREATE TABLE experiment_student ( experiment_id BIGINT NOT NULL, student_id BIGINT NOT NULL, enroll_status TINYINT DEFAULT 0, PRIMARY KEY (experiment_id, student_id) );权限设计采用最简单的RBAC模型角色就三种ROLE_ADMIN、ROLE_TEACHER、ROLE_STUDENT。密码用BCrypt哈希存储这一点非常重要——很多学生项目直接明文存密码一旦数据库泄露就是事故。实验和学生之间是多对多关系用experiment_student表记录指定实验关联的学生教师可以手动添加学生到某个实验也可以通过课程批量导入。数据库设计总的来说核心思路就是提交记录永不覆盖。学生每提交一次代码就生成一条新记录不做更新这么做有两个好处一是老师能看学生的代码迭代过程二是系统可以正确定位“最终提交”。如果覆盖更新学生反复提交的痕迹就丢了查重和分析都没有数据基础。5. 安全与防作弊XSS过滤、文件上传和查重方案5.1 XSS攻击怎么拦有从热词里看到“springboot项目全局过滤器处理上传pdf文件时xss攻击”这说明XSS是这类管理系统里大家都会困扰的点。学生在OJ系统里提交的内容不只是代码还有实验报告、问题反馈这些文本字段。如果不对这些文本做过滤学生提交一段scriptalert(xss)/script其他学生浏览评论区时就可能中招。我的处理方式是全局过滤器 白名单校验 输出编码三层配合。全局过滤器拦截所有请求对JSON请求体里的字符串字段做递归遍历把script、iframe、img onerror这些危险标签转义成无害文本。同时在接收端对富文本字段做白名单校验只允许保留单纯的格式化标签比如p、br、code这些其它标签一律剥离。前端渲染的时候再配合Vue的插值表达式自动转义三层下来基本没有可乘之机。需要特别强调的是代码提交字段不能做XSS转义。Java代码里频繁出现、、符号如果把学生提交的代码做HTML实体转义再存库代码就坏了。所以代码提交的接口需要单独标记为“原始文本”跳过硬编码的XSS过滤器。这也是为什么我说XSS过滤必须做“白名单 路径排除”而不是一刀切全局转义。5.2 文件上传的安全处理实验作业不只是代码有时候老师还要求交文档、项目压缩包。文件上传接口如果只做类型校验很容易被上传恶意脚本文件。我的处理策略是上传的文件存放在服务器本地磁盘不直接放到Web应用的静态资源目录。存放路径用UUID重命名彻底断绝文件名穿越的可能。校验文件扩展名和MIME类型双重验证。比如只允许.pdf、.zip、.docx即使伪造MIME扩展名不在白名单里也直接拒绝。使用Apache Tika检测文件真实类型。只看MIME很容易被伪造Tika通过解析文件头Magic Bytes判断真实类型能拦截大部分改后缀绕过的情况。下载文件时响应头设置Content-Disposition: attachment强制浏览器下载而不触发内联预览。这个细节可以避免在浏览器直接打开SVG这类容易携带脚本的文件。文件上传这块比较容易被忽略的风险是文件大小。学生上传一个2GB的压缩包服务端内存直接被打满。我在Spring配置里限制了单文件最大20MB同时用流式写入不会把整个文件加载进内存。5.3 代码查重从字符串相似度到AST查重功能是课程实验管理系统和普通OJ最不一样的地方。OJ经典场景里学生提交的是算法题答案代码风格本来就接近查重不是核心需求。课程实验则完全不同作业题目相对开放代码结构容易雷同查重反而是教师最关心的功能。我的第一版查重实现是字符串级别的相似度算法——对代码做归一化处理去掉注释、空白字符、统一变量名其实是把标识符替换成固定占位符然后用SimHash算法计算指纹最后用汉明距离算相似度。SimHash对大代码文件跑得很快在整个班级范围内做两两对比也是毫秒级的。但字符串层面查重有个明显的漏洞学生把if (a b)改成if (b a)或者把for循环改成while循环字符串完全不相似但逻辑完全一样。所以第二版我在字符串查重的基础上引入了抽象语法树比对。用Java的词法解析器把源码解析成AST然后做树的规范化——删除位置信息、删除注释节点、把字面量归一化最后比较树的编辑距离。AST级别的相似度能抓住“换个写法但完全同构”的抄袭。这个方案的实现复杂度高不少但判断准确率提升明显。实际部署时我把两个指标的加权平均值作为最终的相似度展示在教师端。教师点开某个相似度高的提交记录可以左右并排对比两个学生的代码自己判断是否构成抄袭。查重只做提示不做自动定性因为代码相似度高有时可能是合理借鉴尤其是全班都在用同一个教材示例的时候。6. 踩坑实录从开发到上线遇到的四个典型问题6.1 JDK 17和Spring Boot 3的组合带来的依赖连锁问题开发初期用的是Spring Boot 3.0.2加JDK 17本身没有大问题但生态里的第三方库适配度参差不齐。比如早期集成的一些工具包老版本是用javax.*命名空间的到了Jakarta EE 9之后全改成了jakarta.*如果代码里用IDE自动导入了javax.persistence.*运行时就报找不到类的错误。这个问题排查起来特别迷惑因为编译能过只有运行时才爆炸。解决办法是统一检查所有第三方依赖的版本确保支持Jakarta命名空间。另外Spring Boot 3.0对配置项做了很多清理老教程里的spring.redis.*这类写法已经不生效了必须使用新的命名空间。建议刚上手Spring Boot 3的人先去看官方Migration Guide别直接拿老教程的配置粘贴。6.2 Docker容器内的中文字符和时区问题学生代码里如果有中文的System.out.println在容器内运行经常输出乱码。这是因为容器基础镜像默认没有安装中文字体也没设置UTF-8编码。我在Dockerfile里显式设置了环境变量FROM eclipse-temurin:17-jdk ENV LANGC.UTF-8 ENV LC_ALLC.UTF-8 ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone加上这行配置之后中文输出和本地时间都正常了。这个坑很隐蔽学生交的代码在本地跑得好好的一上系统就乱码学生就会觉得系统有问题实际上是容器环境没配好。6.3 死循环程序的资源控制学生提交一个while(true){}的死循环如果不对资源做严格限制判题进程会一直占着CPU严重时拖垮服务器。我一开始只在Java进程层面设置超时时间但实测发现超时信号发过去之后子进程不一定会立刻退出因为Runtime.exec()启动的进程和Spring Boot进程不在同一个进程组timeout命令默认杀不掉它。解决办法是两件事配合一是Docker启动容器时加上--cpu-shares和--memory限制让死循环只能消耗容器配额二是在执行命令外层包一层duration配合docker stop操作超时直接强制停掉整个容器。因为每个任务单独一个容器强制停止不会影响其他任务。这个方案实测下来非常稳再也没有出现死循环拖垮服务器的情况。6.4 成绩汇总时的Java空指针问题教师端要汇总某个实验的所有成绩第一版代码是从数据库查出所有提交记录然后按学生ID分组、取每次提交的最新一条。但有些学生报了名一次都没交过分组里压根没有他的记录直接get那一下就是空指针。这个问题的本质是SQL思维和面向对象思维混用导致的——在SQL里用LEFT JOIN天然能搞定的东西在Java里用内存分组处理就漏了边界情况。后来我把统计逻辑改成了SQL聚合查询以学生表为主表做LEFT JOIN一次性查出每个学生的提交次数和最高分再在Java层做展示排序。这提醒我一个很重要的设计原则统计数据尽量交给数据库做别把数据捞出来在应用内存里二次加工既容易漏边界情况性能也差一个量级。7. 实际运行的效果与改进空间系统上线后跑了一整个学期的Java课程实验数据上有个直观的变化以前收一次实验作业老师要花两个晚上批改现在系统自动判题加查重老师只需要花一个小时复核查重标记的疑似抄袭代码并抽查性地看看高分学生的大题代码质量。学生端的使用反馈也不错特别是“提交立刻能看自己哪些测试点没过”这件事对学生的自驱力提升明显相当于把一个“交作业”的被动行为变成了“打怪升级”的主动行为。同时我也观察到几个值得改进的方向列出来给后面想做类似系统的人做参考代码风格分析目前判题只关注结果正确性不关注代码质量。以后可以引入Checkstyle或SonarQube对学生的代码做静态分析检查命名规范、圈复杂度、重复代码这些静态质量指标让分数里包含质量维度。实验报告和代码结合很多实验不只要求代码还要写实验报告。目前报告和代码是两个独立的提交入口后期可以把报告模块和代码提交模块联动一个实验任务同时要求提交报告和代码两者合并计算最终成绩。Git集成真正的软件工程管理应该用Git操作来提交代码而不是在网页里粘贴。如果学生能直接git push到系统系统在服务端自动拉取代码并触发判题那就更贴近真实研发流程了。WebSocket推送判题结果当前用的是轮询方式简单但不够优雅。改成WebSocket或者SSE之后前端判题结果实时推送交互体验会再上一个台阶。班级成绩画像教师端增加成绩趋势分析、知识点掌握度分析这类可视化模块让老师能从宏观层面调整教学节奏。从我个人实际开发这套系统的体会来说最大的收获不是把Spring Boot的技术栈用熟了而是彻底理解了判题系统和业务系统之间微妙的边界——判题是相对独立的计算机科学问题业务系统则要处理大量人的行为带来的边缘情况。两者结合的时候一定要用异步、隔离、标准化的思路去设计否则判题稍微不稳定整个实验管理流程都会被抱怨“不好用”。如果让我重新做一遍我会在第一天就把代码查重和WebSocket推送考虑进来这两个功能后期接入的成本比一开始设计好要高得多。希望这篇复盘能帮到正在做类似课程设计或者有实验教学管理需求的同学少踩我踩过的坑。
返回列表