项目 | 内容 |
---|---|
这个作业属于哪个课程 | 福州大学软件工程实践2022年春-F班 |
这个作业要求在哪里 | 软件工程实践第二次作业——个人实战 |
这个作业的目标 | 爬取数据、实现一个命令行程序 |
其他参考文献 | CSDN 博客园 |
文章目录
1、GitCode项目地址
2、PSP表格
PSP | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
• Estimate | • 估计这个任务需要多少时间 | 695 | 910 |
Development | 开发 | ||
• Analysis | • 需求分析 (包括学习新技术) | 60 | 75 |
• Design Spec | • 生成设计文档 | 30 | 45 |
• Design Review | • 设计复审 | 20 | 25 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 15 | 25 |
• Design | • 具体设计 | 60 | 75 |
• Coding | • 具体编码 | 200 | 220 |
• Code Review | • 代码复审 | 30 | 45 |
• Test | • 测试(自我测试,修改代码,提交修改) | 150 | 260 |
Reporting | 报告 | ||
• Test Repor | • 测试报告 | 80 | 100 |
• Size Measurement | • 计算工作量 | 20 | 20 |
• Postmortem & Process Improvement Plan | • 事后总结, 并提出过程改进计划 | 30 | 40 |
合计 | 695 | 910 |
3、解题思路描述
爬取数据
对于爬虫这一概念,仅仅只是听说过名词而已,没有真正实现过。而第一次要实现数据爬取,遇到了不小的麻烦。通过去官网上看js代码、抓包,找到了奖牌数据和赛程数据的接口,发现奖牌的JSON数据较为合理,但是赛程的JSON数据存在许多不需要用到的字段,还有unicode编码,我是真不会操作。所以尝试自己爬取数据(有点笨)。
本次通过Jsoup
来实现爬取数据,但是Jsoup
仅仅只能爬取静态网页的数据,在JS还未加载完成时,就进行数据爬取,存在丢失数据的可能。因此,使用htmlunit(WebClient)
,模拟一个浏览器客户端,设置JS动态加载开启。然后使用HtmlPage
类接受该网页。使用Jsoup
进行清洗数据,操作DOM树,得到我们需要的内容。最后导出文件即可。
但是在提取数据的过程中,出现了一些麻烦,提取出来的有用数据被整合在一整条字符串中,需要进行切片与分类合并。在数据字符串中,每个数据都由空格分开,所以我先把冗余部分移除,再使用split()
方法,分离每段数据,整合进自定义的实体中,然后将实体对象转化成JSON数据。
实现控制台程序
控制台程序的实现思路较为清晰,通过命令行接收输入输出文件路径,读取指令后,从JSON文件中提取出对应数据,将每条指令的结果打印出来即可。
4、接口设计和实现过程
在整个程序的结构中包含以下几种类:
- 实体类:作为JSON数据的载体,供其他类调用。
- 工具类:
- 验证类:包含了整个程序中对各类数据的验证的方法。
- 文件类:包含了data文件夹的路径,以及与文件读取有关的方法。
- 核心类:对外提供接口,需要调用工具类中的方法。
4.1 实体类设计
分别为奖牌榜输出和赛程输出创建实体类Medal
和Schedule
,Medal
类主要包含rank
、countryName
、countryId
、gold
、silver
、bronze
、total
,7个字段;Schedule
类主要包含time
、sport
、name
、venue
、homeName
、awayName
,6个字段。
实体类用于接收JSON文件的数据,同时在toString
方法中,实现作业要求所示的格式。
4.2 工具类设计
4.2.1 文件工具类
为文件的读取创建文件工具类FileUtil
,其中包括以下三种方法:
readFile
:用于普通文件的读取,将文件内容以字符串的形式返回readMedalFile
:用于奖牌榜JSON文件的读取readScheduleFile
:用于赛程JSON文件读取
当data文件夹与其他的类在同一文件夹下时,使用Class.getResourceAsStream
获取资源,路径常量定义如下:
private static final String TOTAL_PATH = "data/total.json";
private static final String SCHEDULE_PATH = "data/schedule/";
4.2.2 验证工具类
为程序运行的过程中所需要的验证创建验证工具类ValidateUtil
,其中包括:
checkArgs
:检查命令行传来的文件参数是否正确isTotal
:检查指令是否属于totalisSchedule
:检查指令是否属于schedulegetValidDate
:获取schedule指令后面的有效日期
4.3 核心类设计
核心类Core
为整个程序对外提供数据的类,其中包括:
result
:根据指令参数,返回对应的数据字符串(对外的主接口)getMedalTotal
:返回奖牌榜数据字符串(供result
使用)getSchedule
:根据日期参数,返回对应日期的赛程字符串(供result
使用)
5、关键代码展示
Core.java
public class Core {
private static final String NA_MESSAGE = "N/A";
private static final String ERROR_MESSAGE = "Error";
private static final String EMPTY_LINE = "";
private static final String TOTAL = "total";
static HashMap<String, String> cacheData = new HashMap<>();
/**
* 输出指令对应的结果
*/
public static String result(String instruction) {
// 判断空行
if (EMPTY_LINE.equals(instruction)) {
return EMPTY_LINE;
} else if (ValidateUtil.isTotal(instruction)) {
return getMedalTotal();
} else if (ValidateUtil.isSchedule(instruction)) {
// 获取 schedule 指令中的日期
String date = ValidateUtil.getValidDate(instruction);
// 日期验证并写入
if (NA_MESSAGE.equals(date)) {
return NA_MESSAGE + "\n-----\n";
} else {
return getSchedule(date);
}
} else {
return ERROR_MESSAGE + "\n-----\n";
}
}
/**
* 获取奖牌榜字符串
*/
public static String getMedalTotal() {
if (cacheData.containsKey(TOTAL)) {
return cacheData.get(TOTAL);
}
String medalFile = FileUtil.readMedalFile();
List<Medal> medals = JSONArray.parseArray(medalFile, Medal.class);
StringBuilder str = new StringBuilder();
for (Medal medal : medals) {
str.append(medal.toString());
}
cacheData.put(TOTAL, str.toString());
return str.toString();
}
/**
* 获取对应日期赛程字符串
*/
public static String getSchedule(String date) {
if (cacheData.containsKey(date)) {
return cacheData.get(date);
}
String scheduleFile = FileUtil.readScheduleFile(date);
List<Schedule> schedules = JSONArray.parseArray(scheduleFile, Schedule.class);
StringBuilder str = new StringBuilder();
for (Schedule schedule : schedules) {
str.append(schedule.toString());
}
cacheData.put(date, str.toString());
return str.toString();
}
}
OlympicSearch.java
public class OlympicSearch {
public static void main(String[] args) {
// 文件参数检验
if (!ValidateUtil.checkArgs(args)) {
System.exit(0);
}
// 获取指令列表
String[] instructions = FileUtil.readFile(args[0]).split("\n");
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(args[1]), StandardCharsets.UTF_8))) {
// 遍历指令列表
for (String instruction : instructions) {
// 写入指令执行结果
writer.write(Core.result(instruction));
}
} catch (IOException e) {
System.out.println("文件导出失败!");
}
}
}
6、性能改进
对于性能,整个程序能够想到改进的点应该是只有IO了,因此分别在文件的数据读取和写入进行优化。
一开始想着直接把JSON数据直接复制进代码中,这样的话不用读文件,只需要写文件,而且也不用在意data文件夹的路径问题。
但是这样做的话,看起来好长,最后还是放弃了,虽然说这样做的性能是最强的。
6.1 数据读取优化
原始的方法是:读取一条指令后就找到对应的JSON文件进行读取。但是发现,当指令条数过多时,将会频繁开启关闭输入流。
public static String getSchedule(String date) {
String scheduleFile = FileUtil.readScheduleFile(date);
List<Schedule> schedules = JSONArray.parseArray(scheduleFile, Schedule.class);
StringBuilder str = new StringBuilder();
for (Schedule schedule : schedules) {
str.append(schedule.toString());
}
return str.toString();
}
因此,使用缓存,在Core
类中,定义一个Map,用来临时存储奖牌赛程数据。
当Map中存在key( “total” 或者 对应日期字符串时),返回对应数据,否则读取文件,获得数据,存入Map后返回。
public static String getSchedule(String date) {
// 若缓存内有数据直接返回
if (cacheData.containsKey(date)) {
return cacheData.get(date);
}
String scheduleFile = FileUtil.readScheduleFile(date);
List<Schedule> schedules = JSONArray.parseArray(scheduleFile, Schedule.class);
StringBuilder str = new StringBuilder();
for (Schedule schedule : schedules) {
str.append(schedule.toString());
}
// 数据缓存
cacheData.put(date, str.toString());
return str.toString();
}
这样做,将大幅减少文件读取次数,最多读取20次文件。
6.2 数据写入优化
一开始的想法是:封装文件写入的方法后,遍历指令列表,每次读取一条指令,同时调用写入文件的方法。
for (String instruction : instructions) {
// 写入指令执行结果
FileUtil.writeFile(Core.result(instruction));
}
但是这样做的话存在同一个问题:当指令条数过多时,会频繁地开启关闭输出流,导致性能下降。
后来换了一种方法:对于指令结果的写入时,把所有指令的结果,全部拼接起来,再一起写入输出文件。
StringBuilder results = new StringBuilder();
// 遍历指令列表
for (String instruction : instructions) {
// 拼接指令执行结果
results.append(Core.result(instruction));
}
// 写入文件
new FileWriter(fileName).write(results.toString());
但是这样做也存在一个问题:当指令条数过多时,拼接结果字符串内容过多,导致内存溢出,程序崩溃。
最后就想到了个办法:放弃封装写入文件方法,仅一次开启关闭输出流,直接遍历指令列表,写入指令结果。
try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
new FileOutputStream(fileName), StandardCharsets.UTF_8))) {
// 遍历指令列表
for (String instruction : instructions) {
// 写入指令执行结果
writer.write(Core.result(instruction));
}
} catch (IOException e) {
System.out.println("文件导出失败!");
}
6.3 性能分析
使用十万的 total 和 schedule(0202-0220,19天)数据,共计两百万条指令进行测试。输出文件大小7.14GB
不使用缓存、频繁开启关闭输出流
平均耗时:766532ms(12分46秒)
不使用缓存、仅一次开启关闭输出流
平均耗时:358463ms(5分58秒)
使用缓存、一次开启关闭输出流
改进后平均耗时:19656ms(19秒)
7、单元测试
7.1 覆盖率测试
未执行到的代码行,主要是文件的异常处理,比如路径问题和文件类型问题,通过单元测试无法进行处理。
7.2 测试代码
@Test
public void resultTest() {
String error = "Error\n-----\n";
String na = "N/A\n-----\n";
String total = Core.getMedalTotal();
Assert.assertEquals(total, Core.result("total"));
Assert.assertEquals(Core.getSchedule("0202"), Core.result("schedule 0202"));
Assert.assertEquals(error, Core.result("total 0202"));
Assert.assertEquals(na, Core.result("schedule 0000"));
Assert.assertEquals(na, Core.result("schedule xxxx"));
Assert.assertEquals(error, Core.result("xxxx"));
Assert.assertEquals(error, Core.result("ToTal"));
Assert.assertEquals(na, Core.result(" schedule 3333 "));
Assert.assertEquals(error, Core.result(" scHEdule 0215 "));
Assert.assertEquals(error, Core.result("SCHEDUle 0230"));
Assert.assertEquals(error, Core.result("schEdUlE 0215 0202 "));
}
8、异常处理
8.1 命令行文件参数
对于命令行所传递的文件参数存在以下几种异常情况:
- 参数个数不等于两个
- 输入文件不存在
- 输入、输出文件不属于文本文件
异常处理:
-
第一点异常:对参数数组args进行判断,如果args.length不等于2,给出提示“命令行参数错误”。
-
第二点异常:在使用FileReader打开文件时,会抛出异常。捕捉异常即可,并给出提示“文件打开失败!请检查路径是否正确。”
-
第三点异常:对文件路径字符串使用
endsWith
方法,判断后缀是否为.txt
或.dat
,否则提示“输入/输出文件后缀有误或缺失!”
8.2 指令参数
对指令参数进行了一些容错性处理:
- 单条指令前后可出现若干个空格
- schedule与日期之间可出现多个空格
存在的异常情况:
- 指令不为 total 或者 schedule:返回Error
- schedule日期错误或不在范围:返回N/A
- 一行存在多条指令:返回Error
- 大小写不一致:返回Error
9、心得体会
- 在进行接口设计时,要根据不同的功能,来设计不同的类。
- 在进行IO相关的操作时,合理利用缓存进行优化,并尽量减少IO次数。
- 单元测试写的比较懵,可能不太合理,如果有个范例就好了。
- 写博客和其他文档的时间比打代码还久,我真的接受不了啊。