软件工程实践第二次作业

项目内容
这个作业属于哪个课程福州大学软件工程实践2022年春-F班
这个作业要求在哪里软件工程实践第二次作业——个人实战
这个作业的目标爬取数据、实现一个命令行程序
其他参考文献CSDN 博客园

1、GitCode项目地址

personalproject-java

2、PSP表格

PSPPersonal Software Process Stages预估耗时(分钟)实际耗时(分钟)
Planning计划
• Estimate• 估计这个任务需要多少时间695910
Development开发
• Analysis• 需求分析 (包括学习新技术)6075
• Design Spec• 生成设计文档3045
• Design Review• 设计复审2025
• Coding Standard• 代码规范 (为目前的开发制定合适的规范)1525
• Design• 具体设计6075
• Coding• 具体编码200220
• Code Review• 代码复审3045
• Test• 测试(自我测试,修改代码,提交修改)150260
Reporting报告
• Test Repor• 测试报告80100
• Size Measurement• 计算工作量2020
• Postmortem & Process Improvement Plan• 事后总结, 并提出过程改进计划3040
合计695910

3、解题思路描述

爬取数据

对于爬虫这一概念,仅仅只是听说过名词而已,没有真正实现过。而第一次要实现数据爬取,遇到了不小的麻烦。通过去官网上看js代码、抓包,找到了奖牌数据和赛程数据的接口,发现奖牌的JSON数据较为合理,但是赛程的JSON数据存在许多不需要用到的字段,还有unicode编码,我是真不会操作。所以尝试自己爬取数据(有点笨)。

本次通过Jsoup来实现爬取数据,但是Jsoup仅仅只能爬取静态网页的数据,在JS还未加载完成时,就进行数据爬取,存在丢失数据的可能。因此,使用htmlunit(WebClient),模拟一个浏览器客户端,设置JS动态加载开启。然后使用HtmlPage类接受该网页。使用Jsoup进行清洗数据,操作DOM树,得到我们需要的内容。最后导出文件即可。

但是在提取数据的过程中,出现了一些麻烦,提取出来的有用数据被整合在一整条字符串中,需要进行切片与分类合并。在数据字符串中,每个数据都由空格分开,所以我先把冗余部分移除,再使用split()方法,分离每段数据,整合进自定义的实体中,然后将实体对象转化成JSON数据。

实现控制台程序

控制台程序的实现思路较为清晰,通过命令行接收输入输出文件路径,读取指令后,从JSON文件中提取出对应数据,将每条指令的结果打印出来即可。

4、接口设计和实现过程

在整个程序的结构中包含以下几种类:

  • 实体类:作为JSON数据的载体,供其他类调用。
  • 工具类:
    • 验证类:包含了整个程序中对各类数据的验证的方法。
    • 文件类:包含了data文件夹的路径,以及与文件读取有关的方法。
  • 核心类:对外提供接口,需要调用工具类中的方法。

4.1 实体类设计

分别为奖牌榜输出和赛程输出创建实体类MedalScheduleMedal类主要包含rankcountryNamecountryIdgoldsilverbronzetotal,7个字段;Schedule类主要包含timesportnamevenuehomeNameawayName,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:检查指令是否属于total
  • isSchedule:检查指令是否属于schedule
  • getValidDate:获取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秒)
image-20220226211943551

不使用缓存、仅一次开启关闭输出流

平均耗时:358463ms(5分58秒)
image-20220226202729783

使用缓存、一次开启关闭输出流

改进后平均耗时:19656ms(19秒)

image-20220226233245599

7、单元测试

7.1 覆盖率测试

未执行到的代码行,主要是文件的异常处理,比如路径问题和文件类型问题,通过单元测试无法进行处理。
image-20220227220748869

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次数。
  • 单元测试写的比较懵,可能不太合理,如果有个范例就好了。
  • 写博客和其他文档的时间比打代码还久,我真的接受不了啊。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值