这个作业要求在哪里 |
https://edu.cnblogs.com/campus/fzu/2020SpringW/homework/10281 |
这个作业的目标 |
经过完整的设计和开发流程,开发一个疫情统计程序并撰写报告 |
作业正文 |
https://www.cnblogs.com/sillyby/p/12317238.html |
其他参考文献 |
... |
Github仓库地址
作业的主仓库:https://github.com/numb-men/InfectStatistic-main
我的作业仓库:https://github.com/WallofWonder/InfectStatistic-main
个人软件开发流程
Planning |
计划 |
60 |
73 |
Estimate |
估计这个任务需要多少时间 |
60 |
73 |
Development |
开发 |
1600 |
2020 |
Analysis |
需求分析 (包括学习新技术) |
240 |
360 |
Design Spec |
生成设计文档 |
60 |
105 |
Design Review |
设计复审 |
60 |
50 |
Coding Standard |
代码规范 (为目前的开发制定合适的规范) |
120 |
180 |
Design |
具体设计 |
120 |
220 |
Coding |
具体编码 |
600 |
670 |
Code Review |
代码复审 |
100 |
80 |
Test |
测试(自我测试,修改代码,提交修改) |
300 |
355 |
Reporting |
报告 |
140 |
300 |
Test Report |
测试报告 |
120 |
230 |
Size Measurement |
计算工作量 |
20 |
30 |
Postmortem & Process Improvement Plan |
事后总结, 并提出过程改进计划 |
60 |
40 |
|
合计 |
1800 |
2393 |
解题思路
大致浏览了作业要求以及和助教交流后,我首先列出了我不太了解的,这次作业会用到的东西,准备进行知识补充:
JUnit
单元测试组件,前段时间在maven工程中粗浅地使用过,由于这次脱离了maven,而且对单元测试有一定的要求,所以我在网上找了些资料进行学习。
设计模式
说来惭愧,代码敲了快3年,设计模式是真的一点都不了解,作业提到了命令模式、策略模式和责任链模式,就先看看这三个吧。
正则表达式
文本操作的利器,可惜我在这方面的学习属实有些困难,符号太多了记不住语法,这次看看用到什么学什么吧。
要求设计的程序是通过命令行进行文件IO操作,大致流程不难设计:
解析命令
我开始打算寻找 java 有没有现成的库能够满足命令行解析的功能,找来找去只有Apache Commons CLI这个第三方库,然而由于作业要求用原生java,所以只好乖乖手打。
作业需求中,关于命令格式的要求如下:
list命令 支持以下命令行参数:
-log
指定日志目录的位置,该项必会附带,请直接使用传入的路径,而不是自己设置路径
-out
指定输出文件路径和文件名,该项必会附带,请直接使用传入的路径,而不是自己设置路径
-date
指定日期,不设置则默认为所提供日志最新的一天。你需要确保你处理了指定日期之前的所有log文件
-type
可选择[ip: infection patients 感染患者,sp: suspected patients 疑似患者,cure:治愈 ,dead:死亡患者],使用缩写选择,如 -type ip
表示只列出感染患者的情况,-type sp cure
则会按顺序【sp, cure】列出疑似患者和治愈患者的情况,不指定该项默认会列出所有情况。
-province
指定列出的省,如-province 福建
,则只列出福建,-province 全国 浙江
则只会列出全国、浙江
注:java InfectStatistic表示执行主类InfectStatistic,list为命令,-date代表该命令附带的参数,-date后边跟着具体的参数值,如2020-01-22
。-type 的多个参数值会用空格分离,每个命令参数都在上方给出了描述,每个命令都会携带一到多个命令参数
简单总结一下命令格式:
可以发现,有些参数只有一个值,有些参数能有多个值,所以需要将参数分类解析,负责解析的模块解析完成后可以返回一个值或者多个值。
检查命令
命令一定需要符合规范才能被执行。这部分就针对命令格式的要求进行逐个参数的合法性检查,一旦有不符合要求的地方就向入口抛出异常信息终止执行。
对于list
命令各个参数的检查标准:
-log
必须携带该参数;路径合法。
-out
必须携带该参数;路径合法;指定文件不存在则创建,存在则覆盖。
-date
可选参数;若携带则日期格式和数值必须合法。
-type
可选参数;若携带则所有值必须属于ip
、sp
、cure
、dead
。
-province
可不携带该参数;若携带则所有值必须属于国内省份或者全国
。
如果全部参数都符合规范,并不是直接将业务流程推进到”读取日志“的阶段,因为用户输入的命令即使在符合规范的前提下也会五花八门,参数的数量和顺序、值的数量和顺序都有好多种可能,直接使用这些参数会在后期业务逻辑的实现上增加困难,即使成功是实现了需求,代码也可能很臃肿,效率也可能不尽人意。
于是,将用户输入的命令进行标准化是有必要的,所以在检查命令之后,可以将用户命令映射到设计好的命令实体中,在这之后对命令参数的访问就是对这个实体的访问。
以一个例子简单地描述一下映射过程:
可以看出,通过读取日志路径,将-log
的值转为了具体的日志信息,用户没有显式给出-type
的值,在实体中也被赋了缺省值,而 -province
由于和具体的日志有关所以在这里暂不做缺省赋值。
读取日志
读取日志阶段要根据命令的-log
参数定位日志文件,并且根据-date
参数选择性地过滤掉日志文件,同时判断日期是否超出范围。
统计结果
这里的重点是对日志的每一条记录进行解析,判断应该执行什么样的操作。
第一个问题是如何将记录和行为匹配,用正则表达式是个好主意。
记录出现的情况有8种:
该日志中出现以下几种情况:
1、 新增 感染患者 n人
2、 新增 疑似患者 n人
3、 感染患者 流入 n人
4、 疑似患者 流入 n人
5、 死亡 n人
6、 治愈 n人
7、 疑似患者 确诊感染 n人
8、 排除 疑似患者 n人
为了减少工作量,可以将相似情况合并,进而减少到5种:
<省> 新增 <感染患者/疑似患者> n人
<省1> <感染患者/疑似患者> 流入 <省2> n人
<省> <死亡/治愈> n人
<省> 疑似患者 确诊感染 n人
<省> 排除 疑似患者 n人
通过5个正则表达式建立5个入口,其中在情况1、2、3的内部再进行简单的ifelse判断人员种类。
第二个问题是如何将记录定位到与之匹配的行为上。首先想到的是用硕大的控制语句块实现,但是如果情况太多就很不优雅。后来学习了责任链模式后感觉挺有趣的,可以有效地代替if
else
。
整理数据
在将统计结果导出之前,要根据命令中的-type
和-province
进行统计结果的过滤,还要确保统计结果以汉字字典序排列。
设计实现
我以前的实现方法,是像面向过程那样,一条龙服务下来,这样一个程序永远就一条功能线了,能满足。然而助教给出了提示,建议不要把程序写死,要假设程序的需求会发生变化,给程序扩展的空间,这样就不会牵一发而动全身,避免陷入代码维护的噩梦。
程序框架
程序的框架就是从识别命令到开始执行命令的过程,采用命令模式,具体如下:
list命令的执行
代码组织,这里只列出关键的类和方法
list各个的参数检查流程
日志读取
日志解析
采用责任链模式
具体流程
代码说明
框架部分
程序入口,采用命令模式,扩展命令时无需更改此处代码
public static void main(String[] args) {
try {
CommandReceiver receiver = new CommandReceiver();
Command command = new CommandFactory().getCommand(args[0], receiver);
CommandLine commandLine = new CommandLine(args, command);
commandLine.execute();
} catch (Exception e) {
System.out.print("error: " + e.getMessage());
}
}
Command接口,实现此接口以扩展命令:
interface Command {
void execute(CommandLine commandLine) throws Exception;
}
在命令工厂中通过反射生成指定命令的对象,传入“list”字符串即返回ListCommand
对象:
/**
* 命令工厂
*/
class CommandFactory {
/**
* 通过反射执行相应方法获取命令对象
*
* @param name 命令名
* @param receiver 命令接受者
* @return 相应方法执行后获取的命令对象
* @throws Exception 不存在相应命令对象
*/
public Command getCommand(String name, CommandReceiver receiver) throws Exception {
try {
return (Command) this.getClass().getMethod(name.toLowerCase(), Class.forName("CommandReceiver"))
.invoke(this, receiver);
} catch (Exception e) {
throw new NoSuchMethodException("未知命令 \'" + name + "\'");
}
}
public Command list(CommandReceiver receiver) {
return new ListCommand(receiver);
}
// 在此处扩展命令...
}
命令参数解析类,可以对命令进行参数的提取,不仅对List命令有效,还可用于日后扩展的任何命令:
/**
* 命令参数解析
*/
class CommandLine {
private String[] args;
private Command command;
CommandLine(String[] args, Command command) {
this.args = args;
this.command = command;
}
/**
* 获取某个参数的一个值
*
* @param arg 选项名称
* @return 参数 {@code arg} 的值,不存在返回 {@code null}
*/
String getValue(String arg) {...}
/**
* 获取某个参数的所有值
*
* @param arg 选项名称
* @return 参数 {@code arg} 的值列表
*/
List getValues(String arg) {...}
/**
* 获取参数的索引
*
* @param arg 选项名称
* @return 该选项的索引值;若不存在返回 -1
*/
private int indexOf(String arg) {...}
/**
* 命令入口
*
* @throws Exception
*/
public void execute() throws Exception {...}
}
实体映射:
public static ListCommandEntity Mapper(CommandLine line) throws Exception {
ListCommandEntity entity = new ListCommandEntity();
// 映射-out参数
entity.out = new File(line.getValue("-out"));
// 映射-date参数,由于之前已经进行参数检查所以这里不做异常处理
entity.date = Util.DATE_FORMAT.parse(line.getValue("-date"));
// 映射-log参数为读取的日志
entity.log = LogReader.readLog(entity.date, new File(line.getValue("-log")));
// 映射-type
List types = line.getValues("-type");
if (types.size() != 0) {
entity.type = types;
}
// 映射-province
entity.province = line.getValues("-province");
return entity;
}
读取日志,由于最新日志的日期只有在读取日志后才能得知,所以日期范围的合法性判断推迟到此处执行:
/**
* 获取指定日期之前的所有log
*
* @param requiredDate 指定日期
* @param logDir 日志文件所在目录
* @return 指定日期之前的所有log列表
*/
public static List readLog(Date requiredDate, File logDir) throws Exception {
File[] fileList = logDir.listFiles();
List logLines = new ArrayList<>();
Date latestDate = Util.DATE_FORMAT.parse("2000-01-01");
for (File file : fileList) {
if (file.isFile()) {
Date logDate;
// 过滤日期不规范的日志文件
try {
logDate = Util.DATE_FORMAT.parse(getLogDate(file));
} catch (Exception e) {
continue;
}
// 过滤指定日期之后的日志
if (requiredDate == null || !logDate.after(requiredDate)) {
// 记录日志最新日期
if (logDate.after(latestDate)) {
latestDate = logDate;
}
try (InputStream fr = new FileInputStream(file.getAbsolutePath());
BufferedReader bf = new BufferedReader(new InputStreamReader(fr, StandardCharsets.UTF_8))) {
String line;
// 按行读取
while ((line = bf.readLine()) != null) {
// 过滤注解
if (!line.substring(0, 2).equals("//")) {
logLines.add(line);
}
}
} catch (IOException e) {
throw new Exception("日志文件读取失败: '" + file.getAbsolutePath() + "'");
}
}
}
}
// 日期范围的合法性判断
if (requiredDate != null && requiredDate.after(latestDate)) {
throw new Exception("日期超出范围");
}
return logLines;
}
日志解析部分
入口:
public static void doStatistic(List logLines) {
// 各个动作的链接
// exclusive -> diagnosis -> deadOrCure -> flowIn -> add
Add add = new Add(null);
FlowIn flowIn = new FlowIn(add);
DeadOrCure deadOrCure = new DeadOrCure(flowIn);
Diagnosis diagnosis = new Diagnosis(deadOrCure);
Exclusive exclusive = new Exclusive(diagnosis);
for (String logLine : logLines) {
// 标记该地区已被检查
setChecked(logLine.substring(0, logLine.indexOf(" ")));
// 从链头开始传递日志
exclusive.passOn(Util.logType(logLine), logLine);
}
}
使用工具类的正则表达式,通过第一次正则匹配,获得唯一的索引,用于指明日志接收者:
// 正则表达式集
static final String[] REGEXS = {
"(\\S+) 新增 (\\S+) (\\d+)人",
"(\\S+) (\\S+) 流入 (\\S+) (\\d+)人",
"(\\S+) (\\S+) (\\d+)人",
"(\\S+) 疑似患者 确诊感染 (\\d+)人",
"(\\S+) 排除 疑似患者 (\\d+)人",
};
/**
* 匹配日志类型
*
* @param logLine 日志的一条记录
* @return 正则表达式的索引;匹配失败返回-1
*/
public static int logType(String logLine) {
for (int i = 0; i < REGEXS.length; i++) {
if (logLine.matches(REGEXS[i])) {
return i;
}
}
return -1;
}
抽象动作类:
abstract class AbstractAction {
// 定义各种类型的唯一索引
public static int ADD_IP = 0;
public static int FLOW_IN = 1;
public static int DEAD_OR_CURE = 2;
public static int DGS = 3;
public static int EXC = 4;
protected int typeNum;
//责任链中的下一个动作
protected AbstractAction nextAction;
public void passOn(int typeNum, String logLine) {
// 命中,执行动作,退出责任链
if (this.typeNum == typeNum) {
doAction(logLine);
return;
}
// 未命中则传递日志到下一个动作类
if (nextAction != null) {
nextAction.passOn(typeNum, logLine);
}
}
/**
* 执行相应的数据更新操作。
*
* @param logLine
*/
abstract protected void doAction(String logLine);
}
每个具体动作类都具有以下形式:
class Action extends AbstractAction {
// 匹配的正则表达式
private String regex = "";
Add(AbstractAction nextAction) {
// 设置唯一索引和下一个动作
}
@Override
protected void doAction(String logLine) {
// 此处第二次使用正则表达式,提取所需字段进行数据更新
}
}
数据整理部分
每个地区的统计结果用Region对象封装
/**
* 地区实体类
* 一个实例代表一个地区的情况
*/
class Region {
// 地区名称
private String name;
// 数据
private int infected;
private int suspected;
private int cure;
private int dead;
// 是否经过统计
private boolean isChecked;
// ...
}
再将Region集合封装成StatisticResult类
class StatisticResult {
static List regions = new ArrayList<>();
// ...
}
根据-type和-privince过滤数据
public static List filterTypeAndProvince(List type, List province) {
List result = new ArrayList<>();
for (String prv : province) {
if (!prv.equals("全国")) {
result.add(Objects.requireNonNull(get(prv)).toStringWithCertainType(type));
}
}
if (province.contains("全国") || province.size() == 0) {
// 添加全国统计
result.add(statisticAll()).toStringWithCertainType(type));
}
// 若-province为空,列出被统计过的地区情况
if (province.size() == 0) {
for (Region rg : regions) {
if (rg.isChecked()) {
result.add(rg.toStringWithCertainType(type));
}
}
}
// 返回最终的统计结果
return result;
}
文件读写
以utf-8编码方式进行文件的读写:
// 读取日志文件(LogReader.readLog)
try (InputStream fr = new FileInputStream(file.getAbsolutePath());
BufferedReader bf = new BufferedReader(new InputStreamReader(fr, StandardCharsets.UTF_8))) {
String line;
// 按行读取
while ((line = bf.readLine()) != null) {
// 过滤注解
if (!line.substring(0, 2).equals("//")) {
logLines.add(line);
}
}
} catch (IOException e) {
throw new Exception("日志文件读取失败: '" + file.getAbsolutePath() + "'");
}
// 导出统计结果(LogWriter.write)
try (FileOutputStream out = new FileOutputStream(filePath);
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) {
for (String resultLine : results) {
bw.write(resultLine);
}
bw.write("// 该文档并非真实数据,仅供测试使用\n");
}
测试
覆盖率优化和性能测试,性能优化截图和描述。
单元测试
使用Junit5组件,主要进行了一些主要模块的测试,和主函数测试,打包运行
/**
* 打包测试
*/
@RunWith(Suite.class)
@SuiteClasses({
CommandFactoryTest.class,
ListCheckerTest.class,
CommandLineTest.class,
LogReaderTest.class,
InfectStatisticTest.class
})
public class SuiteTest {
}
测试结果如下:
因为Lib类为空,覆盖率为0;
其实前面的分模块测试已经实现了代码覆盖率几乎100%,但《构建之法》提到:
100%的代码覆盖率并不等同于100%的正确性!
所以我增加了完整代码流程的测试并提供了11个测试用例,以确保运行的正确性。
性能测试
使用的测试命令:list -log D:\log\ -out D:\listOut.txt
即获取所有时间段所有有记录的地区的统计数据
最早版本的性能分析:
可以看到sortByRegion
占用了超过一半的运行时间,这是个排序方法,以地区字典序排列数据。
由于最初的统计结果整理方式为动态添加,也就是当日志出现某地区的时候StatisticResult
才会新建一个该地区的Region
实例,这样一来统计结果的顺序就无法得到保证,必须手动实现排序:
/**
* 排序器
*/
class Sorter {
public static void sortByRegion(List results) {
Comparator