寒假作业 (2/2)

寒假作业 (2/2)

作业描述

这个作业属于哪个课程 2021春软件工程实践|W班(福州大学)
这个作业要求在哪里 软工实践寒假作业 (2/2)
这个作业的目标 1. 阅读《构建之法》并提问
2. WordCount编程
其他参考文献

阅读《构建之法》并提问

2.3 中提到了 PSP 依赖于数据

​ 本次作业中,我也发现了PSP记录并不完整,很多东西并不存在PSP记录表内,除此之外计算时间的精确度也非常影响,很多时候你无法确定你接下来做的事情恰好属于某一个内容,请问如何准确记录?

4.3 中提到了函数最好有单一的出口,为了达到这一目的,可以使用goto。

我认为无论如何 goto 语句都是最好不要使用的语句,会让代码的逻辑混乱,造成更多不可知的bug 很多时候根本可以用其他的语法取代,因此我认为无论如何都不应该用goto。这是我与书中相悖的地方

找不到了 中提到了程序过早优化的问题,说尽量不要过早优化

​ 如果程序不尽量在早期优化的话,会导致回归测试需要做多遍,优化的度是什么样比较合适?

9.3 PM做开发和测试之外的所有事情

​ 这个对PM的要求是不是太高了?据我所知软件工程的开发过程还需要运营?

12.2 中说道程序员不该等待设计师的图后再工作

​ 我认为这里说的实在是太简单了,如果是一开始的话,可以进行架构等方面的设计,但是如果到了绘制页面的阶段的时候,我们已经把功能做完了,没设计师的图,我们干啥?难道不是还是要等吗?

WordCount 编程

项目地址

PSP表格

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

解题思路

1. github

​ 之前做项目的时候经常用到 github,这次在查询 github PR 最佳实践的时候发现了一个之前几乎没用过的 git rebase ,印象里这个指令和 git merge 差不多,但是以往的项目一般都是用 git merge 上网查询了差别后,发现 git rebase 可以使提交线变为一条直线。merge 后会有额外的一次 commit。感觉使用上两者其实都行,merge 比较经常用,因此还是用 merge 合并分支。

2. 代码规范

​ 做项目一直用的是 vscode + eslint 配合检查语法规范,有些项目会在 pre-commit 钩子函数检查语法。会根据助教的问题 配合 airbnbJS 语法规范以及与规范的冲突部分撰写。

3. 基本需求

​ 看到基本需求部分,统计文件的字符数,单词总数以及有效行数等都可以用正则表达式进行解决,唯一比较困惑的地方是空白字符是什么,经过查阅呢,了解到,只要是看不见的字符都可以称作空白字符,这个正则表达式也有相应的解决方法。不过在统计单词出现的次数这里,emm ,想到了之前好像有用过 Trie 实现,查阅后发现只适合比较短的单词,这里的单词至少4个英文字母,又要跟上字母数字符号,因此实际上深度造的 Trie 树深度会很深,而且分支会很大,恐怕造成的内存开销会很大,因此还是采用最简单的Map形式实现就好,由于需要遍历一遍文档查找单词,因此时间复杂度至少是O(n),因此也没必要过多优化,在寻找频率最高的10个的时候直接用 Map 遍历一遍就行了,没必要单独维护一个数组记录,最后直接产出,因为算法的时间复杂度不会有根本性的差异,简化实现反而可以避免 BUG。查询正则表达式的时间复杂度……

4. 接口封装

​ 这个接口封装部分,其实在 node 里面,还算是比较常规的操作了,通过模块化的方式,把接口分离,需要解决的问题是,node 如何写命令行程序,单元测试如何配合 node 进行使用,之前了解过单元测试框架 jest 。顺便了解一下 e2e 框架是什么,原来是和 http 有关的,那打扰了。数据的可视化部分实际上可以采用 echarts 或者 G2 等图形化界面进行展示,也就是说产出的接口可能需要满足一定的格式要求。

5. 单元测试

​ 单元测试部分,提到了用白盒测试,并不清楚白盒测试是指什么,查阅后似乎就是要尽量的覆盖到测试的各个部分,例如各个条件分支等。

6. 性能分析

​ 关于性能分析这一块的话,node 层面要做的话,node 并没有原生自带性能分析的软件,需要配合一些性能分析的工具,经查阅可以采用Node-Monitor 进行项目的性能分析。可以配合 git 钩子函数进行单元测试验证,如果不通过的话,那么就阻止 commit

7. 项目结构

​ 本次作业并没有对 node 的项目结构进行约束,只要大致符合提供的 C++ 或者 Java 的项目结构就行了,为了方便助教进行测试,直接采用 npm scripts的形式书写,不过可能需要自行修改脚本内输入输出文件的路径。经查阅也可以采用 process.argv 的形式获取 npm run xxx 的参数。

代码规范制定链接

设计与实现过程

  • 功能文件下面设置 getcal 函数,并从模块导出,可以在各个地方使用。
  • 由于需要过滤汉字,作为模块的统一需求,提取成一个文件 filterChinese
  • 正则表达式常量放入 regex.js 文件方便维护以及防止书写错误。
  • 总结来说总体结构如下,各文件内容助教可以检查。

1. 总体结构

221801107
│  .gitignore
│  codestyle.md
│  README.md
│  
└─src
        character.js
        filterChinese.js
        heap.js
        index.txt
        regex.js
        row.js
        word.js
        wordCount.js

2. 字符处理

获取 filterChinese 函数,过滤后的长度即是字符个数。并从模块导出这两个函数

const filterChinese = require(".filterChinese");

// 获得字符数组
const getCharacter = (content) => filterChinese(content);

// 统计字符数量
const calCharacterCount = (content) => getCharacter(content).length;

3. 行处理

可以通过换行字符 \n 统计行数,通过 trim 函数判断是否为空检测空行。并导出相关函数。

// 统计行数
const calRowsCount = (content) => content.split(/\n/).length;

// 统计空行数
const calEmptyRowsCount = (content) => content.split(/\n/).filter((row) => row.trim() === "").length;

// 统计非空行数
const calNoEmptyRowsCount = (content) => calRowsCount(content) - calEmptyRowsCount(content);

4. 单词处理

  • Filefile 算一个单词,因此先进行小写转换,再用单词分割符分割,再过滤不是单词的例如 fil 这样的。
  • 获得单词的频率写了一个函数,由于是哈希映射,时间复杂度是O(n),而读入文档时间复杂度至少是O(n)级别的,没必要做更多优化
  • 统计排序后的单词数也是直接排序后导出即可,这里为了适应于各个平台,导出为
{
    word: "xxx",
    count: 111,
}

的形式,方便做修改。

const { WORD_SPLIT_REGEX, WORD_REGEX } = require("./regex");
const filterChinese = require("./filterChinese");

// 获得所有单词,返回一个单词数组
const getWord = (content) => filterChinese(content)
  .toLowerCase()
  .split(WORD_SPLIT_REGEX)
  .filter((word) => WORD_REGEX.test(word));

// 统计单词数量
const calWordCount = (content) => getWord(content).length;

// 统计单词频率
const getWordsFrequency = (content) => {
  const wordArr = getWord(content);
  const wordMap = new Map();
  wordArr.forEach((word) => {
    if (!wordMap.has(word)) {
      wordMap.set(word, 0);
    }
    const count = wordMap.get(word);
    wordMap.set(word, count + 1);
  });
  const ret = [];
  wordMap.forEach((value, key) => {
    ret.push({
      word: key,
      count: value,
    });
  });
  return ret;
};

// 排序单词
const calSortedWordsFrequency = (content, count) => {
  const arr = getWordsFrequency(content);
  const sortArr = arr.sort((a, b) => {
    if (a.count === b.count) {
      return a.word < b.word ? -1 : 1;
    }
    return b.count - a.count;
  });
  if (typeof count === "undefined") {
    return sortArr;
  }
  return sortArr.slice(0, count);
};

// 优化部分有堆排序单词

module.exports = {
  getWord,
  calWordCount,
  getWordsFrequency,
  calSortedWordsFrequency,
};
  1. 作业中还要求独到之处。
    • 独到之处可能是代码比较精简,整体功能代码应该不超过150行,如果算上性能改进的堆排序,代码行数大概在 250行左右。
    • 模块清晰,颗粒度小,各个函数都可以直接导出供测试。

性能改进

1. 采取整个文件读取的方式进行文件的读取。

JS 是单线程的,因此不可能在线程上做文章。采用整个文件直接读取,而不是分行读取的方式可以减少 IO 的中断次数,加快读文件。

2. 算法时间复杂度优化

大部分都是用正则表达式以及内部的函数,说实在的没什么好做性能改进的。原因是 split 以及 filter forEach 这些函数全都是 O(n) 级别的。由于需要遍历文档字符串,因此不可能有明显的改进。

不过对于 sort 函数是 O(nlogn) 倒是可以进行改进,因为只需要前十个排好序就可以了,那么可以采用堆排序,使时间复杂度降低至 O(nlogk) 这里 k 是 10, 也就是可以降低至 O(3n) 的级别。在单词数量非常多的情况下,会有一定的性能改进。由于 JS 默认没有堆的实现。因此手写了堆,在 heap.js 文件。

写完堆后采用堆排序

const calSortedWordsFrequencyByHeap = (content, count) => {
  const arr = getWordsFrequency(content);
  const heap = new Heap(count, (a, b) => {
    if (a.count === b.count) {
      return a.word < b.word ? -1 : 1;
    }
    return b.count - a.count;
  });
  arr.forEach((value) => {
    heap.add(value);
  });
  const ret = [];
  while (!heap.empty()) {
    ret.push(heap.top());
    heap.pop();
  }
  return ret.reverse();
};

另外进行单元测试

test("can calculate right sort by heap", () => {
  const test1 = "huro huro lero";
  expect(
    calSortedWordsFrequencyByHeap(test1)
      .map((item) => `${item.word}: ${item.count}\n`)
      .join(""),
  ).toBe("huro: 2\nlero: 1\n");
  const test2 = "windows95 windows2000 windows98";
  expect(
    calSortedWordsFrequencyByHeap(test2)
      .map((item) => `${item.word}: ${item.count}\n`)
      .join(""),
  ).toBe("windows2000: 1\nwindows95: 1\nwindows98: 1\n");
});

通过单元测试。

对于 1e7 个字符,经过上述改进运行时间有减少大约一半的变化,或许针对更大的文件会有更好的提升

使用 heap

未使用 heap

单元测试

1. 字符函数测试

  • 测试是否忽略中文字符
  • 测试是否计算 ASCII 字符
  • 测试是否能够计算空格,制表符等特殊字符
test("can ignore chinese character", () => {
  expect(calCharacterCount("嗨")).toBe(0);
});

test("can calculate ASCII character", () => {
  expect(calCharacterCount("abc")).toBe(3);
});

test("can calculate ' ', '\t', '\n'", () => {
  expect(calCharacterCount("\n\t ")).toBe(3);
});

2. 单词测试

  • 测试是否能区分单词
  • 测试是否能获得正确的单词数量
  • 测试是否单词按照约定顺序进行排序
  • 测试是否忽略了大写
test("can get right word", () => {
  expect(calWordCount("abc123")).toBe(0);
  expect(calWordCount("abc")).toBe(0);
  expect(calWordCount("abcd123")).toBe(1);
  expect(calWordCount("abcd")).toBe(1);
  expect(calWordCount("abcde")).toBe(1);
  expect(calWordCount("abcd##")).toBe(1);
});

test("can calculate right words count", () => {
  expect(calWordCount("")).toBe(0);
  expect(calWordCount("abc123 abcd123")).toBe(1);
  expect(calWordCount("abcd123 abcd123")).toBe(2);
});

test("can calculate right sort", () => {
  const test1 = "huro huro lero";
  expect(
    calSortedWordsFrequency(test1)
      .map((item) => `${item.word}: ${item.count}\n`)
      .join(""),
  ).toBe("huro: 2\nlero: 1\n");
  const test2 = "windows95 windows2000 windows98";
  expect(
    calSortedWordsFrequency(test2)
      .map((item) => `${item.word}: ${item.count}\n`)
      .join(""),
  ).toBe("windows2000: 1\nwindows95: 1\nwindows98: 1\n");
});

test("can ignore uppercase", () => {
  const test = "huro Huro lero";
  expect(
    calSortedWordsFrequency(test)
      .map((item) => `${item.word}: ${item.count}\n`)
      .join(""),
  ).toBe("huro: 2\nlero: 1\n");
});

3. 行测试

  • 测试是否获得正确的行数
  • 测试是否获得正确的空行数
  • 测试是否获得正确的非空行数
test("can get right rows count", () => {
  expect(calRowsCount("xxx")).toBe(1);
  expect(calRowsCount("xxx\nxxx\n")).toBe(3);
});

test("can get right empty rows", () => {
  expect(calEmptyRowsCount("xxx")).toBe(0);
  expect(calEmptyRowsCount("xxx\nxxx\n")).toBe(1);
});

test("can get right no-empty rows", () => {
  expect(calNoEmptyRowsCount("xxx\nxxx\n")).toBe(2);
});

4. 一键测试

​ 之前可以直接运行 yarn test 进行一键测试,测试结果如下。没有下载 yarn 的也可以用 npm run test 进行测试。后来由于目录结构要求删掉了,但是保留了截图。可以配合git 钩子实现提交检测

5. 覆盖率测试

异常处理

由于函数进行了封装,内部调用不会出现传递参数异常的情况,只需要对用户的命令行输入做处理即可,对于程序内部的错误,告知用户是程序内部的错误,让其 File 作者。

1. 处理输入文件不存在的情况

if (!fs.existsSync(input)) {
    console.error("Error: readFile not exist");
    return;
}

2. 处理命令行参数无输入文件或输出文件的情况

const argvs = process.argv;
if (argvs.length < 4) {
    console.error("Error: please input two files");
    return;
}

3. 其他错误可以打印 ex.message 进行查看

try {
	// ...
} catch (ex) {
    console.error(ex.message, "please file xxx");
}

心路历程和收获

  • 学会怎么配置 eslint
  • 知道 webpack 打包的项目应该用于 brower
  • 知道 .eslintignore只能在工作区根目录生效,不过可以通过 settings 配置使其在其他路径下也能生效。
  • 知道 webpack v5 相比 webpack v4 少了 node polyfill 优化性能。
  • 了解命令行程序如何传参
  • 了解白盒测试是什么
  • 了解 git rebasegit merge 的区别
posted @ 2021-02-24 14:43  Huro~  阅读(183)  评论(3编辑  收藏  举报