寒假作业2/2

一、作业基本信息

这个作业属于哪个课程 2021春软件工程实践W班
这个作业要求在哪里 作业要求
这个作业的目标 1.阅读《构建之法》并提出五个问题
2.WordCount程序
其他参考文献 《构建之法》、CSDN

目录

阅读《构建之法》并提问

1.书中的第六章讲到了敏捷开发,

但几乎整章的篇幅都在讲“做法”,讲敏捷流程的问题与解法,讲怎样打造敏捷的团队,但我抱有疑问,为什么我要选择敏捷?
在6.5的问答环节中也只是提到它是

前人经验的总结,它被证明很有效果。

我不是很能接受这种说辞。而且查阅资料的时候我发现,作为开发人员,任何人都不会喜欢这种开发模式。

在表6-3中有提到:

敏捷开发适用于对产品可靠性不高、需求变化经常的场景。

这算是最贴近答案的一句话,但本质上只是在告诉你适用场景,依旧无法让我信服所以我继续思考:

我认为市场环境以需求为大,对于需求这一概念,有三个我们必须注意的问题:

  1. 如何满足用户不断变化的需求?
  2. 如何真正满足用户的需求?
  3. 如何满足不同层次用户的需求?

敏捷开发确实很好的做到了第一点,但后面两者呢?相对于传统的瀑布+文档开发流程,它到底有什么优势?如果它在这两方面没有优势,又该以哪个问题为依据来进行开发流程的选择?
而且在看完16.4魔方的创新后,我更近一步地体会到对于目标用户需求变化的把握很重要,而敏捷开发也确实很契合这种情景。但这个故事又激起了我新的疑问,我们对于需求的把握其实是存在一段空白期的,很多情况下我们并没有充分的时间去了解目标用户,在这种情况下连敏捷开发的优势(上面提到的第一点)都无法发挥,也就是说有的情况虽说是适用敏捷开发,但由于各种各样的情况导致在实践过程中表现的不适用,这种情况该怎么选择?

2.书中的16.3.2讲到了动量和加速度的问题,提到了

关于PC桌面版和移动端的一个事例,前者收入不断减少但还是远大于后者,而后者虽然收入少但用户量却不断增长,对于两个平台的投入该如何权衡?

我乍看这个问题想到的观点是:先继续维护PC端,直到入不敷出了以后就停止运营,转投移动端。这个方法很稳健,
但如果考虑两个极端的话,可以发现两个值得注意的问题:

如果在一开始就把PC端的所有资源都投入到移动端,可能能收获多得多的回报,比如早一天上线,多吃一分国家政策的红利,多抢占一份市场,现在很多拥有垄断地位的公司都是这样积少成多来的。

但如果突然宣布停运,不可避免的就是会伤害到用户体验。用户是有黏性的,你家的PC端产品用得顺手,在你移动端开发出来后自然会更倾向于使用你家的产品,但为了新产品而停运旧产品的话势必会影响到口碑。
这里有一个很经典的例子,百度浏览器的电脑端在2019年4月发出公告宣布停运,5月正式停运,虽然有一个月的缓冲时间。但大部分用户甚至都不知道有停运公告这一回事,而且在停运后出现了用户发现收藏无法转移至移动端,进一步加重了用户的不满情绪,以至于迁怒至移动端甚至百度产品,这个问题直到9月份才给出解决方案,但那时候已经人去楼空了。虽说百度家大业大,而且名声本来就不好,但对于小公司如果遇到类似的情况,那将是毁灭性的打击。所以很多企业也会选择赔钱赚吆喝,但这吆喝对于公司的发展也是很重要的。

这其中各有各的利害关系,不怎么明白具体该如何取舍。

3.书中的16.4讲了魔方的创新的故事,大概内容是

村里本来不知道魔方。
1.A从其他地方学会了魔方的公式,靠口头传授玩法和魔方销售获得了魔方大师的称号。
2.B找出了新的魔方玩法,还承诺买魔方送口诀印本,打破了A的垄断地位(技术竞品)。
3.他们两人竞争激烈,人们很快都学会了怎么玩魔方,规则无非是比谁扭得快,市场饱和,人们厌倦(执行力比较)。
4.C改变了玩魔方的规则,利用屁股扭魔方进行表演,但很快人们也厌倦了。(改变规则)
5.D甚至都不会玩魔方,但他直接改装了魔方,通过给魔方贴上女生喜欢的贴图来吸引女性用户。(了解用户)

其中提到的一个问题引起了我的思考,
我们应该如何保护我们的创新

现在的市场已经很成熟了,不管是哪个方面都趋近饱和,所以不管是哪种新技术都会有人趋之若鹜,技术门槛不够高,那就必然会有竞品出现,哪怕是申请了专利,别人换个皮也经常很难告他侵权。所以为了保护创新,想要消灭竞争对手可以说是很不实际的。
A只是将其他地方的技术带来,对于这个村来说,确实是种创新,但这种创新却是无法长久独立保持下去的,它可以轻易地被复制,对于A来说,所谓的创新对他而言不过是一个没有拓展的公式。
而我认为,想要保护创新,最重要的是从公式入手,深挖出的藏在公式背后的原理,这些对公式的理解才是核心技术,可以利用它简化玩魔方的步骤,提高自己的竞争力,打造属于自己的品牌。说到保护一般我们都是想到藏着掖着,但想要保持技术上的与时俱进,终究不能闭门造车,要想让创新保持生命力,就不能拒绝与人分享和探讨新技术。而且可以看到创新只是一时的创新,人们终有一天是会厌倦的,不能让功利上狭隘之见切断行业生命力,比起垄断一时挣快钱,更该注重的是推动行业进步的价值观。
当然我清楚我的想法过于理想,现实情况往往是内卷过度,人人自危,你向别人分享探讨技术可能只是在给竞争对手送温暖罢了。所以我就困惑这种横竖都是亏的局面该如何找到妥协点?

4.依然还是魔方的创新的事例,在后面提到了另一个问题——

市场饱和时后来者如何取得建立优势?
我对这里与其说有所疑问,不如说有些想补充的观点,想请教一下老师和助教是否有更多的见解。
文中提到市场饱和状况下竞争者的三个选择,可以帮助后来者取得优势:

1.新找一个不知道魔方的村去卖魔方。
2.利用自己其他优势,将魔方销售捆绑在优势项目上。
3.开发有差异化的新东西,体现独特的价值。

我对文中C和D的行为分析如下:两者都是利用了第三点。C打破了外界对某产品的定式思维,不再认为产品只存在一种使用方法,D则是将产品的使用范围扩充到身为看客的女生身上,她们不玩魔方但却看魔方表演 ,与魔方实际上是存在间接的关系的,D实际上很好的把握了潜在的目标用户。
“如何取得优势”是将后来者放在一个劣势的立场上的,而在我看来,其实后来者本身自己就存在有优势:

  • 市场饱和意味着某项技术已经趋近成熟了,这意味着有充足的学习资源,入门的门槛降低了,哪怕学习的不是最新的技术,也比从零开始研发要轻松,毕竟改良型创新往往是比颠覆性创新容易的。
  • 市场饱和表示用户对这种产品已经有了一定的认识基础,相比于从零开始宣发一个新概念,站在巨人的肩膀上所需的成本是肯定要小得多的。
  • 可以不踩别人踩过的弯路,可以根据别人目前的问题进行改进。根据我查阅的资料,很多成熟的公司中各个部门其实是相互掣肘的,很多产品存在的问题不是不知道,而是过不了内部的排期和部门间的KPI扯皮,产品经理就算知道了问题,设计了解决方案,但内部的研发资源却是优先的,正常情况下各个部门就守着这一亩三分地,一个小小的改动可能就要更改整个业务流的流程,降低整个业务的效率,提高业务复杂度,这种事你同意其他部门也不同意,所以才产生了很多滴滴、美团那种“不出事就不整改”的案例。而后来的新企业就没有这种桎梏,从产品设计之初就纳入考虑范围,这就是很大的优势。
  • 正如文中体现的,市场饱和有时也意味着用户对于当前的产品已经存在厌倦心理,所以对于“创新”这一概念的包容度其实会比正常情况下要大的,只要稍微做出一点创新成果就能收获不错的成效。

5.书中的17.6提到了绩效管理,书中对于个人在团队中绩效提到

可以用二维的评价体系:完成任务维度团队贡献维度

但我认为这种评价体系过于理想化了,对于是否拥有更完备的方法抱有疑问。
在查阅资料后发现,大项目往往是由PM分成若干小部分,然后由部门主管分配给个人,在工作的过程中经常会遇到诸如对接冲突等不可控因素导致项目进度受阻,这时对于任务完成度和团队贡献度的评判会变得非常主观。
按我的理解,绩效评估的目的本身就在于激励员工持续做好的、更大的贡献,影响员工工作效率的内部驱动因素就是对工作的使命感,让员工了解自己工作的结果是否有意义,如果不能客观地评价员工的贡献,那就丧失了绩效评估的本意。

冷知识与故事

Ada Lovelace为Charles Babbage的分析机写了一个计算伯努利数的算法实现,因此被后世公认为是世界上第一个程序员。实际上,分析机由于其设计思想过于先进,在当时根本没有被制造出来,也就是说实际上并没有任何计算机能够用来运行她的程序。
后来的企业架构师们重新吸收了她的这个技能,用来学习如何更好地使用UML进行编程。(这是在讽刺现在的某些“软件架构师”顶多只会纸上谈兵地画画UML。) 编程史趣事

二、WordCount程序

项目地址

Github

PSP表格:

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 20 20
• Estimate • 估计这个任务需要多少时间 20 20
Development 开发 870 865
• Analysis • 需求分析 (包括学习新技术) 60 120
• Design Spec • 生成设计文档 20 30
• Design Review • 设计复审 30 45
• Coding Standard • 代码规范 (为目前的开发制定合适的规范) 20 20
• Design • 具体设计 100 240
• Coding • 具体编码 500 240
• Code Review • 代码复审 20 20
• Test • 测试(自我测试,修改代码,提交修改) 120 150
Reporting 报告 110 120
• Test Report • 测试报告 30 20
• Size Measurement • 计算工作量 20 10
• Postmortem & Process Improvement Plan • 事后总结, 并提出过程改进计划 60 90
合计 1000 1005

解题思路描述

  1. 程序IO模块:
    要求从文件读入数据,向文件输出数据。
    考虑封装成两个方法,给输入方法提供【输入文件名】,输出文件内容的字符串,这样计算模块只需要关心字符串是啥就行了;给输出方法提供【要输出的字符串】以及【输出文件名】。
  2. 程序的计算模块共要求四项功能,我将其分为了以下几个部分:
    • 统计字符数:
      将传入的字符串先转成char[],然后判断是否为ASCII字符,如果是,则字符数计数+1
    • 统计行数:
      最重要的就是区分是否为无效行,由于题中要求含有空白符的也算无效行,所以选择使用的正则表达式,只要正则表达式没错就没问题。
    • 统计单词数和词频:
      想到词频就想到了使用Map,键为单词,值为单词数。
      统计单词数和计算词频都需要先将文本分割成一个个有效单词,所以考虑在分割文本时既记录单词数,同时维护Map。
      分割单词和判断单词合法也使用正则表达式。
    • 获得频率前十的单词:
      考虑对Map进行排序,获得前十的entrySet。

代码规范

代码规范

接口设计与实现

将程序分为两个文件,

  • WordCount.java :处理文件IO、主函数所在文件
  • Lib.java : 专职于处理字符串的计算核心

项目结构:

具体实现:

I/O部分:

使用拥有缓冲区, 具有较高性能的BufferedReader和BufferedWriter来进行文件的存取。
选用BufferedReader的read()而非readLine()能够保证\r\n等字符不丢失。
考虑过要读取的字符串大小大于缓冲区大小(8M)的情况,去查阅了资料并翻了下源码发现BufferedReader并不需要担心这个问题(实际上测试的时候使用了80M的文件也没有出问题,想要知道答案的话有时不必看源码,自己测测就知道了),以下是我对源码的解读:
read()读取部分的源码如下:

for (;;) {
    if (nextChar >= nChars) {
        fill();
        if (nextChar >= nChars)
            return -1;
    }
    
    return cb[nextChar++];
}

画成流程图可以如下所示:

由源码得知nChars和nextChar是类成员变量,初始值为0,我们可以进一步将问题简化:

什么时候fill()完还有会有nextChar >= nChars?

其中调用的fill()方法的作用是从底层输入流填充字符到缓冲区。在fill()方法的内部,有个这样的代码:

//每次调用fill时dst重置为0
do {    
    n = in.read(cb, dst, cb.length - dst);
} while (n == 0);
if (n > 0) {    
    nChars = dst + n;    
    nextChar = dst;
}

其中cb是BufferedReader的内部缓冲区,n表示成功从底层流读入缓冲区的字符数,n最大值为缓冲区大小。
可以看到,假设我们读取一个超大文件,除了最后一次外的n值都会是缓冲区大小,最后一次会读到输入流尾,n等于剩余那部分的大小。再接着读,因为输入流此时已经为空,n就会等于0,此时就不会进入if(n>0),也就意味着nChars和nextChar的大小会保持上一次读取的最终状态,也就是它们相等,由此造成fill()完还有会有nextChar >= nChars的情况。
综上可以看出,只有当输入流的所有内容都读完read()才会返回-1,否则无论缓冲区多大都会一直读取。

同时考虑到read()调用的次数较多,所以使用StringBuilder来拼接字符串,不必像String一样不断创建销毁。
readFromFile()返回文件内容,供给Lib模块进行字符串处理。
writeToFile()只是个纯粹的输出字符串的工具,参数给什么就输出什么。

统计字符数:

与解决思路中说的一样,使用字符数组,能够针对单个字符进行合法性的验证。
当然,题目中后来补充说明了没有汉字,所以其实也可以直接用str.length()替代。

//str是文件内容字符串
char[] ch = str.toCharArray();
for(int i=0;i<ch.length;i++){
    if(ch[i] <= 127){        
        count++;    
    }
}

统计有效行数:

使用正则表达式对字符串进行匹配,每找到一个有效行就增加技术,思路很简单,主要是正则表达式的正确性要保证。
java的字符串匹配我较为陌生,去查了一下,
Pattern.compile()会对作为参数的正则表达式进行编译,返回Pattern类,可以把它当做正则表达式的对象。
matcher()相当于把正则表达式与某字符串关联(匹配),返回的就是一个匹配器matcher。
匹配器常用两个方法:matcher.matches()和matcher.find()
前者将字符串与整个正则表达式匹配,成功则直接返回true。分割完单词数组后,对单词一个个地进行单词合法性判断的时候其实就可以使用这个方法。但实际上,String本身就自带matches()方法,所以后面统计单词时我就没有用matcher.matches()。
后者第一次调用的时候会找到第一个匹配项,而后在每次调用find()时都会在上次find()的结束位置继续开始搜索。可以看到这个性质很符合我们的要求——给定一个字符串,搜索所有满足正则表达式的子串。
正则表达式的构造取了些巧,并不是只匹配有效行,而是利用一前一后的两个\\s* 将无效行吞并入有效行,当做有效行的一部分。

//private static String LINE_REGEX = "\\s*\\S+\\s*\n";
Matcher matcher = Pattern.compile(LINE_REGEX).matcher(str);
while(matcher.find()){
    count++;
}

统计单词数:

在最开始先将文本分割成一个个单词。
原本打算使用效率更高的StringTokenizer,但它不支持正则表达式。由于题中要求的分隔符并不唯一,所以想要使用的话还得先把字符串中的分隔符全都替换成一种,生成新字符串又有额外的开销,而且java新规范也不提倡使用StringTokenizer,所以就直接用split了。

由于分割单词是个比较耗时的行为,所以为了提高性能,考虑在分割文本时既记录单词数,同时维护记录着词频的Map。
具体实现时考虑到

  1. 题目中的排序要求较复杂,先按value排序,同频词还要用字典序排序,
  2. 对于没有排序需求,单单要求统计单词数的情况,使用TreeMap只会导致排序这一耗时操作很多余。

综上,没有使用有序的TreeMap,而是选择使用了性能较好但无序的HashMap,到统计词频的时候再进行排序操作。


//private static String SPLIT_REGEX = "[^a-zA-Z0-9]";
//private static String WORD_REGEX = "[a-zA-Z]{4,}[a-zA-Z0-9]*";
String[] temps = str.split(SPLIT_REGEX);
for(int i=0;i<temps.length;i++){
    if(temps[i].matches(WORD_REGEX)){
        //空间换时间, 创建一个临时对象存储小写单词, 不然toLowerCase()两遍, 对于很长的单词会降低效率.
        String word = temps[i].toLowerCase();
        if(!hashMap.containsKey(word)){
            hashMap.put(word, 1);
        }else{
            hashMap.put(word, hashMap.get(word) + 1);
        }
        count++;
    }
}

获得词频前十的单词:

考虑到程序的扩展性,将要显示的单词个数作为方法的参数,想要获得词频前十只需要传入参数10就行。
采用java8新引进的流(Stram API)来进行排序。
传统java对集合的操作,如果业务逻辑处理复杂的话很可能需要大量的迭代器进行帮助,操作相当繁琐。
而流是专门用于集合复杂操作的工具,使用流可以使得代码简化,性能提高,对于题中的排序要求,更是只需使用现成的比较器就行。
使用comparingByValue可以按value排序,使用Comparator.reverseOrder()比较器构造,即可实现按值升序。
使用thenComparing指定排序的第二优先级,而Map.Entry.comparingByKey()就是按key排序,而且就是字典序。

Map<String, Integer> map = hashMap.entrySet().stream()
                .sorted(Map.Entry.<String, Integer>comparingByValue(Comparator.reverseOrder())
                        .thenComparing(Map.Entry.comparingByKey()))
                .limit(k)
                .collect(Collectors.toMap(Map.Entry::getKey,
                        Map.Entry::getValue, (oldValue, newValue) -> oldValue, LinkedHashMap::new));

性能改进

考虑性能时分析了程序的消耗较大的一些因素:
1.分隔方式的效率。
    到底是先全部将分隔符都转为一种(比如说空格),然后用更高效率的StringTokenizer?还是直接利用split(正则表达式)进行处理?
最终选用了后者,理由见上文具体实现部分。
2.IO效率。选用最简单的FileWriter?还是选用缓冲流BufferedWriter?还是具有更高读写性能的MappedByteBuffer(NIO)?
    其实就是在后面两者中选择,但由于不了解NIO,也不太明白MappedByteBuffer的使用注意点,即使查了资料还是看的云里雾里,为了稳妥起见还是选用了后者。
3.排序算法效率,选用java8的流式?还是利用集合类中自带的sort方法?
    最终选用了前者,理由见上文具体实现部分。
4.文件存取方式:Lib模块的各个方法到底参数为文件名还是文件内容?
    最终是将Lib设计成一个只用来处理字符串的模块,文件的存取交给调用该模块的程序(即WordCount.java),这样能保证程序一次运行只会读取一次文件,在进行大文件的读取时能较大提高性能。
5.字符串拼接:
    由于大量字符串使用运算符拼接的时候会一直创建新的对象,导致浪费大量内存和性能,所以使用Stringbuffer来代替简单的String拼接。

最终性能测试结果:
10000个乱序单词:

100000个乱序单词:

1000000个乱序单词:

10000000个乱序单词:

单元测试:

使用JUnit4测试。
分成两类:
一是字符、有效行、单词数测试,使用Assert.assertEquals()将输出结果和预期的正确结果相比较看测试是否通过,方法大同小异,这里就贴一种出来。

/**
 * 测试charsCount方法
 */
@Test
public void testCharsCount() {
    String str = WordCount.readFromFile("input.txt");
    Lib lib = new Lib(str);
    Assert.assertEquals(250, lib.charsCount(str));
}

二是Map排序测试,通过输出排序后的Map,观察是否和预期的一致来判断。

/**
 * 测试getTopK方法
 */
@Test
public void testGetTopK() {
    String str = WordCount.readFromFile("input.txt");
    Lib lib = new Lib(str);
    Map<String, Integer> map = lib.getTopK(10);
    for(Map.Entry<String, Integer> entry : map.entrySet()){
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}

正确性测试用例包含了如下情况(已全部通过):
空文本,
空行,只有空白符的无效行,只有分隔符的有效行,普通有效行,
空格作为分隔符, 非空白字符作为分隔符,
少于四位的非法单词,
只有四位的合法单词, 只有四位的非法单词
多余四位的合法单词(前四位为字母),多余四位的非法单词(前四位包含数字)
全大写单词,全小写单词, 首字母大写单词,首字母小写单词,大小写混杂单词
先存入字典序靠前的同频词,先存入字典序靠后的同频词,
文件单词数少于k个(测试时指定k为10), 文件单词数大于k个。

项目覆盖率如下图所示:

覆盖率优化方法:
1.减少不必要的判断,尽量少用多if判断;
2.消除重复代码,重复代码会导致优化覆盖率时重复工作增加。
3.构造异常用例,覆盖到异常处理的路径。

异常处理:

Lib中由于将文件存取分离了,所以不存在异常处理。
WordCount负责文件的存取,所有异常均为I/O异常,比如FileInputStream的构造时如果指定的文件不存在,则抛出FileNotFoundException,而BufferedReader的read()和close()则会抛出IOException。处理方式就是捕获+异常信息输出。

心路历程与收获:

整个项目花费的最多的时间在安排项目架构和选用具体实现上。
在耦合度、性能、可移植性三个方面的取舍花了大量的时间,查了各种各样的资料,尤其是缓冲、HashMap、Stream API、正则表达式,找不到资料的地方还要去翻源码。
而实际上,安排好了项目架构、各种功能及其具体实现方式后,具体的编码阶段就十分轻松。

收获:

  • 学会了git、github的相关知识,对于版本控制等有关项目管理的知识有了新的认识。
  • 学会了利用JUnit进行单元测试。
  • 学会了通过查看覆盖率进行代码的针对优化。
  • 对Java各知识点有了更深入的了解。
  • 提高了查询资料的能力,在查询资料时发现了许多不错的资料查询方法、平台。
  • 学会了正则表达式的使用,以前只知道有这种很好用的东西,现在可以自己编写了。
  • 根据《码出高效_阿里巴巴Java开发手册》结合自己的编程习惯,规范了自己的代码风格。
  • 对文字编码、不同系统的回车、命令行输入等项目遇到的其他知识点有了了解。
posted @ 2021-03-05 11:37  抹布拉布  阅读(155)  评论(10编辑  收藏  举报