Loading

寒假作业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

代码规范制定链接

codestyle.md

解题思路

​ 看过一遍题目,一开始我认为可以将代码分为几个大模块

  • WordReader模块:读取合法单词

  • TopK模块:统计topK

  • Core模块,将上两个模块整合,实现统计文档信息和TopK的需求

​ 思路就是Core通过WordReader模块,读取出合法单词,统计文档的字符数、单词数和行数,同时将这个合法单词存入TopK模块统计TopK,最后将从TopK模块中获取出现频率前十的数,并和文档的统计信息一并输出。

​ 之后想了想,应该在读取模块中就可以统计文档信息,那么Core模块实际上就起到了一个将WordReader模块和TopK模块整合的功能

image-20210227133550157

模块关系

​ 最后在网上查资料的时候,有看到在有限内存下对多数据(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封装的算法,实际代码实现也很简短,所以在这方面也不过多赘述。

性能改进

性能改进的方法

​ 上面说到,我希望我的程序也能对大文件进行处理。而要实现这一要求,有两个槛:

  1. 在读入的时候必须严格按流读入,不能加载整个文件到内存中,这一要求我上面已经实现了
  2. 在处理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个单词,我分别用了不分割文件分割文件的方法跑了一遍:

image-20210226170528118

不分割文件

image-20210227130110730

分割文件

​ 这里解释一下这个图,上面的柱状图是这段时间内分配的内存,而下面折线图这是实际使用的内存,橙色竖条则是

​ 可以看到,如果不分割文件,内存在处理TopK的时候是到达了1g左右,而分割文件就内存则被控制在了200mb左右。这还是在我按流读入的情况下,我让其他同学用他缓存整个文件后进行处理的读入实现运行,在读入阶段的内存使用就到达了1g左右。在分割文件的情况下,程序大概跑了9秒。

​ 同样的,我生成了一个1500mb左右的数据,大概1亿个单词,在生成数据的时候,在每个单词后面添加一个1000以下的随机数,让数据变得比较稀疏:

image-20210226170639739

不分割文件

image-20210226170801422

不分割文件CPU使用图

image-20210227130258936

分割文件

image-20210227130228765

分割文件CPU使用图

​ 可以看到,如果是不分割文件,内存使用了接近4g,而且一直触发gc导致cpu占用率也非常高。而分割了文件后,内存占用不超过400mb,而且程序也不会因为一直gc而陷入死锁。在分割文件情况下,程序大概跑了2分半。

​ 最后,因为对文件进行了分割,所以肯定是比不上直接对文件进行处理的耗时短,而且这还与机器硬盘的读取速写有关,所以性能是没有优势的。但是我认为在这里用一定的时间换取空间是非常划算的。

单元测试

z

对于判断单词合法性的单元测试

@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

​ 同样的,在分割文件时,文件夹的权限不足也会抛出这个异常。

心路历程与收获

心路历程

​ 刚开始感觉这个程序比起之前的什么管理系统简单的太多,但是当我真的想要按上述的要求编写程序时,还是发现了许多以前我没有注意到的点。在之前,我编写程序的时候,感觉很难控制每个方法的粒度,但是这次在写单元测试的时候,发现要做到'单元'的话,我之前设计的方法粒度可能太大。

收获

​ 在之前的编程中,我从来没有用过单元测试,基本上都是手动输入样例然后人工对比结果。这次学到了单元测试的使用。

posted @ 2021-02-27 22:17  羊肉串i  阅读(273)  评论(6编辑  收藏  举报