这个作业属于哪个课程 | 软件工程-23年春季学期 |
---|---|
这个作业要求在哪里 | 软件工程实践第二次作业—文件读取 |
这个作业的目标 | 实现一个命令行程序、爬取澳大利亚网球公开赛官网的数据【该爬取行为仅用于课程教学】 |
其他参考文献 | CSDN、博客园、《构建之法》 |
1. Gitcode项目地址
项目地址:地址
2. PSP表格
PSP | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
• Estimate | • 估计这个任务需要多少时间 | 15 | 20 |
Development | 开发 | ||
• Analysis | • 需求分析 (包括学习新技术) | 60 | 140 |
• Design Spec | • 生成设计文档 | 30 | 20 |
• Design Review | • 设计复审 | 30 | 20 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 15 | 30 |
• Design | • 具体设计 | 20 | 25 |
• Coding | • 具体编码 | 300 | 330 |
• Code Review | • 代码复审 | 300 | 400 |
• Test | • 测试(自我测试,修改代码,提交修改) | 300 | 500 |
Reporting | 报告 | ||
• Test Repor | • 测试报告 | 30 | 30 |
• Size Measurement | • 计算工作量 | 15 | 15 |
• Postmortem & Process Improvement Plan | • 事后总结, 并提出过程改进计划 | 20 | 40 |
合计 | 1135 | 1570 |
3. 解题思路描述
3.1 Java如何解析Json数据
拿到题目后,我并没有先去爬取官网的Json,想先试试能不能正常解析在群里发的Json数据,于是我查找Java有哪些可以用于解析Json的依赖,发现阿里的FastJSON
这个库上手很快,可以将Json映射到实体类,且可以需要哪些属性就设置哪些属性。
3.2 Core模块要怎么实现
通过搜索class library
、DLL
相关的资料,最终决定采用静态方法的方式来封装输出选手信息和日程信息这两个功能。
3.3 Json结构分析
players的结构较为简单,players是一个player的对象数组,其中每个player对象的full_name、gender和nationality就是我们需要的属性。
schedule的结构由于最初用的是群里发的最早的版本的Json,结构很复杂,是要从courts里拿到teams的信息以及分数,嵌套了很多层,拿到teams后要遍历teams找到对应的选手id,然后再遍历选手的id找到选手的姓名。(但是在之后官网爬取到的Json没有嵌套多层,从matches里直接遍历各场比赛即可)
3.4 官网爬取json
因为完全没有爬取数据的经验,找到官网上F12中Json数据的位置后,采用复制粘贴的方法将Json获取到本地。
4. 接口设计和实现过程
4.1 接口设计
AOSearch.java:调用Validate
验证工具类的validateInput(String[] args)
验证命令行指令格式是否合法,若合法则调用FileUtil
文件工具类的outputAllResult(String inputUrl, String outputUrl)
将结果输出到output文件中。
FileUtil.java:
void outputAllResult(String inputUrl, String outputUrl)
:输出input文件中所有指令的结果到output文件中。String getPlayerInfo()
:返回players.json中所有选手信息的字符串。String getScheduleInfo(String date)
:返回某一天的赛程信息字符串。String readJson(String jsonUrl)
:打开Json文件,将Json文件的内容转为字符串并返回。String getPlayerStr(String url)
:将readJson(String jsonUrl)
返回的字符串解析为对象及对象数组,获取需要的选手信息,并按作业要求格式返回字符串。String getScheduleStr(String url)
:将readJson(String jsonUrl)
返回的字符串解析为对象及对象数组,构造HashMap
获取并存储每一场比赛的id与比赛时间,获胜队伍id等信息,并按作业要求格式返回字符串。void getStatus(Map<String, String> actIdToStatus, List<Matches> matches)
:将每一场比赛的id作为key,每一场比赛的status(abbr)作为value存储到Map
中。void getScoreStr(Map<String, String> actIdToScore)
:将每一场比赛的id作为key,每一场比赛的比分字符串作为value存储到Map
中。void getShortName(Map<String, String> actIdToTeamId, Map<String, String> actIdToShortName, List<Team> teams, List<PlayerInSchedule> players)
:将每一场比赛的id作为key,每一场比赛的Winner的名字缩写作为value存储到Map
中。void getTimeTeamIdScore(List<String> actIds, List<Matches> matches, Map<String, String> actIdToTime, Map<String, String> actIdToTeamId, Map<String, String> actIdToScore)
:将每场比赛的id作为key,每一场比赛的开始时间和获胜队伍的id作为value存储到两个Map中。
Validate.java:boolean validateInput(String[] args)
验证args数组的长度以及输入输出文件是否是同一文件,输入输出文件的后缀是否是txt,并返回验证结果bool值。
Tyt.java等:JavaBean
,用于存储解析Json获取到的对象。
4.2 程序流程
4.3 实现过程
最先实现的是String readJson(String jsonUrl)
这一函数,将Json文件内容转为字符串,在测试命令行运行jar的时候,发现打开input.txt一直会报错找不到这个文件,然后发现input.txt的起始路径会随着jar包的位置的变化而变化(采用的是相对路径查找这个文件),通过搜寻资料,发现要用ClassLoader来解决这个问题,它会将起始路径变成调用ClassLoader函数所在类的位置。
然后实现的是String getPlayerInfo()
,这一函数调用JSON.parseObject(json, Tyt.class);
以及Player的get属性的方法获取选手的信息。
能获取到输出字符串后,开始测试命令行运行jar包是否会报错,先将刚刚获取字符串的函数封装起来,利用FileWriter
和FileReader
先打开input.txt和创建output.txt,再创建BufferedWriter
和BufferedReader
对象来读取input文件中的指令和将字符串输出到output文件中,由于澳网已经结束,因此加入了判断比赛日期是否合法的代码。
在测试完jar包可以正常运行后,schedule的解析Json过程和players类似,直接在一次遍历中拼接输出字符串较为复杂,因此采用HashMap
来存储比赛id和各个要输出的内容,然后再拼接起来。
最后编写的是Validate
验证工具类,主要验证的内容上文有所提及。
5. 关键代码展示
5.1 使用ClassLoader获取Json位置
private static String readJson(String jsonUrl) throws IOException {
InputStream inputStream = FileUtil.class.getClassLoader().getResourceAsStream(jsonUrl);
/*
返回jsonStr
/*
}
5.2 使用FastJson解析players.json(schedule的解析过程类似)
private static String getPlayerStr(String url) throws IOException {
String json = readJson(url);
Tyt tyt = JSON.parseObject(json, Tyt.class);
List<Player> playerList = new ArrayList<>();
//Get playerList from json
if (tyt != null) {
playerList = tyt.getPlayers();
} else {
System.err.println("Get PlayerList Failed!");
}
StringBuilder playerOutput = new StringBuilder();
for (Player player : playerList) {
playerOutput.append("full_name:").append(player.getFullName()).append("\n");
if(player.getGender().equals("M")){
playerOutput.append("gender:").append("male").append("\n");
}else {
playerOutput.append("gender:").append("female").append("\n");
}
playerOutput.append("nationality:").append(player.getNationality().getName()).append("\n");
playerOutput.append("-----").append("\n");
}
return playerOutput.toString();
}
5.3 输出所有结果(判断比赛日期是否合法)
public static void outputAllResult(String inputUrl, String outputUrl) {
FileWriter fileWriter;
BufferedWriter bufferedWriter;
FileReader fileReader = null;
BufferedReader bufferedReader = null;
try {
...
while ((order = bufferedReader.readLine()) != null) {
String[] orderWords = order.split(" ");
if (order.equals("players")) {
bufferedWriter.write(FileUtil.getPlayerInfo());
bufferedWriter.flush();
} else if (orderWords.length == 2 && orderWords[0].equals("result") && orderWords[1].charAt(0) == '0' && Integer.parseInt(orderWords[1].substring(1, 4)) >= 116 && Integer.parseInt(orderWords[1].substring(1, 4)) <= 129) {
bufferedWriter.write(FileUtil.getScheduleInfo(orderWords[1]));
bufferedWriter.flush();
} else if (orderWords.length == 2 && orderWords[0].equals("result") && orderWords[1].charAt(0) == 'Q' && Integer.parseInt(orderWords[1].substring(1, 2)) >= 1 && Integer.parseInt(orderWords[1].substring(1, 2)) <= 4) {
bufferedWriter.write(FileUtil.getScheduleInfo(orderWords[1]));
bufferedWriter.flush();
} else if (orderWords[0].equals("result")) {
bufferedWriter.write("N/A\n-----\n");
bufferedWriter.flush();
} else if (order.equals("")) {
continue;
} else {
bufferedWriter.write("Error\n-----\n");
bufferedWriter.flush();
System.err.println("Order is incorrect!");
}
}
} catch (FileNotFoundException e) {
System.err.println("File not found!" + " " + inputUrl + " " + outputUrl);
} catch (IOException e) {
System.err.println("IO error!");
} finally {
try {
if (bufferedReader != null) {
bufferedReader.close();
}
if (fileReader != null) {
fileReader.close();
}
} catch (IOException e) {
System.err.println("BufferedReader/Writer & FileReader/Writer Close Error");
}
}
}
5.4 HashMap存储比赛id和赛程信息(以获取比赛的状态abbr为例)
private static void getStatus(Map<String, String> actIdToStatus, List<Matches> matches) {
for (Matches match : matches) {
actIdToStatus.put(match.getId(), match.getMatch_status().getAbbr());
}
}
5.5 Validate验证工具类
public class Validate {
public static boolean validateInput(String[] args) {
if (args.length!=2){
System.err.println("the length of array args is not 2!");
return false;
}
if (!args[0].endsWith(".txt")) {
System.err.println("Input file extension is wrong");
return false;
}
if (!args[1].endsWith(".txt")) {
System.err.println("Output file extension is wrong");
return false;
}
if (args[0].equals(args[1])) {
System.err.println("Input file is the same as output file!");
return false;
}
return true;
}
}
6. 性能改进
6.1 改进的思路
input内容为1000组输入指令(从players、result 0116到result Q4),测试运行时间以及利用
JProfiler
生成内存使用量、垃圾收集活动、类加载数量、线程个数和状态、CPU 使用率等指标随时间变化的趋势图
可以发现上图中内存的使用量较高,由于input中都是重复的代码,因此想到可以通过将某一指令的运行结果缓存至cache来优化执行速度。
6.2 利用Caffeine缓存机制优化
先看看优化后的效果,同样是1000组
可以发现运行完性能是大幅提升了的,这主要采用的是Caffeine这个基于Java8的高性能缓存库,可提供接近最佳的命中率,每次运行时会先从cache里查找有没有指令对应的输出字符串,比如result 0116的输出字符串,如果有则直接从cache里获取字符串。
6.3 优化的代码(拿players这个指令为例)
if (order.equals("players")) {
if (loadingCache.getIfPresent("players") != null) {
bufferedWriter.write(loadingCache.getIfPresent("players"));
bufferedWriter.flush();
} else {
bufferedWriter.write(FileUtil.getPlayerInfo());
loadingCache.put("players", FileUtil.getPlayerInfo());
bufferedWriter.flush();
}
}
7. 单元测试
7.1 单元测试思路
主要要测试的内容有三个,一个是命令行输入指令的格式是否正确,即参数的数量是否为2,输入输出文件的后缀必须是.txt,输入和输出是否是相同文件;其次是测试input文件中的指令是否合法,即指令如果是乱打的字符串,程序会不会直接报出有效地错误信息,输出文件里是不是会有N/A或Error;最后是测试input文件中指令的输出结果是否正确,主要包含以下几种信息:男选手和女选手的信息(将F和M转换为female和male有用分支语句),以及状态为CMP的比赛、状态为W/O的比赛、状态为RET的比赛以及双打比赛的比赛信息。
7.2 项目部分单元测试代码
单元测试主要测试的函数是AOSearch.java
中的main
函数,前面几行主要测试程序遇到命令行输入指令格式不正确时,会不会正确抛出错误信息,最后一个断言主要是测试input文件中的指令是否合法以及input文件中指令的输出结果是否正确,判断输出结果是否正确采用的是assertEquals
函数来判断实际的输出结果是否符合预期。
public class AOSearchTest {
@org.junit.Test
public void main() throws IOException {
AOSearch.main(new String[]{"input.txt", "input.txt"});
assertFalse(false);
AOSearch.main(new String[]{"in.txt", "input.txt"});
assertFalse(false);
AOSearch.main(new String[]{"input.dat"});
assertFalse(false);
AOSearch.main(new String[]{"input.dat", "output.txt"});
assertFalse(false);
AOSearch.main(new String[]{"input.txt", "output.dat"});
assertFalse(false);
AOSearch.main(new String[]{"input.txt", "input.txt", "x"});
assertFalse(false);
AOSearch.main(new String[]{"input.txt", "output.txt"});
FileReader fileReader = new FileReader("output.txt");
BufferedReader bufferedReader = new BufferedReader(fileReader);
StringBuilder result = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
result.append(line).append("\n");
}
assertEquals("""
Error
-----
Error
-----
time:08:34
winner:I. Swiatek
score:6:4 | 7:5
-----
W/O
-----
time:23:57
winner:D. Svrcina
score:2:6 | 7:6 | 2:0
-----
time:08:49
winner:O. Gadecki & M. Polmans
score:0:6 | 2:6
-----
full_name:Radu Albot
gender:male
nationality:Moldova
-----
full_name:Alicia Barnett
gender:female
nationality:United Kingdom
-----
N/A
-----
N/A
-----
N/A
-----
N/A
-----
""", result.toString());
}
}
7.3 构造测试数据的思路
测试数据是通过将Q4.json的内容替换为一场CMP的比赛、一场状态为W/O的比赛、一场状态为RET的比赛以及一场双打比赛的比赛信息,同时players.json的内容替换为一名男选手的信息以及一名女选手的信息。
7.4 单元测试得到的测试覆盖率截图
input.txt中的内容
7.5 应该如何优化覆盖率?
优化覆盖率我认为应该要测试尽量多的分支情况,例如if-else语句的各种情况输入内容都应该能包括在内。因此既要测试命令行指令合法的情况,又要测试命令行指令不合法的情况,不合法的情况又包括命令行参数的数量不同、命令行参数中文件不存在、文件后缀名不是tx。同时也要测试input文件中的指令的合法情况以及不合法的情况,不合法的情况又包括输出是Error、N/A、正常输出这三种情况。
8. 异常处理
8.1 命令行指令异常
Validate
验证工具类处理参数数量不正确,文件后缀不为.txt以及输入输出文件为同一文件的情况,源码见上文。
8.2 打开关闭输入输出流、读写文件IO异常
- 在FileUtil中用try catch封装代码
try {
fileReader = new FileReader(inputUrl);
bufferedReader = new BufferedReader(fileReader);
fileWriter = new FileWriter(outputUrl);
bufferedWriter = new BufferedWriter(fileWriter);
String order;
while ((order = bufferedReader.readLine()) != null) {
...
}
} catch (FileNotFoundException e) {
System.err.println("File not found!" + " " + inputUrl + " " + outputUrl);
} catch (IOException e) {
System.err.println("IO error!");
} finally {
try {
if (bufferedReader != null) {
bufferedReader.close();
}
if (fileReader != null) {
fileReader.close();
}
} catch (IOException e) {
System.err.println("BufferedReader/Writer & FileReader/Writer Close Error");
}
}
9. 心得体会
- 做这个作业之前没有爬虫的经验,通过这次作业了解到Json应该怎么从网页中获取。
- 在这次作业之前没有用过Jprofiler这样的软件分析程序的性能,今后如果做其他项目也可以用这个工具来看看项目哪里还能继续优化。
- Caffeine这个cache相关的依赖也是第一次使用,发现这个包比较好上手,运行速度也确实能有很大的提升。
- 这次作业做的过程中曾不小心把仓库的所有代码全删了,
也借此掌握了Git远程仓库如何回溯版本。 - 单元测试其实测试用例应该更多一点,白盒测试的覆盖率也让我了解到怎样测试程序才是较为全面的。
- 接口封装如果分装得较好可以使main函数变得十分简洁,同时函数的注释写得规范也可以一眼看出函数的功能,以及输入输出,在今后做其他项目时会保持这个习惯。