这个作业属于哪个课程 | 2022年福大-软件工程;软件工程实践-W班 |
---|---|
这个作业要求在哪里 | 要求在这里 |
这个作业的目标 | 以北京冬奥会为背景建立一个赛事数据查询器,锻炼读取文件的能力,并将项目上传至GitCode,撰写相关文档,熟悉软件开发基本步骤 |
其他参考文献 | 由于参考较多,见文末 |
说明:本次项目采用Java语言编写
本博客所提到的数据爬取仅作为教学研究使用,无其他目的
1 GitCode项目地址
2 TimeList
PSP | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
• Estimate | • 估计这个任务需要多少时间 | 15 | 20 |
Development | 开发 | ||
• Analysis | • 需求分析 (包括学习新技术) | 60 | 75 |
• Design Spec | • 生成设计文档 | 20 | 20 |
• Design Review | • 设计复审 | 15 | 25 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 20 | 10 |
• Design | • 具体设计 | 20 | 30 |
• Coding | • 具体编码 | 420 | 420 |
• Code Review | • 代码复审 | 15 | 15 |
• Test | • 测试(自我测试,修改代码,提交修改) | 180 | 240 |
Reporting | 报告 | ||
• Test Report | • 测试报告 | 30 | 35 |
• Size Measurement | • 计算工作量 | 15 | 10 |
• Postmortem & Process Improvement Plan | • 事后总结, 并提出过程改进计划 | 25 | 35 |
总计 | 835 | 935 |
3 如何完成项目
Q1:如何获取赛事数据?
A:从冬奥专栏获取赛事数据的api,然后在程序中利用网络请求把api对应的文件内容拉下来写程序处理鉴于本次实践需求不包含从网络实时获取赛事数据,加之之前也没有写过类似的程序,因此考虑用最简单的一种办法来获取数据到本地进行处理。下面以获取某日赛程为例,说一下获取数据的思路。
首先进入冬奥专栏,找到每日赛程
打开开发者模式并刷新(相当于向后台重新请求数据):
选择“JS”,会发现有很多文件,最终找到一个叫getBjOlyMatchList的js文件,打开会发现他的字段和本次作业提供的部分赛程数据文件相同,于是找到了数据文件,例如这个赛程api:
但这里的中文采用unicode编码,且字段没有换行,对后期处理数据会造成不便,所以需要先手动处理一下,可以使用这个JSON转义/格式化网站,格式化及转义后即变为利于处理的形式,然后保存即可。
Q2:如何将json字段处理成想要的格式?
A:由于编码之初,需求上要求不能使用第三方库解析json,所以其实这个问题在我看来是整个项目的核心和主要难点。
由于java8自带了许多处理字符串的函数,想要实现这个需求也不难。在这里,我选择使用replace
函数,从文件逐行读取内容,并判断是否含有我们想要的字段,如果有,那么就使用replace函数进行相关操作(去掉引号、逗号、空格,并视情况更改字段名),如:
if (tempString.contains("bronze")) {
tempString = tempString.replaceAll("\"", "").replaceAll(",", "").replaceAll(" ", "");//字段处理
...//做相应的存储操作
}
这种方式看上去不太优雅,写起来也不够丝滑,但他确实能用。后来需求更改,允许使用第三方库。无奈更改时程序大体已经写好,若使用第三方库,程序结构会发生实质改变,相当于重写项目,所以最后没有更改。但理论上第三方库的解析速度应该更快。
Q3:如何识别用户输入的指令?
这其实还是一个字符串游戏。观察本次作业需求可知,正确的指令只有两类,他们分别由一个单词和两个单词构成,且都是严格匹配(区分大小写),所以可以先判断是否为total,直接使用字符串的equals
函数即可。再判断是否为schedule 0205
形式的指令,可以使用正则分割的形式,判断分割后的字符串长度是否为2,首项是否为schedule
。上述判断都不满足时就是错误指令。整个识别思路be like:
Q4:如何将标准时间表示转换成需求提及的样式?
A:如果按常规思路,这应该还是一个字符串游戏。由于json文件中的时间格式规整且标准,所以用字符串遍历、拆解的思路同样适用于这里。
But…正是由于这种规整标准的格式,给了我偷懒的想法。利用Date类的一系列函数,我们可以轻松地把这个时间格式转成Date对象,然后从中提取时、分这两个要素拼接,就能完成目的。
SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
//这里需要捕获一个ParseException异常
Date date = sf.parse(originTimeString);
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
String hour = calendar.get(Calendar.HOUR_OF_DAY) <10 ? "0"+ calendar.get(Calendar.HOUR_OF_DAY):calendar.get(Calendar.HOUR_OF_DAY)+"";
String minute = calendar.get(Calendar.MINUTE) < 10 ? "0" + calendar.get(Calendar.MINUTE) : calendar.get(Calendar.MINUTE) + "";
modifiedTimeString = "time:" + hour + ":" + minute;
Q5:如何删去文件最后一行的换行符?
这个问题涉及到java文件操作,一种可行且简单的方法是使用RandomAccessFile
。由于我们只需要删掉文件的最后一行的换行符,所以可以使用setLength
直接将文件长度缩短1个字节即可。
RandomAccessFile raf = new RandomAccessFile(fileOutput,"rw");
raf.setLength(raf.length()-1);//remove \n in the end
raf.close();
Q6:面对多行输入,应该如何处理比较合理?
一种方法是先用一个字符串存放所有的输出结果,等到所有输入全部处理后一次性写到输出文件。但由于输入的数据量不确定,完全有可能会突破String理论存储极限2G,所以并没有使用此法。
另一种方法是边读边写入文件。由于在不同类之间完成数据处理,所以这个文件流需要被设置为static
,同时可以考虑在主函数中执行文件的开与关,减少IO次数。
Q7:怎么实现在对抗比赛后加入对抗双方信息?
可以采取遍历的手段,先将所有的字段填充到你的数据结构中,然后再统一输出。针对非对抗赛,homename
和awayname
均为空,因此后续输出时可以先判断一下是否为空,若不是,则在sport
后加上对抗双方信息即可。
4 代码结构
4.1 用到了哪些类
public class OlympicSearch{...}
//主类,主要用来读input.txt、分析指令,并调用其他类的相应获取、写入函数得到内容,并写入output.txt
public class MedalRanking{...}
//core模块,用于获取排行榜数据并写入文件
public class RankingStruct{...}
//于性能优化后加入的新类,主要目的是存储排行榜信息
public class ScheduleList{...}
//core模块,用于分析指令正误、获取指定日期的赛程并写入文件
public class ScheduleStruct{...}
//于性能优化后加入的新类,主要目的是存储某一天的赛程信息
4.2 各类包含的函数及重要变量一览
- OlympicSearch类
public static HashMap<String,ScheduleStruct> scheduleData = new HashMap<>();
//存放曾经读到的赛程信息
public static RankingStruct rankingData = new RankingStruct();
//存放曾经读到的排行榜信息
public static void main(String[] args) throws IOException{...}
//程序入口,读文件并调用其他函数来写文件
public static void writeToFile(){...}
//分析input.txt的指令,根据分析结果调用相应的处理函数
public static void writeInvalidCommandInfo(){...}
//处理无法识别的指令,即写入“Error”到文件
- MedalRanking类
public void getRankingContent() throws IOException{...}
//获取排行榜,根据是否有存储奖牌榜信息而执行不同的动作
public void initializeArray(){...}
//奖牌榜信息为空时调用,初始化rankingData
public static void fillArray() throws IOException{...}
//奖牌榜信息为空时调用,向rankingData填充信息
public static void writeMedalRankingInfo() throws IOException{...}
//写入奖牌榜信息到文件
- ScheduleList类
public void getScheduleList(String dateDetail) throws IOException{...}
//获取赛程,根据是否有dateDetail的日程而执行不同的动作
public static void writeInvalidDateFormatInfo() throws IOException{...}
//处理无效日期格式(如211,02xx)的情况,输出N/A
public void getCompetitionTotal() throws IOException{...}
//获取当日赛事总数
public static boolean isValidDate(String testString){...}
//判断日期是否在赛会期间
public void fillArray(String dateKey) throws IOException, ParseException{...}
//根据提供的日期键值dateKey,填充scheduleData
public void initializeArray(){...}
//初始化scheduleData中的一个成员,以存放赛程信息
public void writeCertainScheduleByMap(String dateKey) throws IOException{...}
//将已存在scheduleData的赛事信息写入文件
public static String convertTimeFormat(String originTimeString){...}
//将YYYY-mm-dd HH:mm:ss转换成HH:mm
public static void writeInvalidDateInfo() throws IOException{...}
//处理不在赛会日期范围内的情况,输出N/A
- ScheduleStruct类(工具类,一个对象存放单日赛事情况)
public int competitionTotal = 0;//当日赛事数
public String[] timeList = null;//时间
public String[] sportList = null;//大项名
public String[] nameList = null;//比赛名
public String[] homenameList = null;//交战方1国家名
public String[] awaynameList = null;//交战方2国家名
public String[] venueList = null;//比赛地点名
- RankingStruct类(工具类,存放所有国家的奖牌情况)
public int countryTotal = 0;//上榜国家数
public String[] totalMedals = null;//总奖牌数
public String[] ranks = null;//排名
public String[] goldMedals = null;//金牌数
public String[] silverMedals = null;//银牌数
public String[] bronzeMedals = null;//铜牌数
4.3 函数调用关系
5 关键代码展示
- 写入文件writeToFile
public static void writeToFile() {
String tempString;
try
{
while ((tempString=reader.readLine()) != null) {
if (tempString.trim().equals("total"))//total:ok,total :ok. total:ok
{
(new MedalRanking()).getRankingContent();
}//function 1 : right situation of total
else if (tempString.trim().equals("schedule"))
{
writeInvalidCommandInfo();
}//a special situation
else
{
String[] stringSlice = tempString.split("\\s+");
try
{
if (!stringSlice[0].equals("schedule"))
{
writeInvalidCommandInfo();
}
else if (stringSlice.length!=2)
{
writeInvalidCommandInfo();
}
else
{
(new ScheduleList()).getScheduleList(tempString);
}
}
catch (ArrayIndexOutOfBoundsException abe)//invalid command
{
writeInvalidCommandInfo();
}
}//function 2: right situation of schedule
}
}
catch (IOException ioe)
{
ioe.printStackTrace();
}
}
- 从数据文件获取字段并处理(以处理赛程数据为例,处理奖牌榜数据方法结构类似):
public void fillArray(String dateKey) throws IOException, ParseException {
int fillPtr = 0;
String tempString;
while ((tempString = reader.readLine()) != null) {//read the file line by line and store
if (tempString.contains("title")&&!tempString.contains("totaltitle"))
{
tempString = tempString.replaceAll("\"","").replaceAll(",","").replaceAll("title","name").replaceAll(" ","").trim();//replaceAll()
this.dailySchedule.nameList[fillPtr/SCHEDULE_FIELD] = tempString;//store in struct
fillPtr++;
}
...//all the fields are dealt in the same way
else if (tempString.contains("awayname"))
{
tempString = tempString.replaceAll("\"","").replaceAll(",","").replaceAll("awayname:","").replaceAll(" ","").trim();
this.dailySchedule.awaynameList[fillPtr/SCHEDULE_FIELD] = tempString;
fillPtr++;
}
}
OlympicSearch.scheduleData.put(dateKey,dailySchedule);//execute the result to the static variable in class OlympicSearch
}
- 将标准时间串转换成需要的时间串
public static String convertTimeFormat(String originTimeString) {
SimpleDateFormat sf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String modifiedTimeString = null;
try
{
Date date = sf.parse(originTimeString);
Calendar calendar = Calendar.getInstance();
calendar.setTime(date);
String hour = calendar.get(Calendar.HOUR_OF_DAY) <10 ? "0"+ calendar.get(Calendar.HOUR_OF_DAY) : calendar.get(Calendar.HOUR_OF_DAY)+"";
String minute = calendar.get(Calendar.MINUTE) < 10 ? "0" + calendar.get(Calendar.MINUTE) : calendar.get(Calendar.MINUTE) + "";
modifiedTimeString = "time:" + hour + ":" + minute;
}
catch (ParseException e)
{
e.printStackTrace();
}
return modifiedTimeString;//hh:mm
}
- 判断日期是否在赛会期间:
public static boolean isValidDate(String testString) {
try
{
int dateByInt = Integer.parseInt(testString);
return dateByInt >= BEGIN_OF_WINTEROLYMPIC && dateByInt <= END_OF_WINTEROLYMPIC;
}
catch (Exception e)
{
return false;
}
}
这里强制转换了schedule后面的那个串,如果他在202到220之间,说明他确实符合条件;如果不在,则超出范围;如果程序捕获异常(说明这个字符串并非纯数字),那么他也不满足规范,也会判断为不正确。
- 写入文件(部分示例)
public void writeCertainScheduleByMap(String dateKey) throws IOException {
ScheduleStruct schedule = OlympicSearch.scheduleData.get(dateKey);
for (int i = 0; i < schedule.competitionTotal; i++) {
OlympicSearch.bw.write(schedule.timeList[i]+'\n');
...
if (!schedule.homenameList[i].isEmpty())
{
OlympicSearch.bw.write(" "+schedule.homenameList[i]+"VS"+schedule.awaynameList[i]+"\n");
}
else
{
OlympicSearch.bw.write('\n');
}
...
}
}
文件规定用\n
进行换行处理
6 项目性能分析与优化
6.1 未改进前的性能分析
未改进前,用100万行随机生成的指令(包含正确、错误、不能识别的指令)文件input1
,运行时长:
好像还可以,输出文件大小126MB。但是如果全是有效指令(同样约100万行)input2
那就不一样了:
真的是久到离谱,跑了差不多20分钟,此时输出文件大小4.2G。
JProfiler分析如下:
6.2 如何优化才能让性能提升
面对运行速度如此感人
的1st edition,事后分析了一下为什么会这么慢:
- 频繁打开关闭output文件,每处理一行指令,就要对输出文件进行一组io操作
- 如果指令是对的,每次都要打开对应的数据文件来处理文件(事实证明,这是优化的一个关键点)
想到了这些点之后,接下来就想到了下面的优化思路:
- 将对output文件操作的有关变量全部设置为
static
,这样可以不再读一行就关一次文件,而且可以在其他类中对output文件执行读写操作 - 在主类
OlympicSearch
中设置两个static
变量用于存储已经读到的数据信息,这样要处理数据时,先看一下相应数据是否已经存储,若确实已经存储,则直接输出即可,没有的时候再打开数据文件读数据
6.3 优化后的性能分析验证
同样使用input1
,时长:
大概快了1个数量级
使用input2
,时长:
快了40倍左右,这个优化确实挺明显的,暂且认为这次优化效果挺ok
JProfiler分析如下:
6.4 进一步优化的思路
从JProfiler提供的内存占用情况可以看出,项目创建了大量的字符串,可见与字符串有关的操作是2nd edition性能的瓶颈,为此可以:
- 改用第三方库解析json
(gson避免了频繁生成String对象,理论上可以让我们的程序更快一步) - 与字符串有关的拼接操作使用StringBuilder类进行,减少String对象的创建
7 单元测试
主要思路:采用junit4,以程序写入的方式,先写好测试用的input.txt,然后进行测试。测试时,将正确答案文件与经项目得到的文件内容进行对比,获得测试结果,并观察代码覆盖率。
7.1 部分重要的测试函数
- 在开始测试前,先初始化测试文件
@Before
public void generateTestFile() {
try
{
curLocation = (new File ("")).getAbsolutePath();
System.out.println(curLocation);
testFile = new File(curLocation + "\\src\\data\\"+args[0]);
if (!testFile.exists())
{
testFile.createNewFile();
}
} catch (IOException e) {
e.printStackTrace();
}
}
- 用于比对项目生成的文件与预期文件内容的函数
public void compareWithExpectedFile(String testContent,String pathOfExpectedFile) {
try
{
bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(testFile)));
bw.write(testContent);
//...
OlympicSearch.main(args);
File actualFile = new File(curLocation + "\\src\\data\\"+args[1]);
assertTrue(actualFile.exists());
int lenOfActualFile = (new BufferedInputStream(new FileInputStream(curLocation + "\\src\\data\\"+args[1]))).available();
int lenOfExpectedFile = ...
String expectedContent = new String(IOUtils.readFully(new FileInputStream(pathOfExpectedFile),lenOfExpectedFile), StandardCharsets.UTF_8);
String actualContent = ...
assertEquals(expectedContent,actualContent);
} catch (IOException e) {
e.printStackTrace();
}
}
这里使用了第三方库org.apache.commons.io包中IOUtils类的readFully函数,用于将文件内容读入字符串,然后比对内容。
- 测试函数(节选)
@Test
public void testPrintSchedule() {
compareWithExpectedFile("schedule 0214",curLocation + "\\src\\data\\certainSchedule_right.txt");
compareWithExpectedFile("schedule 0214\nschedule 0214",curLocation + "\\src\\data\\certainScheduleDouble_right.txt");
}
其中,certainSchedule_right.txt
为预期文件
7.2 测试覆盖率情况
经过不断的补充完善,最后的coverage
为90%,剩下未覆盖的代码绝大部分是异常处理。由于第一次接触单元测试,这部分测试不知该如何完成,所以并没有进一步提高覆盖率。
8 异常处理
由于之前极少有对异常处理的经历,所以对这部分不是很了解。大部分异常是idea自动补全的,遇到异常时只是简单的输出一下异常信息。下面给几个例子:
- IOException
本次作业最可能出现的异常,发生于文件读写不成功等一系列与文件有关的操作时:
- ParseException
日期转换错误,有可能是因为字符串非数字,这时可以认定为不是有效日期:
- ArrayIndexOutOfBoundsException
出现在分割单词时。如果指令为空,那么分割结果也为空,抛出此异常,可由此判定为无效指令。
- FileNotFoundException
9 心得体会
- 有问题一定要尽早提出,并通过各种办法解决,否则效率会大打折扣
- 需求更改时,或许你的感觉是:
摸了电线又吃花椒——麻上加麻
,但其实这就是以后工作的常态,要学会躺平及时根据需求进行调整 - 不要做ddl战士!否则可能会因为忙乱发生各种各样的错误,早完成还有时间进行完善
- 要学会独立查资料解决问题
- 本次编码中发现对于单元测试、异常处理不是很熟练,希望可以给一些示例学习一下
- 博客可以边做项目边写,不然到最后一下子写真的很痛苦
附:参考文献
博客园-如何将java项目打成jar包
Bilibili-黑马程序员:Java单元测试(JUnit 4)
CSDN-Java JUnit单元测试教程
CSDN-JProfiler安装、使用教程
RUNOOB-Java HashMap使用
博客园-获取Java程序运行时间
解决cmd输出中文乱码问题-step1
解决cmd输出中文乱码问题-step2
解决javac不是内部指令问题