欢迎来到YangZhenXuan的博客

寒假作业2/2

作业信息

这个作业属于哪个课程 2021春软件工程实践W班 (福州大学)
这个作业要求在哪里 作业要求
这个作业的目标 1、阅读《构建之法》并提出至少五个问题
2、完成词频统计程序
3、撰写博客
其他参考文献 CSDN,bilibili,简书

目录:

  1. 阅读构建之法并提问
    1.1 问题一
    1.2 问题二
    1.3 问题三
    1.3 问题四
    1.3 问题五
  2. WordCount程序实现
    2.1 github地址
    2.2 PSP表格
    2.3 解题思路
  3. 设计,实现过程
    3.1 函数划分图
    3.2 运行流程图
    3.3 实现过程
  4. 性能改进
  5. 单元测试
    5.1 流程测试
    5.2 错误情况测试
    5.3 单元测试覆盖率
  6. 异常处理
  7. 代码规范链接
  8. 个人收获

阅读构建之法并提问

问题一

在第一章概论中有如下表述

除此之外, 软件的商业模式决定一个软件企业的成败。软件从业人员和软件企业的道德操守会极大地影响软件用户和社会。这两点不会在这门课里深入探讨。

我的困惑是软件企业的道德操守对软件用户的影响是否足够大?在道德和软件可用性之间的权重比例该如何断定,例如大家都在用的百度公司的相关软件,百度在某些方面并未达到所谓的遵守企业道德(甚至为了利益不择手段),但似乎这并不影响使用百度公司的软件的用户占我国绝大多数,其他名声较好的公司也有类似的软件以供使用,但为何仍是百度占主导呢?或许软件的可用性,功能性强大会使用户忽略了开发该软件的公司的道德操守问题。也或许作为一个普通用户,并不需要关注开发公司如何,只要软件能满足生活需要即可,在这两种想法上,该如何定义正确,或者两个都是正确的想法,只是个人想法不同?

我认为作为用户我们必须支持功能良好的软件,但同时也应该呵斥那些为了利益不择手段,不遵守道德的软件企业,当这两者冲突时,我认为后者应该权重大于前者。

问题二

第二章将软件工程师的成长中提到

  1. 通用的软件设计思想, 软件工程思想的提高

这一方面就比较虚,什么是好的软件设计思想, 什么是好的软件工程思想? 一个工程师开了博客, 转发了很多别人的文章, 这算有思想么? 另一个工程师坚持任何设计都要画 UML 图, 这算有思想么? 我个人比较重视一个程序员原创的博客, 在面试的时候, 我们别空口吹思想, 一起来看看你写的原创博客吧

确实一个经常写原创博客的人有很大概率是个优秀的软件工程师,因为他能自己总结技术经验,或者从中找出新的应用方法,而不是只是ctrl c + ctrl v复制粘贴。但我在想并不是每个人都有写原创博客的习惯,毕竟博客虽然能够很好的分享和讨论,但有些时候一些博客网站的审核较为繁琐,或者在没有连接网络的情况下无法查看博客。从个人角度出发,我是比较喜欢在一些笔记软件上记录自己的学习经历的,这些软件容易打开,并且可以保存在U盘中,分享给其他人也较容易,所以断定一个程序员是否优秀可否通过仅查看其原创博客以外的其他方式呢?是否应该多样化,尊重每一个人的不同学习习惯呢?

所以作为总结,我认为优秀的软件工程师不仅要看原创博客,还要看个人的学习笔记积累,以及自己的学习态度,良好的学习习惯永远是更加重要的,这证明能够在工学习两不误,符合软件开发这项工作的基本要求。

问题三

第三章代码规范中指出

另外,注释(包括所有源代码)应只用ASCII字符,不要用中文或其他特殊字符,它们会极大地影响程序的可移植性

我比较疑惑注释不能用中文是否是一条硬性的规则,因为我曾经有看过bilibili视频网站的源代码,虽然是用go语言写的,但一段代码上面的注释清除明白地写着“增加这个值,创建用户很多的假象。。。”,bilibili作为一个大公司不可能不会考虑到程序的移植问题,并且使用go语言证明他们已经将代码重构完成,但为何还是使用中文注释呢,从我自己的角度出发,写英文注释固然比较快速且清晰,但作为软件工程学生,我认为我的英文并没有好到能什么都用英文表示,这是否意味着在开发软件写注释的时候是否还要查询某些英文单词怎么写,是否写错,是否意味着降低了开发效率?

依我个人之见,注释必须使用英文也许只是一条建议,谁都不能保证自己写的注释语法正确且易于读懂。但对于跨国企业,注释写英文也许更能提高开发效率。

问题四

第五章团队和流程中

  1. 团队有一致的集体目标, 团队要一起完成这目标。

  2. 一个团队的成员不一定要同时工作, 例如接力赛跑,(王屋村搬砖的“非团队” 成员则不然, 每个人想搬多少就搬多少, 不想干了就结算工钱走人)

  3. 团队成员有各自的分工, 互相依赖合作, 共同完成任务

(王屋村搬砖的“非团队” 成员则是各自行动, 自行独立把任务完成,有人不辞而别, 对其他的搬砖人无实质影响

前两条都没问题,但第三条让我觉得有一些个人的想法存在,实际情况下,我认为一个团队是很难做到每个人的水平都不相上下,甚至许多情况是我们平常说的“一拖n”的情况,在这种情况下的互相分工合作是否会较为不合理?例如某个人能做A和B两件事且都能做好,而另一个人只会做B并且还没做的那么好,这时将A和B分别给这两个人完成的安排是否合理?如何找到折中的方案?

按我个人的学习经历来看,当团队中存在一些不太跟得上开发节奏的人时,在无法更换人员的前提下,应当少分配任务,或者分配较简单的,不容易出错的任务给他,此时其他水平高的人员就很有可能要承担较重的开发任务。

问题五

第六章用户需求调研中

我们在开发软件的时候,总想知道用户到底想的是什么, 对各种功能的偏好是什么, 掌握这些信息,我们就可以按部就班地去满足用户的需求。 大家可以靠直觉,靠老板的命令,靠互联网上传来的各种信息,靠拷贝其它软件, 靠其它不靠谱的手段… 当然我们也可以靠一些经过实践证明行之有效的办法。如焦点小组,深入面谈,用户调研问卷等等。

首先因为我还未参与过正式的企业级软件开发工作,故我对用户需求分析阶段的工作比较模糊和困惑,例如文中指出的焦点小组是否就是开发团队,亦或者有专门的调研团队通过整合用户口中所述的需求,从而转换为计算机方面的表示形式。如果开发团队在执行这项工作会不会导致整体开发任务受阻?还是说开发团队都必须接受这方面的训练?

以我在大学的学习情况来看,虽然学习过面向对象设计与方法这门课,但这肯定不能应对各种各样的需求环境,学校也许应该开放一门课专门教学如何从用户需求转到软件的总况设计,公司也许也应结合自己的企业特色和工作风格培养新人做到这些。

WordCount实现

github 地址

YangZX1428-PersonalProject-Java

PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划
• Estimate • 估计这个任务需要多少时间 1740 1830
Development 开发
• Analysis • 需求分析 (包括学习新技术) 135 150
• Design Spec • 生成设计文档 50 60
• Design Review • 设计复审 150 130
• Coding Standard • 代码规范 (为目前的开发制定合适的规范) 70 65
• Design • 具体设计 160 180
• Coding • 具体编码 700 760
• Code Review • 代码复审 80 60
• Test • 测试(自我测试,修改代码,提交修改) 100 80
Reporting 报告
• Test Repor • 测试报告 90 100
• Size Measurement • 计算工作量 150 185
• Postmortem & Process Improvement Plan • 事后总结, 并提出过程改进计划 55 60
合计 1740 1830

解题思路

一看到题目要求,我便想到这个题目涉及IO操作,集合操作,正则表达式等方面。

因此可以将整个程序划分为几个功能块

  • 统计字符数
  • 统计有效单词数
  • 统计有效行数
  • 将单词计数并按需求输出结果映射表
  • 根据结果映射表生成输出内容并写入输出文件中

1、保存统计结果

初步构想的时候认为统计结果可以直接保存在main函数所在的类中,例如通过私有静态变量保存,再在程序运行中动态改变。
认真想了一下这可能并不是一个好的方案,一个是因为这些变量不方便管理,变量本身的定义与执行程序紧密耦合在一起,若其他程序要使用变量,必须访问主程序,这样也可能导致一些安全问题。
因此,我设想将这些统计结果单独通过一个管理类保存起来,既能解耦变量与主程序的关系,也能控制其安全性,例如可以只提供几个访问这些变量的公共方法,防止其他程序不正当地修改这些变量的值。

2、I\O操作方面

Java的I/O操作首想便是FileInputStream类或者FileReader类,但在使用之前必须确保这两个类读取字符的方式与作业要求必须一致,例如可能这两个类读到制表符会当做四个字符,而作业要求是吧制表符当做一个字符来看(实际上在文本编辑器中使用tab键时,FileInputStream会把制表符当做一个字符来计算,但是在IDEA中使用tab时,又会被当做四个空格,即四个字符判断,因此在这方面我无法做很全面的判断,只能默认输入的文件中的制表符都是通过文本编辑器添加的)。
除了字符,单词和行数涉及每一行的数据,因此需要其他的读取方法按行读取数据。因为按流的方式读取行数较为简单快速,故考虑通过流读取数据,在将该流转化为List集合方便后续理。
写数据使用FileWriter类较为简单,且可以直接写入字符流,方便快速。

3、读取单词

读取单词考虑是通过获得的每行的字符串,通过split方法分隔开每个单词,分隔符初始时考虑一个或多个的空格或者一个或多个制表符(后续发现,逗号,分号,冒号,感叹号,问号等都需要成为分隔符.PS:提交代码时发现分隔符只要取非字母数字以外的任何字符或字符组合即可,一下子简化了正则表达式的书写,也提高了程序的正确性)。读取得的单词必须要满足要求的规则,即再次通过正则表达式判断该单词字符串是否以4个英文字母开头,若不是则不予以计数。

4、排序结果

这一步是我在构思的时候最模糊的一个问题,因为在正式查资料前,我并不确定LinkedHashMap能否进行排序,例如将键值对存进LinkedHashMap中,在调用一个简单的sort方法,传入排序规则即可完成排序(后来发现,正确排序map要做的工作比这多得多)。

设计,实现过程

整个项目共有四个java文件

  • WordCount.java : 主流程main所在文件
  • CountResultHolder.java : 统计数据容器
  • Utils.java : 包含一些辅助方法
  • Counters.java : 包含计数单词,字符,有效行数等方法

函数划分图

运行流程图

实现过程

1、创建统计结果容器

为了方便保存统计结果,便于不同的程序文件访问和测试,我创建了一个容器类CountResultHolder

在读取文件的时候动态改变容器中相应统计量的值,并在输出时直接通过容器访问。

测试时直接跟踪该容器中统计量的值,与测试用例结果进行断言比对。
优化代码后添加了对容器数据的安全性访问,外部只能通过方法来对数据进行有限制的修改。

/**
 *  save the counting result of valid characters ,lines and words
 */
public class CountResultHolder {
    private static int charactersCount = 0;
    private  static int linesCount = 0;
    private static int wordsCount = 0;
    private static Map<String,Integer> wordToNumMap = new LinkedHashMap<>();

    public static void increaseCharactersCount(){
        charactersCount ++;
    }
    
    // 省略其他安全访问方法
    ...
}

2、读取文件内容

一开始我有想过一次读取就计算完所有指标数据,但后来我发现这样会导致一些问题。例如为了获得有效行数以及单词数,我需要按行读取整个文本文件,若只按这样读取,则字符计算会出现问题,例如不会计算换行符,且制表符\t会被计算为四个空格(四个字符)而不是一个字符,因此我决定读两次文件,一次计算字符数,一次通过流获得每一行的信息,再计算单词总数。

Path inputFilePath = Path.of(filename);
Stream<String> linesStream = Files.lines(inputFilePath);
return linesStream.collect(Collectors.toList());

3、单词映射表排序

通过正则表达式过滤单词并保存每个单词的个数进HashMap后,需要对该HashMap进行排序,通过将该map的entrySet转化成集合流,并调用sort方法即可排序,在排序规则中我指定了若单词数不同,则按单词数排序,若单词数相同,则按照单词的字典顺序排序排序结果存入LinkedHashMap(能保存键值对的顺序情况)。经测试,符合作业要求。

  • PS :若使用输入文件为较大文本量(例如亿级别字符量),则程序可能报错Comparison method violates its general contract!,是因为对Map应用排序算法,因为排序规则比较特殊,很可能会导致排序必须满足的三个规则之一的传递性不满足,故在JDK 7版本使用TimSort作为底层排序方法后,不满足传递性的排序规则执行时将会抛出错误。

解决办法:在运行时添加JVM启动参数-Djava.util.Arrays.useLegacyMergeSort=true,使用JDK 6 的底层排序规则。

wordsToNumMap.entrySet()
        .stream()
        .sorted((p1, p2) -> {
            // 单词数不一样则按单词数目排序
            if (p1.getValue() != p2.getValue()) {
                return p2.getValue().compareTo(p1.getValue());
            } else {
                // 单词数一致则按字典顺序排序
                return compareStringByDict(p2.getKey(),p1.getKey());
            }
        })

4、统计单词数

单词数的统计通过表达式进行过滤,计数,两次正则表达式分别用来分隔每一行的内容得到单词字符串,以及过滤无效单词。

有效的单词将会被合并(merge)到Map中,我曾用两个if语句大概七八行的代码完成“若map中不存在该单词,则加入该单词,value设为1,若存在该单词,则value++”的逻辑,后来发现直接调用merge方法可以一行便完成上述所有工作。

public static Map<String, Integer> countWordsAndTransform(List<String> list) {

    Map<String, Integer> wordToNumMap = new HashMap<>();
        // 正则表达式匹配四个英文开头的有效单词
        Pattern pattern = Pattern.compile("[A-Za-z]{4}.*?");
        list.forEach(line -> {
            String[] strings = line.split("[^a-zA-Z0-9]");
            for (String word : strings) {
                String w = word.trim().toLowerCase();
                Matcher matcher = pattern.matcher(w);
                // 计数有效单词并将其加入映射集合
                if (matcher.matches()) {
                    CountResultHolder.increaseWordsCount();
                    wordToNumMap.merge(w, 1, Integer::sum);
                }
            }
        });
    return wordToNumMap;
}

性能改进

性能改进方面,我试过使用并行的方式实现计数程序,通过使用CountDownLatch类,将总流程分为两个线程,其中一个线程计算字符数,另一个线程完成剩余任务,两个线程都执行完毕时程序结束。

            CountDownLatch c  = new CountDownLatch(2);
            ExecutorService service = Executors.newCachedThreadPool();
            service.execute(new CounterCharacterTask(c,inputFile));
            service.execute(new CounterOtherTask(c,inputFile,outputFile));
            c.await();

对于单词数为15万,字符数148万的文件
以下是程序运行五次,每次的用时情况

程序运行时间: 977ms
程序运行时间: 1783ms
程序运行时间: 1448ms
程序运行时间: 1741ms
程序运行时间: 1153ms

而使用串行的方式运行五次,每次的用时情况如下

程序运行时间: 885ms
程序运行时间: 941ms
程序运行时间: 1022ms
程序运行时间: 1000ms
程序运行时间: 935ms

明显串行流程耗时更少,推测是因为线程管理增加了额外的时间开销,故不采取该优化方案。

之后经网上查证,得知读取文件使用BufferedReader会比使用FileReader更合适,故使用BufferedReader代替

        BufferedReader fileReader = null;
        try {
            fileReader = new BufferedReader(new FileReader(filename));
        ....
}

程序耗时有些许减小

程序运行时间: 920ms
程序运行时间: 912ms
程序运行时间: 869ms
程序运行时间: 930ms
程序运行时间: 854ms

运行结果

单元测试

流程统计测试

我创建了一个枚举类,用来表示测试用例,该枚举类包含了这个测试用例的所有期望值

// 该测试用例期望字符数为1725,行数为6,单词数为184
// 该测试用例使用的输入文件保存在TestEnum枚举类对象中
COUNTER_TEST_INSTANCE_1(1726,6,184,
            Map.of("those",9,
                    "have",7,
                    "make",6,
                    "that",6,
                    "want",6,
                    "enough",4,
                    "life",4,
                    "with",4,
                    "when",4,
                    "your",4),
            TestEnum.TEST_INSTANCE_1);

测试该用例,运行程序后,统计量容器CountResultHolder中保存了程序结果,该结果用来与测试用例期望值比对。

   /**
     * 测试计数功能
     */
    @Test
    public void counterTest(){
        // 测试用例1
        CounterEnum testInstance1 = CounterEnum.COUNTER_TEST_INSTANCE_1;
        TestEnum testFiles = testInstance1.getTestFiles();
        WordCount.main(testFiles.getArgs());
        assertEquals(testInstance1.getResultCharactersCount(),CountResultHolder.charactersCount);
        assertEquals(testInstance1.getResultLinesCount(),CountResultHolder.linesCount);
        assertEquals(testInstance1.getResultWordsCount(),CountResultHolder.wordsCount);
        assertEquals(testInstance1.getResultWordToNumMap(),CountResultHolder.wordToNumMap);
    }

错误情况测试,我创建了枚举类TestEnum用于模拟各种错误情形>

每个测试用例携带一个期望码code,在程序结束后,程序中的statusCode变量保存了执行流程的状态信息,例如执行成功,则statusCode的值为1000。

    // 命令行参数错误,期望返回code为1001
    PARAMETER_ERROR_TEST_INSTANCE("input.txt", 1001),
    // 文件类型错误
    FILE_TYPE_ERROR("input.java", "output.txt", 1002),
    // 程序正常
    SUCCESS_PROCESS("input.txt", "output.txt", 1000),
    //文件未找到
    NO_FILE_FOUND("not_found.txt", "output.txt", 1003),
    // 文件内容为空
    NO_CONTENT_ERROR("nocontent.txt", "output.txt", 1005),
    // 文件名字错误
    FILE_NAME_ERROR("abc","abc",1002),
    // 测试用例
    TEST_INSTANCE_1("test_input.txt","test_output.txt");

这样就可以方便的进行集成测试,例如下面这样

        // 缺少参数的情况
        TestEnum paramsErrorTest = TestEnum.PARAMETER_ERROR_TEST_INSTANCE;
        WordCount.main(paramsErrorTest.getArgs());
        int paramsErrorProcessResult = WordCount.getStatusCode();
        assertEquals(paramsErrorProcessResult,paramsErrorTest.getResultCode());

单元测试覆盖率

优化覆盖率

避免将if控制块写成三目运算符,在使用特殊的集合操作前考虑是否要进行细化,判断特殊情况等。

异常处理

我将所有错误都在主涵数main中进行集中处理(除了关闭文件连接可能发生的错误会在函数中处理)。当程序发生错误的时候,我不选择直接将异常返回给用户,而是打印具体的错误信息给用户,我认为这样会使程序的使用过程更加人性化。
例如传入文件无法找到,则会在窗口返回信息

No Such File Found!

代码规范链接

codestyle.md

个人收获

1、一个比较重要的收获就是让我更加熟悉了git的使用,曾经在一些项目中使用过git,不过也只是简单的将项目提交到github上,而这次的作业让我知道了如何拉取别人的仓库代码,再将自己的项目提交到远程仓库上,使用懂了更多git的使用方法,为以后版本管理方面的学习打下基础。

2、通过阅读构建之法这本书的内容,我了解了许多软件从构思,开发到测试,发布,运维的一生中充满了许多有趣的事情,作者通过幽默风趣,通俗易懂的讲解让我明白了各种各样的流程,也让我明白了各种各样的道理,例如团队的重要性,以及如何对软件好坏进行评估,如何优化软件的使用等等。通过阅读书籍,也促进了我对许多方面事情的思考与理解,增加了我的阅历,让我对软件工程这门学科有了更深的了解,也使我更加有学习动力。

3、这次作业让我第一次体验到了单元测试的魅力,从前的我根本不知道测试有什么重要,该如何实现等,仅仅只是知道有这个东西而已,写的程序只要跑过一遍没有问题就觉得一定可用了。然而这次在各种情况的单元测试中,我发现了程序中的许多bug,通过不断设计各种情形下的数据输入,我也绞尽脑汁,同时也深刻感受到了测试的有趣,一方面想方设法构造奇怪的数据企图让自己的程序崩溃,或出错,一方面又不断根据测试结果完善程序,提高程序的稳定性,使程序能应对不同的情况,这也是我所感兴趣的事情之一。这次作业让我深深感受到了测试的重要性,让我有基础取学习进阶的测试相关方面的知识。

posted @ 2021-03-04 14:25  YamaiKaguya  阅读(123)  评论(2编辑  收藏  举报