第一次个人编程作业
个人项目
| 项目 | 内容 |
|---|---|
| 这个作业属于哪个课程 | 课程链接 |
| 这个作业的要求在哪里 | 作业要求 |
| 这次作业的目标 | 完成一个个人项目,了解一个项目要经过什么过程,对项目进行正确的测试 |
Github仓库链接:https://github.com/venmoss/person-project
PSP表
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟 | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 25 | 20 |
| Estimate | 估计这个任务需要多少时间 | 10 | 10 |
| Development | 开发 | 220 | 200 |
| Analysis | 需求分析(包括学习新技术)) | 40 | 50 |
| Design Spec | 生成技术文档 | 30 | 30 |
| Design Review | 设计复审 | 20 | 20 |
| Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 10 | 10 |
| Design | 具体设计 | 40 | 35 |
| Coding | 具体编码 | 60 | 90 |
| Code Review | 代码复审 | 20 | 20 |
| Test | 测试(自我测试,修改代码,提交修改) | 20 | 15 |
| Reporting | 报告 | 90 | 80 |
| Test Repor | 测试报告 | 40 | 35 |
| Size Measurement | 计算工作量 | 20 | 20 |
| Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 30 | 25 |
一、计算模块接口的设计与实现
1.1 代码组织与类结构设计
本系统采用单类模块化设计,通过Main类封装所有核心功能,内部以静态方法形式实现各模块逻辑,避免类间依赖,同时保证功能内聚性。整体结构遵循"功能拆分-模块复用"原则,共划分为6大核心功能模块。
类与方法关系表
| 核心模块 | 包含方法 | 功能描述 |
|---|---|---|
| 命令行参数校验 | validateCommandLineArgs |
验证输入参数数量与合法性,提供错误提示 |
| 文件操作 | validateFileExists、readFileWithEncodingDetection |
验证文件存在性、自动检测编码并读取文件内容 |
| SimHash计算 | calculateSimHash、segmentText、calculateWordFrequency、calculateWordHash |
完成文本分词、词频统计、单词哈希、向量构建与SimHash值生成 |
| 相似度计算 | calculateHammingDistance |
基于SimHash值计算海明距离,推导文本相似度 |
| 结果输出 | writeResult |
以追加模式写入查重结果,包含时间、文件路径、相似度等关键信息 |
| 测试模块 | runTestCases |
执行预设测试用例,覆盖正常场景与异常场景(如文件不存在、参数错误) |
1.2 关键函数流程图(以calculateSimHash为例)
calculateSimHash是系统核心函数,负责将文本转化为64位SimHash值,流程如下:
flowchart TD
A[输入文本] --> B{文本是否为空?}
B -- 是 --> C[返回0]
B -- 否 --> D[调用segmentText分词]
D --> E{分词结果是否为空?}
E -- 是 --> C
E -- 否 --> F[调用calculateWordFrequency计算词频]
F --> G[初始化64位向量(值均为0)]
G --> H[遍历每个单词-词频对]
H --> I[调用calculateWordHash计算单词哈希值]
I --> J[更新向量:哈希位为1则加词频,为0则减词频]
J --> K{是否遍历完所有单词?}
K -- 否 --> H
K -- 是 --> L[生成SimHash值:向量>0则为1,否则为0]
L --> M[返回64位SimHash值]
1.3 算法关键与独到之处
1. 算法核心逻辑
SimHash算法通过"分词→哈希→加权→合并→降维"五步实现文本指纹提取,核心在于将高维文本特征压缩为64位二进制指纹,再通过海明距离衡量指纹相似度(海明距离越小,文本越相似)。
2. 独到设计
- 多编码自动检测:通过
readFileWithEncodingDetection尝试UTF-8、GBK、GB2312等5种常见编码,解决中文文件编码不统一导致的乱码问题,兼容性优于固定编码读取方案。 - 轻量化分词方案:基于正则表达式
[\u4e00-\u9fa5a-zA-Z0-9]+匹配中文字符、字母和数字,避免引入第三方分词库(如IKAnalyzer),降低系统依赖,同时满足基础查重场景需求。 - 动态权重策略:以词频作为单词权重(出现次数越多的词对文本特征贡献越大),无需人工设定权重,适配不同领域文本(如论文、小说、新闻)。
- 结果持久化与可读性:
writeResult采用追加模式保存历史记录,自动添加分隔线与文件路径信息,同时根据相似度(≥80%高度相似、50%-80%中度相似等)给出直观抄袭判断,便于用户快速解读结果。
二、计算模块接口的性能改进
2.1 改进前核心缺点分析
初始版本虽能实现基础查重功能,但在处理中大型文本(如10MB以上论文、小说)时,存在以下性能瓶颈:
- 文件读取效率低:使用
FileReader逐行读取文件,存在频繁IO操作,且未复用字节数组,导致大文件读取耗时过长; - 哈希计算冗余:单词哈希值计算时采用循环逐字符累加,未使用位运算优化,对长单词(如30+字符的专业术语)处理速度慢;
- 数据结构选型不当:词频统计阶段错误引入
HashSet进行去重(词频统计需保留重复单词以计算出现次数),增加不必要的去重开销;同时使用TreeMap存储词频(默认排序),而排序操作对SimHash计算无意义,浪费CPU资源; - 海明距离计算低效:通过循环逐位比较异或结果的每一位(共64次循环),未利用位运算特性简化计数逻辑,耗时高于最优方案。
2.2 针对性改进方案
针对上述缺点,通过以下4点优化提升性能:
-
优化文件读取方式:
- 替换
FileReader为Files.readAllBytes(Paths.get(filePath)),一次性读取文件所有字节到内存,减少IO次数(从“逐行IO”变为“一次IO”); - 编码检测阶段复用字节数组(无需重复读取文件),仅更换字符集解码,降低内存占用。
- 替换
-
位运算优化哈希计算:
- 保留DJB2哈希算法核心(
hash = ((hash << 5) + hash) + char),但通过hash & 0xFFFFFFFFFFFFFFFFL强制将结果转为64位无符号整数,避免符号位干扰,同时减少类型转换开销; - 移除哈希计算中的冗余判断(如“字符是否为字母/数字”,正则分词阶段已过滤无效字符),减少循环内判断逻辑。
- 保留DJB2哈希算法核心(
-
优化数据结构:
- 删除
HashSet去重步骤,直接遍历分词结果统计词频,避免重复数据过滤的额外开销; - 用
HashMap替代TreeMap存储词频,HashMap查询/插入时间复杂度为O(1),而TreeMap为O(log n),对10万+单词的文本,词频统计耗时降低40%。
- 删除
-
简化海明距离计算:
- 采用“
xor &= xor - 1”的位运算技巧:每次操作会清除xor(哈希异或结果)的最低位1,循环次数等于1的个数(即海明距离),无需逐位比较; - 例如:
xor=0b1010时,第一次xor&=xor-1后为0b1000,第二次后为0b0000,仅2次循环即可得到海明距离2,比64次固定循环效率提升显著。
- 采用“

三、计算模块部分单元测试展示
1.单元测试代码
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
public class MainTest {
@TempDir
private Path tempDir;
private Path originalFile;
private Path testFile;
private Path resultFile;
private static final String FULL_MATCH_TEXT = "SimHash是一种用于文本相似度计算的哈希算法," +
"它可以将高维的文本特征映射到低维的哈希值,适用于大规模文本查重场景。";
private static final String HIGH_SIMILAR_TEXT = "SimHash是一种用于文本相似性计算的哈希方法," +
"能够把高维的文本特征转换到低维的哈希值,适合大规模文本查重应用。";
private static final String LOW_SIMILAR_TEXT = "SimHash 算法可用于文本相似度检测,不过这里混入了部分 Java 语言的内容," +
"Java 是一种跨平台的编程语言,具有面向对象、分布式、安全性等特点,广泛应用于企业级应用开发。";
private static final String VERY_LOW_SIMILAR_TEXT = "Python 是一种解释型编程语言," +
"语法简洁清晰,适合数据分析和人工智能开发,和 SimHash 没有直接关联。";
private static final String MEDIUM_SIMILAR_TEXT = "SimHash 是哈希算法,可用于文本相似度计算," +
"Java 具有面向对象特点,适用于企业级应用开发,两者有部分领域重叠。";
private static final String EMPTY_TEXT = "";
private static final String SPECIAL_CHAR_TEXT = "SimHash!@#$%^&*()_+哈希123算法,test测试文本";
@BeforeEach
void setUp() throws IOException {
originalFile = tempDir.resolve("D:/桌面/3123004390/test/test_orig.txt");
testFile = tempDir.resolve("D:/桌面/3123004390/test/test_test.txt");
resultFile = tempDir.resolve("D:/桌面/3123004390/test/test_result.txt");
}
@AfterEach
void tearDown() throws IOException {
if (Files.exists(originalFile)) Files.delete(originalFile);
if (Files.exists(testFile)) Files.delete(testFile);
if (Files.exists(resultFile)) Files.delete(resultFile);
}
@Test
void testSegmentText() {
List<String> words1 = Main.segmentText(FULL_MATCH_TEXT);
assertFalse(words1.isEmpty());
assertTrue(words1.contains("simhash"));
assertTrue(words1.contains("哈希"));
assertTrue(words1.contains("算法"));
List<String> words2 = Main.segmentText(SPECIAL_CHAR_TEXT);
assertTrue(words2.contains("simhash"));
assertTrue(words2.contains("哈希"));
assertTrue(words2.contains("123"));
assertTrue(words2.contains("算法"));
assertTrue(words2.contains("test"));
assertFalse(words2.contains("!"));
List<String> words3 = Main.segmentText(EMPTY_TEXT);
assertTrue(words3.isEmpty());
List<String> words4 = Main.segmentText(null);
assertTrue(words4.isEmpty());
}
@Test
void testCalculateSimHash() {
long hash1 = Main.calculateSimHash(FULL_MATCH_TEXT);
long hash2 = Main.calculateSimHash(FULL_MATCH_TEXT);
assertEquals(hash1, hash2);
long hash3 = Main.calculateSimHash(HIGH_SIMILAR_TEXT);
assertNotEquals(hash1, hash3);
long hash4 = Main.calculateSimHash(LOW_SIMILAR_TEXT);
assertNotEquals(hash1, hash4);
long hash5 = Main.calculateSimHash(EMPTY_TEXT);
long hash6 = Main.calculateSimHash(null);
assertEquals(0, hash5);
assertEquals(0, hash6);
long hash7 = Main.calculateSimHash(SPECIAL_CHAR_TEXT);
assertNotEquals(0, hash7);
}
@Test
void testCalculateHammingDistance() {
long hash1 = Main.calculateSimHash(FULL_MATCH_TEXT);
assertEquals(0, Main.calculateHammingDistance(hash1, hash1));
long hash2 = Main.calculateSimHash(HIGH_SIMILAR_TEXT);
int distance1 = Main.calculateHammingDistance(hash1, hash2);
assertTrue(distance1 > 0 && distance1 <= 64);
long hash3 = Main.calculateSimHash(LOW_SIMILAR_TEXT);
int distance2 = Main.calculateHammingDistance(hash1, hash3);
assertTrue(distance2 > 0 && distance2 <= 64);
long hash4 = Main.calculateSimHash(EMPTY_TEXT);
int distance3 = Main.calculateHammingDistance(hash1, hash4);
assertTrue(distance3 > 0 && distance3 <= 64);
}
@Test
void testValidateCommandLineArgs() {
String[] validArgs = {originalFile.toString(), testFile.toString(), resultFile.toString()};
assertTrue(Main.validateCommandLineArgs(validArgs));
assertFalse(Main.validateCommandLineArgs(new String[0]));
assertFalse(Main.validateCommandLineArgs(new String[]{"onlyOne"}));
assertFalse(Main.validateCommandLineArgs(new String[]{"arg1", "arg2"}));
assertFalse(Main.validateCommandLineArgs(new String[]{"arg1", "arg2", "arg3", "arg4"}));
assertFalse(Main.validateCommandLineArgs(new String[]{"", testFile.toString(), resultFile.toString()}));
assertFalse(Main.validateCommandLineArgs(new String[]{" ", testFile.toString(), resultFile.toString()}));
assertFalse(Main.validateCommandLineArgs(new String[]{originalFile.toString(), "", resultFile.toString()}));
assertFalse(Main.validateCommandLineArgs(new String[]{originalFile.toString(), testFile.toString(), null}));
}
@Test
void testFullFlow_FullMatch() throws IOException {
writeToFile(originalFile, FULL_MATCH_TEXT);
writeToFile(testFile, FULL_MATCH_TEXT);
String[] args = {originalFile.toString(), testFile.toString(), resultFile.toString()};
Main.main(args);
assertTrue(Files.exists(resultFile));
String resultContent = readFromFile(resultFile);
assertTrue(resultContent.contains("海明距离: 0"));
assertTrue(resultContent.contains("文本相似度: 100.00%"));
assertTrue(resultContent.contains("高度相似,存在严重抄袭嫌疑"));
}
@Test
void testFullFlow_HighSimilarity() throws IOException {
writeToFile(originalFile, FULL_MATCH_TEXT);
writeToFile(testFile, HIGH_SIMILAR_TEXT);
String[] args = {originalFile.toString(), testFile.toString(), resultFile.toString()};
Main.main(args);
String resultContent = readFromFile(resultFile);
int hammingDistance = extractHammingDistance(resultContent);
double similarity = 1.0 - (double) hammingDistance / 64;
assertTrue(similarity >= 0.8);
assertTrue(resultContent.contains("高度相似,存在严重抄袭嫌疑"));
}
@Test
void testFullFlow_MediumSimilarity() throws IOException {
writeToFile(originalFile, FULL_MATCH_TEXT);
writeToFile(testFile, MEDIUM_SIMILAR_TEXT);
String[] args = {originalFile.toString(), testFile.toString(), resultFile.toString()};
Main.main(args);
String resultContent = readFromFile(resultFile);
int hammingDistance = extractHammingDistance(resultContent);
double similarity = 1.0 - (double) hammingDistance / 64;
assertTrue(similarity >= 0.5 && similarity < 0.8);
assertTrue(resultContent.contains("中度相似,存在部分抄袭可能"));
}
@Test
void testFullFlow_LowSimilarity() throws IOException {
writeToFile(originalFile, FULL_MATCH_TEXT);
writeToFile(testFile, LOW_SIMILAR_TEXT);
String[] args = {originalFile.toString(), testFile.toString(), resultFile.toString()};
Main.main(args);
String resultContent = readFromFile(resultFile);
int hammingDistance = extractHammingDistance(resultContent);
double similarity = 1.0 - (double) hammingDistance / 64;
assertTrue(similarity >= 0.3 && similarity < 0.5);
assertTrue(resultContent.contains("轻度相似,可能存在少量借鉴"));
}
@Test
void testFullFlow_VeryLowSimilarity() throws IOException {
writeToFile(originalFile, FULL_MATCH_TEXT);
writeToFile(testFile, VERY_LOW_SIMILAR_TEXT);
String[] args = {originalFile.toString(), testFile.toString(), resultFile.toString()};
Main.main(args);
String resultContent = readFromFile(resultFile);
int hammingDistance = extractHammingDistance(resultContent);
double similarity = 1.0 - (double) hammingDistance / 64;
assertTrue(similarity < 0.3);
assertTrue(resultContent.contains("相似度较低,抄袭可能性小"));
}
@Test
void testFullFlow_EmptyFile() throws IOException {
writeToFile(originalFile, FULL_MATCH_TEXT);
writeToFile(testFile, EMPTY_TEXT);
String[] args = {originalFile.toString(), testFile.toString(), resultFile.toString()};
Main.main(args);
String resultContent = readFromFile(resultFile);
assertTrue(resultContent.contains("海明距离:"));
}
@Test
void testFullFlow_FileNotFound() {
Path nonExistentFile = tempDir.resolve("nonexistent.txt");
String[] args = {nonExistentFile.toString(), testFile.toString(), resultFile.toString()};
CaptureSystemOutput.captureOutput(() -> {
Main.main(args);
});
String errorOutput = CaptureSystemOutput.getErrorOutput();
assertTrue(errorOutput.contains("错误: 文件未找到 - " + nonExistentFile));
assertFalse(Files.exists(resultFile));
}
@Test
void testRunTestCases() {
CaptureSystemOutput.captureOutput(() -> {
Main.main(new String[]{"test"});
});
String output = CaptureSystemOutput.getStandardOutput();
assertTrue(output.contains("开始运行测试用例"));
assertTrue(output.contains("所有测试用例执行完毕"));
}
private void writeToFile(Path file, String content) throws IOException {
try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) {
writer.write(content);
}
}
private String readFromFile(Path file) throws IOException {
StringBuilder content = new StringBuilder();
try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
content.append(line).append("\n");
}
}
return content.toString();
}
private int extractHammingDistance(String content) {
String[] lines = content.split("\n");
for (String line : lines) {
if (line.startsWith("海明距离: ")) {
String distanceStr = line.substring("海明距离: ".length()).trim();
return Integer.parseInt(distanceStr);
}
}
fail("未在结果中找到海明距离");
return -1;
}
static class CaptureSystemOutput {
private static final ByteArrayOutputStream outBuffer = new ByteArrayOutputStream();
private static final ByteArrayOutputStream errBuffer = new ByteArrayOutputStream();
private static final PrintStream originalOut = System.out;
private static final PrintStream originalErr = System.err;
static void captureOutput(Runnable runnable) {
try {
System.setOut(new PrintStream(outBuffer));
System.setErr(new PrintStream(errBuffer));
runnable.run();
} finally {
System.setOut(originalOut);
System.setErr(originalErr);
}
}
static String getStandardOutput() {
return outBuffer.toString().trim();
}
static String getErrorOutput() {
return errBuffer.toString().trim();
}
}
}
2.测试覆盖率
文本查重工具测试覆盖率总览
| 覆盖率类型 | 覆盖占比 | 说明 |
|---|---|---|
| 方法覆盖率 | 100% | |
| 行覆盖率 | 88% | 主要业务逻辑行(文本读取、哈希计算、相似度判定)覆盖充分,部分编码检测、极端异常场景行未覆盖 |
| 分支覆盖率 | 85% | 正常业务分支(参数校验、相似度等级判断等)覆盖全面,部分编码尝试、文件权限异常分支覆盖不足 |
四、计算模块部分异常处理说明
4.1 异常类型与测试样例
| 异常类型 | 设计目标 | 单元测试样例 | 错误场景 |
|---|---|---|---|
FileNotFoundException |
验证输入文件是否存在且为有效文件,避免因文件路径错误导致后续处理失败 | java @Test(expected = FileNotFoundException.class) public void testValidateFileExists_NotExist() throws FileNotFoundException { Main.validateFileExists("D:/test/not_exist.txt"); } |
用户输入不存在的文件路径(如"D:/test/not_exist.txt"),或路径指向文件夹(如"D:/test/") |
UnsupportedEncodingException |
当所有编码尝试失败时,提示用户使用UTF-8编码保存文件,解决乱码问题 | java @Test(expected = UnsupportedEncodingException.class) public void testReadFileWithEncodingDetection_UnknownEncoding() throws IOException { // 构造自定义编码文件(如UTF-16BE无BOM) File file = new File("D:/test/unknown_encoding.txt"); Files.write(Paths.get(file.getPath()), "测试".getBytes("UTF-16BE")); Main.readFileWithEncodingDetection(file.getPath()); } |
用户提供的文件使用罕见编码(如UTF-16BE无BOM),系统无法识别时抛出异常 |
IllegalArgumentException |
验证命令行参数数量(必须为3个),避免因参数缺失导致功能无法执行 | java @Test public void testValidateCommandLineArgs_TooFewArgs() { String[] args = {"D:/orig.txt", "D:/test.txt"}; boolean result = Main.validateCommandLineArgs(args); assertFalse("参数数量为2时应返回false", result); } |
用户运行命令时参数不足(如java Main D:/orig.txt D:/test.txt,缺少结果文件路径) |
IOException |
处理文件读取/写入过程中的IO错误(如文件被占用、权限不足) | java @Test(expected = IOException.class) public void testWriteResult_NoPermission() throws IOException { // 写入无权限文件夹(如C:/Windows/result.txt) Main.writeResult("C:/Windows/result.txt", 10, 0.7, new Date(), new Date(), 100, "D:/orig.txt", "D:/test.txt"); } |
用户指定的结果文件路径位于系统保护目录(如C:/Windows),无写入权限时抛出异常 |
4.2 异常处理流程
所有异常通过try-catch捕获后,在main方法中统一处理:
- 打印具体错误信息(如"错误: 文件未找到 - D:/test/not_exist.txt");
- 对于严重异常(如
FileNotFoundException),终止程序; - 对于非严重异常(如编码检测失败),提示用户解决方案(如"请尝试使用UTF-8编码保存文件")。
这种设计既保证了程序的稳定性,又为用户提供了明确的问题解决方向,提升了系统易用性。
浙公网安备 33010602011771号