公司动态

基于Spring Boot与SimHash的代码查重系统:从原理到企业级实现

📅 2026/8/28 18:40:13
基于Spring Boot与SimHash的代码查重系统:从原理到企业级实现
简介在软件工程与计算机教育领域代码相似度检测是一个重要的技术课题它涉及文本处理、算法设计与系统架构等多个基础概念。其核心原理是通过特征提取与相似度计算将源代码转化为可度量的数字指纹进而实现自动化比对。这项技术的价值在于能够将教师从繁琐的人工审查中解放出来提升教学管理的效率与公正性。典型的应用场景包括高校编程作业查重、代码抄袭检测以及软件知识产权保护等。本文以Spring Boot和MyBatis Plus构建的企业级项目为例深入剖析了如何实现一个完整的代码查重系统其中重点讲解了基于SimHash算法生成代码指纹以及通过海明距离进行高效比对的工程实践为开发者提供了从技术选型、核心算法到性能优化的全链路解决方案。1. 项目概述与核心价值最近在整理过往项目时翻出了一个几年前为某高校计算机学院做的课程作业查重系统源码。这个项目基于 Spring Boot 和 MyBatis Plus 构建核心目标是解决编程类课程中学生提交的源代码作业存在抄袭、高度相似的问题。对于一线教师和助教来说手动比对几十上百份代码文件无异于大海捞针效率低下且容易出错。这个系统就是为了把这个过程自动化、智能化。简单来说它就是一个专门针对程序代码的“查重工具”。用户通常是教师上传一个包含多份学生作业源代码的压缩包系统会自动解压、解析然后通过特定的算法计算每两份代码之间的相似度最终生成一份清晰的可视化报告指出哪些作业之间存在高度相似的可能。这不仅能有效震慑抄袭行为更能将教师从繁琐的重复劳动中解放出来把精力投入到更有价值的教学反馈中去。这套源码的价值在于它提供了一个完整、可落地的企业级解决方案雏形。从技术选型上看Spring Boot 确保了项目的快速启动和易于维护MyBatis Plus 则极大地简化了数据库操作。更重要的是它实现了代码查重这个特定业务场景的核心逻辑包括代码预处理、特征提取、相似度比对和报告生成这些模块的设计思路和实现细节对于想深入理解业务系统开发、或需要定制类似功能的开发者来说是非常好的学习材料和开发起点。2. 系统整体架构与设计思路拆解2.1 技术栈选型背后的考量为什么是 Spring Boot MyBatis Plus 这个组合这背后是经过实际项目打磨后的经验之选。首先Spring Boot 是微服务和企业级应用的事实标准。在这个查重系统中我们需要处理文件上传、批量任务调度、异步计算等典型的后台服务功能。Spring Boot 的自动配置、内嵌容器和丰富的 Starter 依赖让我们能快速搭建一个健壮的后端服务。比如通过spring-boot-starter-web轻松提供 RESTful API 供前端调用通过spring-boot-starter-data-redis集成缓存来存储临时的比对任务状态提升响应速度。选择它意味着项目在可维护性、社区支持和后续扩展性上都有保障。其次数据库操作层选择 MyBatis Plus 而非原生 MyBatis 或 JPA是基于开发效率与灵活性的平衡。代码查重系统涉及的数据表并不复杂主要是用户信息、作业批次、原始文件存储路径、相似度结果等。MyBatis Plus 在 MyBatis 的基础上提供了强大的 CRUD 封装如BaseMapper、Service层封装对于这类标准操作几乎可以不用手写 SQL开发速度极快。同时当遇到复杂的查询比如需要联表查询某次作业批次下所有学生的比对结果时MyBatis Plus 的Wrapper条件构造器也能清晰、安全地构建查询条件避免了 SQL 注入风险又保留了手写 SQL 的灵活性。这种“开箱即用”与“按需定制”的结合非常适合此类业务逻辑中等复杂度的管理系统。2.2 核心业务流程设计系统的核心业务流程可以清晰地分为几个阶段我把它画成了一个线性的处理管道作业上传与预处理教师通过 Web 界面上传一个 ZIP 格式的作业包。系统后端接收文件将其保存到指定的存储目录如服务器本地磁盘或云存储。随后系统会解压这个 ZIP 包遍历其中的每个学生文件夹。这里的第一步预处理就是代码的“清洗”包括去除所有注释行注释//、块注释/* */、去除空白字符空格、制表符、换行有时还会将变量名、函数名进行统一标准化例如都替换为var1,func1。这一步的目的是消除因格式、命名习惯不同带来的无关噪声聚焦于代码的逻辑结构。特征提取与索引构建清洗后的代码文本需要转化为可计算的特征。常用的方法有基于词法分析的“令牌序列”Token Sequence即将代码解析成关键字、运算符、标识符等令牌流或者基于抽象语法树的“树结构”AST特征对于查重来说一种简单有效的方法是使用“字符串哈希指纹”比如 SimHash。系统会对每份预处理后的代码计算其 SimHash 值一个 64 位的指纹。同时为了后续快速比对系统会为本次作业批次建立一个临时的内存索引将学生 ID 与其代码指纹关联起来。相似度计算与配对比对这是系统的算法核心。系统会遍历索引中的所有代码指纹进行两两比对。对于 SimHash 方法比对就是计算两个 64 位指纹的海明距离Hamming Distance。海明距离越小说明两份代码越相似。系统会设定一个阈值例如海明距离小于 3 认为高度相似将所有超过阈值的代码对记录下来。这个过程计算量较大是O(n²)的复杂度因此在实际实现中会考虑使用任务队列如 Redis Queue进行异步处理避免阻塞 Web 请求。结果持久化与报告生成计算出的相似度结果学生A ID 学生B ID 相似度分数 疑似片段会被存入数据库。同时系统会生成一份结构化的报告通常是 JSON 或 HTML 格式。报告会列出所有相似度超过阈值的作业对并可能高亮显示两份代码中相同的部分这需要回溯到原始代码进行行级比对。前端页面拿到这份报告后可以渲染成一个交互式的界面教师可以点击查看任意一对作业的详细比对情况。这个流程设计的关键在于“解耦”。文件处理、算法计算、结果存储、报告生成每个模块相对独立通过清晰的数据接口如文件路径、任务ID、结果DTO进行通信。这样设计的好处是未来如果想更换更先进的查重算法如基于深度学习的代码表征只需要替换“特征提取与相似度计算”这个模块其他部分几乎不用改动。3. 核心模块详解与实操要点3.1 文件上传与预处理模块实现文件上传是系统的入口必须保证稳定和安全。在 Spring Boot 中我们通常使用MultipartFile来接收前端上传的文件。PostMapping(/upload) public ApiResponseString uploadAssignment(RequestParam(file) MultipartFile file, RequestParam(batchName) String batchName) { if (file.isEmpty()) { return ApiResponse.error(上传文件不能为空); } // 1. 生成唯一批次ID和存储路径 String batchId UUID.randomUUID().toString(); Path batchDir Paths.get(uploadRootDir, batchId); Files.createDirectories(batchDir); // 2. 保存原始ZIP文件 Path zipPath batchDir.resolve(file.getOriginalFilename()); file.transferTo(zipPath.toFile()); // 3. 异步触发预处理流程 codePreprocessService.asyncProcessBatch(batchId, zipPath.toString(), batchName); return ApiResponse.success(作业包上传成功正在处理中, batchId); }实操要点与避坑指南存储路径规划不要使用用户上传的原始文件名直接保存避免重名和路径遍历攻击。建议使用UUID生成唯一目录名将整个批次的所有文件原始ZIP、解压后文件、处理中间文件都放在该目录下便于管理和清理。异步处理解压和预处理代码可能是耗时操作务必使用Async或消息队列将其异步化立即返回给前端一个“任务已接收”的响应和任务IDbatchId。前端可以轮询或通过WebSocket来获取任务处理进度。代码清洗的准确性去除注释的正则表达式要写准确避免误伤字符串内的内容。例如Java代码中String s //这不是注释;这里的双斜线就不能被去掉。一个更稳妥的方法是使用现成的词法分析库如 ANTLR 对应语言的 Lexer进行令牌化然后过滤掉注释令牌。3.2 基于 SimHash 的代码指纹与比对算法SimHash 是 Google 用于网页去重的算法其思想同样适用于代码文本。它的优点是能将一段文本代码压缩成一个固定长度的指纹且相似文本的指纹海明距离小非常适合快速比对。实现步骤分词与哈希将清洗后的代码文本按行或按单词分割。对每个词Token计算其 MD5 或 SHA-1 哈希值得到一个 64 位的哈希值视为一个 64 维的向量。加权与累加为每个哈希值的每一位0或1设置权重。如果是1则向量对应位置 weight如果是0则对应位置 -weight。这里的 weight 可以简单设为1或者根据词频设定。生成指纹累加所有词的向量后得到一个最终的 64 维向量。对于这个向量的每一位如果大于0则置1小于0则置0。这样就得到了一个 64 位的 SimHash 指纹。public class SimHashCalculator { public static long simHash(String code) { int[] featureVector new int[64]; // 64位向量 ListString tokens tokenize(code); // 分词 for (String token : tokens) { long hash hashToken(token); // 计算token的64位hash for (int i 0; i 64; i) { // 判断hash的第i位是1还是0 if (((hash i) 1) 1) { featureVector[i] 1; // 为1则加 } else { featureVector[i] - 1; // 为0则减 } } } // 生成指纹 long fingerprint 0; for (int i 0; i 64; i) { if (featureVector[i] 0) { fingerprint | (1L i); } } return fingerprint; } public static int hammingDistance(long hash1, long hash2) { return Long.bitCount(hash1 ^ hash2); // 异或后计算1的个数 } }注意事项分词粒度对代码的分词Tokenize策略直接影响效果。简单按空格分割对于英文代码尚可但对编程语言最好使用词法分析器获取关键字、标识符等。在本项目中为了简化我们采用了“行”作为基本单元即每一行清洗后的代码作为一个“词”。实践证明对于学生作业级别的代码这种方法在效果和复杂度之间取得了不错的平衡。海明距离阈值阈值需要根据实际数据调整。通常通过分析一批已知是否抄袭的样本对来选择一个合适的值比如3或4。阈值设得太低会漏掉一些改头换面的抄袭设得太高则会产生大量误报。3.3 数据库设计与 MyBatis Plus 应用系统的数据模型并不复杂核心是几个实体。-- 作业批次表 CREATE TABLE assignment_batch ( id VARCHAR(32) PRIMARY KEY COMMENT 批次ID, batch_name VARCHAR(100) NOT NULL COMMENT 批次名称, teacher_id VARCHAR(32) COMMENT 创建教师ID, original_file_path VARCHAR(500) COMMENT 原始ZIP文件路径, status TINYINT DEFAULT 0 COMMENT 状态0-处理中1-完成2-失败, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 学生作业表 CREATE TABLE student_assignment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id VARCHAR(32) NOT NULL COMMENT 所属批次, student_id VARCHAR(50) NOT NULL COMMENT 学号, student_name VARCHAR(50) COMMENT 姓名, file_path VARCHAR(500) COMMENT 解压后代码目录路径, sim_hash BIGINT COMMENT 代码SimHash值, INDEX idx_batch_id (batch_id), INDEX idx_sim_hash (sim_hash) -- 为SimHash建立索引加速海明距离查询范围查询支持有限 ); -- 相似度结果表 CREATE TABLE similarity_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id VARCHAR(32) NOT NULL, assignment_id_a BIGINT NOT NULL COMMENT 作业A ID, assignment_id_b BIGINT NOT NULL COMMENT 作业B ID, similarity_score DOUBLE NOT NULL COMMENT 相似度分数换算为百分比, hamming_distance INT NOT NULL COMMENT 海明距离, detail TEXT COMMENT 详细比对信息JSON格式存储相似片段位置, UNIQUE KEY uk_pair (batch_id, assignment_id_a, assignment_id_b) -- 防止重复记录 );使用 MyBatis Plus 操作这些表异常简单。以SimilarityResult实体为例Data TableName(similarity_result) public class SimilarityResult { TableId(type IdType.AUTO) private Long id; private String batchId; private Long assignmentIdA; private Long assignmentIdB; private Double similarityScore; private Integer hammingDistance; private String detail; } // Mapper接口继承BaseMapper即可获得CRUD方法 public interface SimilarityResultMapper extends BaseMapperSimilarityResult { // 如果需要复杂查询可以在这里定义方法配合Select注解或XML // 例如查询某个批次下所有高相似度结果 Select(SELECT * FROM similarity_result WHERE batch_id #{batchId} AND similarity_score #{threshold} ORDER BY similarity_score DESC) ListSimilarityResult selectHighSimilarityByBatch(Param(batchId) String batchId, Param(threshold) Double threshold); } // Service层使用 Service public class SimilarityResultService extends ServiceImplSimilarityResultMapper, SimilarityResult { public boolean saveResult(String batchId, Long aId, Long bId, int distance) { double score calculateSimilarityScore(distance); // 将海明距离转换为百分比分数 SimilarityResult result new SimilarityResult(); result.setBatchId(batchId); result.setAssignmentIdA(aId); result.setAssignmentIdB(bId); result.setHammingDistance(distance); result.setSimilarityScore(score); return this.save(result); } }经验之谈索引优化similarity_result表的(batch_id, assignment_id_a, assignment_id_b)唯一索引非常必要防止在异步任务重试等情况下插入重复数据。student_assignment表的sim_hash索引对于按指纹查询有帮助但请注意海明距离计算WHERE BIT_COUNT(sim_hash ^ #{targetHash}) #{threshold}这种查询在 MySQL 中即使有索引也无法高效利用因为它是计算表达式。这也是为什么我们将所有比对计算放在内存或Java服务中进行数据库仅用于存储最终结果。分页查询当一次比对的作业数量很多时结果可能成千上万条。在后台管理页面展示时一定要使用 MyBatis Plus 的Page对象进行分页查询避免一次性加载大量数据导致内存溢出和页面卡顿。4. 系统部署与性能调优实战4.1 从开发环境到生产部署本地开发时我们用内嵌的 Tomcat 和 H2 数据库很方便。但上生产环境需要考虑更多。打包与运行使用mvn clean package打出一个可执行的 JAR 文件。通过java -jar your-system.jar --spring.profiles.activeprod来指定生产环境配置文件。在生产环境我们通常会使用systemd或supervisor来管理这个 JAR 进程实现开机自启和故障重启。数据库迁移生产环境使用 MySQL 或 PostgreSQL。需要在application-prod.yml中配置数据源。数据库表结构可以通过 Flyway 或 Liquibase 这样的数据库迁移工具来管理确保每次版本升级时数据库结构能同步更新。文件存储在开发环境文件存在本地磁盘没问题。但在生产环境尤其是分布式部署时必须考虑共享存储。可以将上传的作业包存储到诸如 MinIO、阿里云 OSS、腾讯云 COS 这类对象存储服务中。在代码中你需要将文件路径替换为对象的 URL 或 Key。这样即使后端服务有多实例也能访问到同一份文件。配置外部化所有可能因环境而变的配置如数据库连接、对象存储的 Access Key、文件存储根路径、SimHash 比对阈值等都必须从代码中抽离放到application.yml或环境变量中。Spring Boot 的ConfigurationProperties是管理这类配置的好帮手。4.2 处理大规模作业比对的性能策略当一次需要比对数百甚至上千份作业时O(n²)的两两比对会成为性能瓶颈。这里有几个优化策略异步化与任务队列这是必须的。上传接口只负责接收文件和创建批次任务将批次ID放入 Redis 或 RabbitMQ 队列。由独立的“作业比对工作线程”从队列中消费任务进行处理。前端通过 WebSocket 或轮询一个状态查询接口来获取进度。基于 SimHash 的预过滤与分桶SimHash 有一个很好的特性如果两个指纹的海明距离很近比如小于3那么它们的 64 位中必然有若干位是完全相同的。我们可以利用这个特性进行分桶。例如取 SimHash 的前 16 位作为“桶键”只有桶键相同的指纹才需要进行详细的海明距离计算。这可以极大地减少需要两两比对的数量。在实现上可以在计算完一个批次所有作业的 SimHash 后用一个MapString, ListLong来存储桶键到作业ID列表的映射然后只在每个桶内部进行两两比对。分布式计算如果数据量极大单机内存和计算力无法满足可以考虑将比对任务进一步拆分。例如将作业列表分成多个块分发到不同的计算节点上执行比对最后再合并结果。这需要引入更复杂的分布式任务调度框架如 Apache Spark 或更轻量级的分布式计算模型。结果缓存对于相同的代码文件其 SimHash 值是固定的。可以考虑将(文件内容MD5, SimHash)的映射关系缓存起来。如果后续有完全相同的文件上传可能是不同批次的同一份作业可以直接从缓存中获取指纹避免重复计算。一个简单的分桶比对代码示例public void compareInBatch(String batchId, ListStudentAssignment assignments) { // 1. 计算所有作业的SimHash并存入Map MapLong, String hashToIdMap new HashMap(); MapString, ListLong bucketMap new HashMap(); // 桶映射 for (StudentAssignment assignment : assignments) { long hash assignment.getSimHash(); hashToIdMap.put(hash, assignment.getId()); // 取前16位作为桶键 (这里假设hash是64位long) String bucketKey Long.toBinaryString(hash 48); // 取高16位 bucketMap.computeIfAbsent(bucketKey, k - new ArrayList()).add(assignment.getId()); } // 2. 在每个桶内进行两两比对 ListSimilarityResult results new ArrayList(); for (ListLong idListInBucket : bucketMap.values()) { if (idListInBucket.size() 2) continue; for (int i 0; i idListInBucket.size(); i) { for (int j i 1; j idListInBucket.size(); j) { Long idA idListInBucket.get(i); Long idB idListInBucket.get(j); long hashA getHashById(idA); // 需要根据ID获取hash这里简写 long hashB getHashById(idB); int distance SimHashCalculator.hammingDistance(hashA, hashB); if (distance SIMILARITY_THRESHOLD) { results.add(new SimilarityResult(batchId, idA, idB, distance)); } } } } // 3. 批量保存结果到数据库 similarityResultService.saveBatch(results); }5. 前端交互与结果可视化后端提供了数据还需要一个清晰的前端界面供教师使用。这个系统通常配有一个简单的管理后台。作业上传页面一个表单包含批次名称输入框和文件选择框。上传后显示“处理中”的提示和任务ID。页面可以定时轮询后端/task/{batchId}/status接口获取处理进度如已处理 30/100 份作业。报告展示页面这是核心。页面以列表形式展示某个批次下的所有高相似度作业对按照相似度从高到低排序。每一行显示两个学生的学号、姓名和相似度百分比。详情对比视图点击列表中的某一行可以弹窗或跳转到详情页并排展示两份源代码。这里需要一个代码对比组件类似 GitHub 的 Diff 视图。前端可以使用诸如react-diff-viewer或vue-code-diff这样的开源组件。后端需要提供一个接口根据两个作业ID返回它们源代码的详细差异信息通常是一个描述了“哪些行相同、哪些行被修改”的结构化数据如JSON前端组件根据这个数据来渲染高亮。前端与后端的协作关键点进度反馈后端异步任务需要将进度写入 Redis 或数据库前端轮询查询。进度信息可以包括当前阶段解压、预处理、计算中、已处理项目数/总数等。大结果集分页相似度结果列表一定要支持分页和排序后端接口设计时要包含page,size,sortBy等参数。代码查看安全提供源代码查看功能时要确保只能查看属于该教师所创建批次的作业防止越权访问。在后端接口中必须校验当前登录用户是否有权限访问该batchId下的数据。6. 常见问题排查与系统扩展思考在实际部署和使用中你可能会遇到以下问题问题一上传大文件超过100MB时服务报错或超时。排查检查 Spring Boot 的multipart.max-file-size和multipart.max-request-size配置是否足够。默认值通常很小如1MB。解决在application.yml中增加配置spring: servlet: multipart: max-file-size: 500MB max-request-size: 500MB同时考虑前端采用分片上传后端流式接收并保存避免一次性占用过多内存。问题二比对大量作业时服务内存溢出OOM。排查一次性将整个批次的所有代码内容加载到内存中进行计算。解决采用流式处理。解压一个学生作业立即进行预处理和 SimHash 计算计算完成后就可以释放这份代码的内存。只将最终的 SimHash 指纹和作业ID保存在内存中进行比对。将整个比对任务拆分成更小的子任务。问题三SimHash 算法对于调换代码顺序、增加无关语句的抄袭检测效果不佳。分析这是 SimHash 基于词袋模型的局限性。调换顺序不影响词频所以 SimHash 变化不大这是优点也是缺点。增加大量无关语句会稀释特征。扩展思路可以引入更复杂的算法作为补充或替代。例如基于AST的算法将代码解析成抽象语法树比较树的结构相似度。这能更好地捕捉代码的逻辑结构对顺序调换不敏感但对语法错误容忍度低。基于令牌序列的算法如Winnowing提取代码的令牌流使用滑动窗口生成特征哈希然后比对这些哈希集合的重合度。这对代码重排有一定鲁棒性。基于深度学习的代码表征使用像 CodeBERT 这样的预训练模型将代码片段编码为一个向量通过计算向量余弦相似度来判断相似性。这是目前最前沿、效果可能最好的方法但需要一定的机器学习知识和计算资源。问题四系统如何支持多种编程语言现状当前实现的语言无关预处理去注释、去空格对大多数语言通用但分词策略可能对某些语言如 Python 依赖缩进不友好。扩展设计可以设计一个“语言处理器”插件接口。定义一个LanguageProcessor接口包含preprocess(String code)、tokenize(String code)等方法。为每种支持的编程语言Java、Python、C提供一个实现类。在系统配置中教师上传作业时可以指定或由系统自动检测编程语言然后调用对应的处理器。这样系统的核心比对流程不变只是前置处理环节可插拔。这个基于 Spring Boot 和 MyBatis Plus 的代码作业查重系统从一个具体的痛点出发串联起了文件处理、算法应用、异步任务、数据持久化和前后端交互等多个后端开发核心技能点。源码的价值不仅在于它可以直接运行更在于它展示了一个完整业务系统的架构方法和实现细节。你可以基于它进行二次开发比如集成更强大的算法、优化性能、美化前端让它更适合你自己的应用场景。本文还有配套的精品资源点击获取