寒假作业2/2
这个作业属于哪个课程 | 2021春软件工程实践|W班 |
---|---|
这个作业要求在哪里 | 寒假作业2/2 |
这个作业的目标 | 阅读《构建之法》提出问题 编写程序,使用PSP进行时间管理与总结 |
其他参考文献 | The Most Efficient Way To Find Top K Frequent Words In A Big Word Sequence |
阅读《构建之法》并提问
如果你是病人, 你希望你的医生是下面的那一种呢?
a) 刚刚在书上看到你的病例, 开刀的过程中非常认真严谨, 时不时还要停下来翻书看看…
b) 富有创新意识, 开刀时突然想到一个新技术, 新的刀法, 然后马上在你身上试验…
c) 已经处理过很多类似的病例, 可以一边给你开刀, 一边和护士聊天说昨天晚上放的 《非诚勿扰》的花絮…
d) 此医生无正式文凭或医院, 但是号称有秘方, 可治百病。
e) 还有这一类, 给你开刀到一半的时候, 出去玩去了, 快下班的时候, 他们匆匆赶回来, 胡搞一气, 给你再缝好, 打了很多麻药,就把你送出了院, 说“治好了”!
我认为现在互联网已经进入了"存量竞争",能脱颖而出的往往是那些富有创新意识, 开刀时突然想到一个新技术
的项目之后在加上稳定的运营
有一种意见认为作坊只能独立存在,和其他机构都合不来。其实不然,在庞大的企业内部,
也有一些人构建了一个小作坊,自己做主,做自己感兴趣的事
我感觉大机构反而可能是小作坊创新的阻碍。比如facebook,在他还是小作坊的时候,就是靠创新吸引了最初的用户,但是现在,为了商业利益,他对于拥有新技术的小作坊,首先是收购,如果收购不成功,就仿照一个与之对应的软件来压垮小作坊,一定程度上是阻碍了创新。
白箱:在设计测试的过程中,设计者可以“看到”软件系统的内部结构,并且使用软件的内部知识来指导测试数据及方法的选择。“白箱”并不是一个精确的说法,因为把箱子涂成白色,同样也看不见箱子里的东西。有人建议用“玻璃箱”来表示。
如果白箱测试要知道程序的结构,那测试人员是不是要熟悉整个系统,不然不能进行白箱测试。
职业发展
几种方法:
· PSP
· 考级
· Steve McConnell Construx
· Corporate Career Model
· Pragmatic Approach
正如文中所说,是很难量化一个工程师的技术和能力,但是我觉得上面的这些方法在国内有些不适用。之前也读过这篇文章:怎样花两年时间去面试一个人。但是能做到文中的做法的公司,也是极少的,感觉在国内,衡量一个人的技术和能力还是靠他的项目经历。
Lotus 1-2-3 占据了大部分市场份额, 不过, 它的日期计算功能有一个小Bug,就是把1900 年当作闰年。这类软件在内部把日期保存为“从1900/1/1 到当前日期的天数”这样的一个整数。Excel 作为后来者,要支持 Lotus 1-2-3 的数据文件格式,这样才能正确处理别的软件产生的格式文件。 这个错误就这么延续下来了,每一版本都有人报告,但是都没有改正。
我自己在使用一些软件的时候,或是写代码的时候,会遇到一些让人有“为什么要这样实现”的感觉,然后在网上寻找答案的时候常常是说为了“向后兼容”。有些软件因为过重的历史包袱导致被那些新进的软件超越,而有一些软件却因“不能向后兼容”导致被用户抛弃。那要如何在这两者之间找到一个平衡点?
WordCount编程
Github项目地址
PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
• Estimate | • 估计这个任务需要多少时间 | 3Day | 4Day |
Development | 开发 | ||
• Analysis | • 需求分析 (包括学习新技术) | 60 | 90 |
• Design Spec | • 生成设计文档 | 20 | 10 |
• Design Review | • 设计复审 | 10 | 20 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 20 | 10 |
• Design | • 具体设计 | 60 | 70 |
• Coding | • 具体编码 | 240 | 360 |
• Code Review | • 代码复审 | 20 | 10 |
• Test | • 测试(自我测试,修改代码,提交修改) | 60 | 180 |
Reporting | 报告 | ||
• Test Repor | • 测试报告 | 60 | 120 |
• Size Measurement | • 计算工作量 | 20 | 20 |
• Postmortem & Process Improvement Plan | • 事后总结, 并提出过程改进计划 | 30 | 10 |
合计 | 600 | 1000 |
代码规范制定链接
解题思路
看过一遍题目,一开始我认为可以将代码分为几个大模块
-
WordReader模块:读取合法单词
-
TopK模块:统计topK
-
Core模块,将上两个模块整合,实现统计文档信息和TopK的需求
思路就是Core通过WordReader模块,读取出合法单词,统计文档的字符数、单词数和行数,同时将这个合法单词存入TopK模块统计TopK,最后将从TopK模块中获取出现频率前十的数,并和文档的统计信息一并输出。
之后想了想,应该在读取模块中就可以统计文档信息,那么Core模块实际上就起到了一个将WordReader模块和TopK模块整合的功能
最后在网上查资料的时候,有看到在有限内存下对多数据(1亿条左右)实现求TopK的思路,所以我希望我的程序也能对大文件进行处理。
设计与实现过程
整体设计
由于学习了Spring Boot框架,觉得它的IoC的思想确实能减少不同模块之间的耦合,所以我的Core模块默认是没有对WordReader模块的定义,而是通过set方式将其"注入"。
而如果我是通过在Core中new一个WordReader来实现的,这次的要求是从文件中读取,那么我必然要在Core模块中写一个方法传入File类来构造WordReader,如果需求改为要从数据库读取,那Core模块还是要修改出一个传递数据库Connect的方法,这样对WordReader的修改传递到了Core,程序之间耦合度较高。
以上是我在整体设计的时候的思考。
WordReader模块
首先,由于作业要求能把这个功能放到不同的环境中去,所以WordReader模块不应只能从文件中读取数据,所以我将设计了一个WordReader的接口,并实现了一个从InputStream中读取单词的实现类。
public interface WordReader {
//返回下一个有效的单词
public String nextWord();
public boolean setInputStream(InputStream is);
public long getLineNumber();
public long getWordNumber();
public long getCharNumber();
public void clear();
}
设置读入流的工作交给了main函数,main函数将文件路径转换成流,通过流新建一个WordReader,之后set进Core中。这样可以很容易复用这个功能。
public static void main(String[] args){
...
InputStream is = new FileInputStream(file);
InputStreamWordReader wr = new InputStreamWordReader(is);
Core core = new Core();
core.setWordReader(wr);
core.start();
...
}
而在实现的时候,我第一个想到的就是用Scanner类来读取单词,它提供了丰富的读取功能,但是它按单词读取的时候并不能统计换行,而如果按行读取,对于大文件来说,如果数据都在一行,那么就不能将内存控制在可以控制的访问内
综上缺点,我决定还是自己手写一个从流中获取有效单词的类。
我实现了一个InputStreamWordReader类,由于我源代码中有详细的注解,这里就不放代码来解释了,而这个类有以下的特点
- 继承自WordReader类,可以整合到Core中
- 统计了读出文件的字符数、单词数和行数
- 可以通过next方法获取下一个有效的单词,即作业要求中对单词的定义
- 可以统计有效的行数,跳过只包含空字符的行,如果最后一行没有换行也可以统计到
- 这个类的实现是完全通过流的思想,不会缓存整个文件、一整行的字符串,最多缓存一个单词,以控制整个程序的内存使用,也因为这个功能的实现,所以整体逻辑比较复杂
TopK模块
刚开始查阅资料的时候,发现大概的思路是将单词存入HashMap或TrieTree中,在统计频率最高的单词时,通过小顶堆来维护出现频率最高的k个字符串,最后返回结果。
结合这个作业的实际来看,他除了26个字母,数字也是合法的,如果使用TrieTree所以可能导致数的深度很深,而且每个节点的字节点也很多,如果在稀疏的情况下,TrieTree实际上的内存用量更多。所以我使用了HashMap+PriorityQueue来实现TopK模块。
由于使用的都是java封装的算法,实际代码实现也很简短,所以在这方面也不过多赘述。
性能改进
性能改进的方法
上面说到,我希望我的程序也能对大文件进行处理。而要实现这一要求,有两个槛:
- 在读入的时候必须严格按流读入,不能加载整个文件到内存中,这一要求我上面已经实现了
- 在处理TopK的时候,也不能直接处理整个文件,不然也是内存不足,这个是要改进的地方
而之前查询到的思路是将整个文件的单词按hash分到不同的小文件中,之后再对每个小文件进行处理。因为是按hash分割的,所以相同的单词必然在同一个文件之中,所以最后只要取出每个小文件的前k高出现频率的词,再取出k*n的词中最高的k个,就用有限的内存实现了功能。
我本人是不喜欢直接贴大段代码的,所以我这里只贴方法名并且说说我他的流程。
public class HugeDataCore{
...
//分割的每个小文件
private File files[];
//缓存的文件夹
private File cacheDir;
//是否启用分割文件
private boolean div=false;
//如果分割文件,记录要分割成几份文件
private int divNum;
//获取最高词频单词的个数
private long topCount =10;
//文件最大阈值
private int eachFileSize;
//优先队列
private PriorityQueue<Node> pq = new PriorityQueue<>();
...
//通过文件来构造,这样阈值默认是50mb
public HugeDataCore(File inputFile){...}
//可以设置阈值的构造
public HugeDataCore(File inputFile,int eachFileSize){...}
//判断并分割文件,超过阈值则启用分割文件,反之不启用
private void divFile(){...}
//开始处理的函数,在开始之前判断并分割文件
public void start() throws ExecutionException, InterruptedException {
divFile();
...
}
//设置获取的TopK的个数K
public void setTopCount(int topCount) {...}
//设置缓存文件夹,结束会自动删除文件
public void setCacheDir(String cacheDir) {...}
//获取各种信息
public long getWordNumber() {...}
public long getCharNumber() {...}
public long getLineNumber() {...}
...
}
- 实现了一个HugeDataCore类,这个类只能通过File来构建,因为大文件只能从文件中读取。
- 在开始调用start来开始处理文件,处理之前会先判断文件是否超过一个阈值,当然这个阈值可以你自己来设置。如果超过阈值就分割文件,如果不超过就不分割文件。分割文件可以自己设置缓存的路径。
- 因为分割文件要读入一次文件,所以统计字符数、单词数和函数再分割文件中记录。
- 分割完文件后,会将分割的文件存到一个File数组中,之后再start中顺序处理生成的文件。
- 这里处理小文件的逻辑和之前实现的不分割处理文件的Core的逻辑是一样的,所以直接用文件构的流构造Core,复用了代码。
- 最后也是优先队列类处理处理每个小文件的前k个词频最高的词,获取到频率最高的k个词。
所以理论上,这个程序在空间上是可以对非常大的文件进行处理的
性能优化后的结果
在github上找到了一个随机生成字符串的Java代码:wordnet-random-name,我是用他来生成随机字符串的。因为他是用词库的,而且词库有限,所以认为如果生成非常大量的字符串,生成的字符串是非常稠密的。
对于随机生成的150mb大小的文件,大概1000w个单词,我分别用了不分割文件和分割文件的方法跑了一遍:
这里解释一下这个图,上面的柱状图是这段时间内分配的内存,而下面折线图这是实际使用的内存,橙色竖条则是
可以看到,如果不分割文件,内存在处理TopK的时候是到达了1g左右,而分割文件就内存则被控制在了200mb左右。这还是在我按流读入的情况下,我让其他同学用他缓存整个文件后进行处理的读入实现运行,在读入阶段的内存使用就到达了1g左右。在分割文件的情况下,程序大概跑了9秒。
同样的,我生成了一个1500mb左右的数据,大概1亿个单词,在生成数据的时候,在每个单词后面添加一个1000以下的随机数,让数据变得比较稀疏:
可以看到,如果是不分割文件,内存使用了接近4g,而且一直触发gc导致cpu占用率也非常高。而分割了文件后,内存占用不超过400mb,而且程序也不会因为一直gc而陷入死锁。在分割文件情况下,程序大概跑了2分半。
最后,因为对文件进行了分割,所以肯定是比不上直接对文件进行处理的耗时短,而且这还与机器硬盘的读取速写有关,所以性能是没有优势的。但是我认为在这里用一定的时间换取空间是非常划算的。
单元测试
对于判断单词合法性的单元测试
@Test
void isWord(){
//一定是一个只包含字母和数字的字符串
Assert.assertEquals(true,wr.isWord("abce"));
Assert.assertEquals(false,wr.isWord("abc1e"));
Assert.assertEquals(false,wr.isWord("12abce"));
Assert.assertEquals(false,wr.isWord("3"));
Assert.assertEquals(false,wr.isWord(""));
Assert.assertEquals(true,wr.isWord("asodjf123abce"));
}
分别是测试了
- 连续四个字母为合法
- 数字对连续字符的隔断
- 数字开头
- 纯数字
- 空字符
- 一个字母+数字的合法字符
对有效行数的统计
@Test
void getLineNumber() {
String string = new String("\n \naisodjo \t \n ???---\n\ni\nrr");
wr.setInputStream(new ByteArrayInputStream(string.getBytes(StandardCharsets.UTF_8)));
while (wr.nextWord()!=null);
Assert.assertEquals(4,wr.getLineNumber());
}
分别是测试了
- 开头空行
- 空白字符换行
- 字符+空白字符+换行
- 非空白字符非有效字符的换行
- 连续换行
- 最后无换行的一行
异常处理说明
这个程序异常较少,主要是文件的异常和用户输入的异常。
如果用户输入的文件不存在或者权限不足,会抛出FileNotFoundException
。
同样的,在分割文件时,文件夹的权限不足也会抛出这个异常。
心路历程与收获
心路历程
刚开始感觉这个程序比起之前的什么管理系统简单的太多,但是当我真的想要按上述的要求编写程序时,还是发现了许多以前我没有注意到的点。在之前,我编写程序的时候,感觉很难控制每个方法的粒度,但是这次在写单元测试的时候,发现要做到'单元'的话,我之前设计的方法粒度可能太大。
收获
在之前的编程中,我从来没有用过单元测试,基本上都是手动输入样例然后人工对比结果。这次学到了单元测试的使用。