项目 | 内容 |
---|---|
本次作业所属课程 | 2019BUAA软件工程 |
本次作业要求 | 结对项目-最长单词链 |
我在本课程的目标 | 学会团队合作开发项目,为以后的工作打下基础 |
本次作业的帮助 | 了解结对编程并且体验完整项目开发流程 |
一、GitHub项目地址
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 90 |
-Estimate | -估计这个任务需要多少时间 | 60 | 90 |
Development | 开发 | 3200 | 3300 |
-Analysis | -需求分析 (包括学习新技术) | 200 | 80 |
-Design Spec | -生成设计文档 | 200 | 180 |
-Design Review | -设计复审 (和同事审核设计文档) | 150 | 90 |
-Coding Standard | -代码规范 (为目前的开发制定合适的规范) | 150 | 60 |
-Design | -具体设计 | 200 | 240 |
-Coding | -具体编码 | 1500 | 1800 |
-Code Review | -代码复审 | 200 | 210 |
-Test | -测试(自我测试,修改代码,提交修改) | 600 | 640 |
Reporting | 报告 | 400 | 900 |
-Test Report | -测试报告 | 210 | 720 |
-Size Measurement | -计算工作量 | 90 | 60 |
-Postmortem & Process Improvement Plan | -事后总结, 并提出过程改进计划 | 100 | 120 |
合计 | 3660 | 4290 |
三、看教科书和其它资料中关于Information Hiding, Interface Design, Loose Coupling的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的
In computer science, information hiding is the principle of segregation of the design decisions in a computer program that are most likely to change, thus protecting other parts of the program from extensive modification if the design decision is changed. The protection involves providing a stable interface which protects the remainder of the program from the implementation (the details that are most likely to change).
Written another way, information hiding is the ability to prevent certain aspects of a class or software component from being accessible to its clients, using either programming language features (like private variables) or an explicit exporting policy.
信息隐藏是为了不让程序内部的信息直接暴露给用户,为了在设计被改动的时候保护其他部分。
接口在这里就扮演着很重要的角色。类只向外界提供它们实现的接口中规定的方法,所有属性皆为私有。
In computing and systems design a loosely coupled system is one in which each of its components has, or makes use of,little or no knowledge of the definitions of other separate components.
松耦合就是在设计模块的时候只用到了很少或几乎没有的其他模块中的东西,像数据,接口,服务等等。
在我们的程序中,就实现了计算模块函数的封装,只提供直接可用的接口给用户,根据需求对core模块进行封装,生成的.dll文件在命令行程序和GUI中都可以进行使用,而不会暴露给用户。在在互换core模块的时候也较为顺利。
四、计算模块接口的设计与实现过程
-
接口:项目中计算模块接口的设计采用了要求中统一的API,即:
# 最多单词数 int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop); # 最多字母数 int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop)
由于在第一次阅读作业要求时已经了解了固定API的要求,因此我们在第一次实现时已经采用了这个结构。具体算法采用树实现,而不是其他组普遍使用的图结构。
-
类:共有两个类和四个自定义异常结构体。其中Core类为封装好的计算模块,用于计算最长链,node类为树节点信息,自定义异常则用于抛出异常时输出信息。具体结构如下:
class node { public: string word; node* parent; node* first_child; node* next; int word_num; int character_num; __declspec(dllexport) node(string cur_word, int cur_word_num, int cur_character_num); }; class Core { public: __declspec(dllexport) int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop); __declspec(dllexport) int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop); __declspec(dllexport) bool gen_tree(node* cur_node, char* words[], int len, bool enable_loop, char tail, node* word_max_node, node* char_max_node, int words_index[][2]); __declspec(dllexport) bool find_in_chain(node* cur_node, string word); };
自定义异常参见第九节。
-
函数:计算模块共有五个函数(不包括node类的构造函数),除去API的两个函数gen_chain_word()和gen_chain_char()外,还有递归生成树函数gen_tree(),在树中查找函数find_in_chain()和排序时的比较函数compare()。关键函数的流程图如下:
-
基本算法为:首先对输入的所有单词words进行排序,并建立索引。此后将每个单词作为一棵树的根节点建树。建树时根据索引,遍历所有满足和父节点连接条件的子节点,查找该子节点是否在父节点所在链上出现过。若出现过,根据enable_loop标志判断是否抛出成环的异常;反之则将子节点加入树。为了简化查找过程,我们在建树的过程中就计算最长单词和最长字母的节点。当所有树建好,可直接通过得到的最长单词链的叶节点向上遍历,找到整个链并输出。因此,我们项目的独到之处包括:使用树结构、建立索引、建树完成后无需再遍历寻找最长链、自定义异常类型和创新异常单元测试方法。具体内容将在第六节和第九节详细说明。
五、阅读有关UML的内容,画出UML图显示计算模块部分各个实体之间的关系。
六、计算模块接口部分的性能改进
我们运行了一个有50个单词的允许成环的文本。优化后的性能分析结果如下:
在初次运行时,我们发现-r参数下70个单词已经无法在300s内运行完成。在调试的过程中,我们发现,时间最长的部分是建树的过程gen_tree(),上图也印证了这个猜想。因此我们决定对words排序后建立索引,在建树时只需遍历索引指向的首位范围即可。这样做会减慢小数据的速度(5个单词时的运行速度由4ms变为了12ms),但会提升数据较多时的性能。但当实现之后发现,对大数据量时改进仍然不够令人满意。我们也尝试过对排序后的words去重,但是在单元测试时发现,代码:
int cnt = 1;
for (i = 1; i < len; i++) {
if (strcmp(words[i], words[i - 1]) != 0) {
strcpy_s(words[cnt++], strlen(words[i]) + 1, words[i]);
}
}
len = cnt
中的strcpy部分似乎会在单元测试中抛出异常,我们并未查到bug的原因。在网上的唯一解释是由于被赋值的指针指向了字符串常量,不能被修改,但这个解释并不符合。尽管在release版本中这部分代码可以通过,最终为了保险起见,我们删除了去重功能。
花费时间最长的函数是find_in_chain(),其作用是在找到一个首字母可以和当前节点尾字母相连的节点时,从当前节点向上查找,如果出现过这个单词,则判断是否有-r参数。若没出现过,则加到当前节点后面。我们曾想过是否要将遍历改为维护一个“单词是否出现过的标志数组”的形式,但发现这样修改非常复杂,时间有限,没能实现。
此外,生成树以后重新便利寻找最长链也是没有必要的时间消耗,我们在建树过程中,直接记录下当前节点的链的单词数和字母数,使得在树建完后,直接得到最长链的叶节点,反向遍历即可得到整条链。
七、看Design by Contract, Code Contract的内容,描述这些做法的优缺点, 说明你是如何把它们融入结对作业中的
Design by Contract(契约式设计)
It prescribes that software designers should define formal, precise and verifiable interface specifications for software components
共同定义一个精确的接口,知道先验,功能和影响。
-
优点:
-
保证模块的正确性
-
复用容易
-
文档和设计都是经过精心的撰写,质量比较高
-
可靠性较强
-
-
缺点:
- 正因为很难修改,导致有一些错误和需要扩展的时候变得更麻烦
- 建立契约的过程较为麻烦,同样,耗时就会比较长。
此次作业中,就是已经设定好了一个模块——core,在这个模块中的函数也是给定的两个接口。给定这些接口就让我们对这个项目的核心算法有了统一的认识和设计思路,即主要有两个功能,一个通过-w参数,一个通过-c参数来实现生成最长单词链的过程。
但是,这时候,缺点就体现出来了。在使用命令行运行程序的过程中,我们输入的命令都应该是一个不会变的,固定的常量,因此,将接口中的char*改为const char*才更符合编程的规范。但由于契约式的编程,无法修改既定的接口规则导致了这些方面的矛盾。
八、计算模块部分单元测试展示和覆盖率报告的生成
-
部分测试代码展示
TEST_METHOD(UnitTest_gen_tree5) { node1 = new node(cur_word, cur_word_num, cur_character_num); word_max_node = new node("", 0, 0); char_max_node = new node("", 0, 0); char head = 0, tail = 0; int len = 6; char* words[6] = { "aj", "jhgjh","hjhjbdkjhaksjdfhkjhkjhjd" ,"hdfdrp","pd","ddfghj" }; Assert::AreEqual(core_test.gen_chain_word(words, len, result, head, tail, true), 5); Assert::AreEqual(core_test.gen_chain_char(words, len, result, head, tail, true), 5); } TEST_METHOD(UnitTest_gen_tree6) { char* words[5] = { "aj", "cdfdrp", "ddfghj", "hhgjh", "sjhjb" }; word_max_node = new node("", 0, 0); char_max_node = new node("", 0, 0); char head = 0, tail = 0; int len = 5; Assert::IsFalse(core_test.gen_tree(node1, words, len, false, 0, word_max_node, char_max_node, words_index)); } TEST_METHOD(UnitTest_find_in_chain3) { char* words[5] = { "aj", "cdfdrp", "ddfghj", "hhgjh", "sjhjb" }; Assert::IsFalse(core_test.gen_tree(node1, words, len, false, 0, word_max_node, char_max_node, words_index)); Assert::IsFalse(core_test.gen_tree(node1, words, len, false, 0, word_max_node, char_max_node, words_index)); Assert::IsTrue(core_test.find_in_chain(node1->first_child->first_child, word)); } TEST_METHOD(UnitTest_command_line2) { argc = 4; char* argv[4] = { "Wordlist.exe","-w","-r","../Wordlist/file.txt" }; Assert::IsTrue(command_handler(argc, argv, words, len, head, tail, enable_loop, w_para)); }
-
被测试函数
测试的函数有五个:
int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop); int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop); bool gen_tree(node* cur_node, char* words[], int len, bool enable_loop, char tail, node* word_max_node, node* char_max_node, int words_index[][2]); bool find_in_chain(node* cur_node, string word); bool command_handler(int argc, char* argv[], char* words[], int &len, char &head, char &tail, bool &enable_loop, bool &w_para);
-
测试思路
首先明确程序的架构,command_handler是处理命令行参数函数;gen_chain_word和gen_chain_char是两个主函数,在这两个主函数中均调用了get_tree和find_in_chain这两个函数。
从小函数开始测试,根据gen_tree的输入和输出,使用assert函数,返回一个bool类型的值。主要测试重点就在于生成树的过程中是否遇到禁止环出现还出现环的情况以及树的生成是否正确。
然后是find_in_chain函数,主要功能是找在该路径上是否出现了和这个word一样的word,不满足我们的条件(一个单词只允许出现一次),返回bool类型的值。在测试时,就用gen_tree先生成一颗树,然后设定多种word让该函数去寻找。
对与两个主函数,因为主要的核心已经测试过,于是采用一些白盒测试的方法,测试其余没有覆盖的情况,调整-h,-t参数等等。
最后是command_handler函数的测试,这部分就是测试输入命令的正确与否。不过因为是命令行输入,已经有了一些情况的限制,需要测试的情况也可以较好地覆盖。测试内容分为正确指令和错误指令,错误指令有包含各种错误:有不正确指令,内容缺失,文件找不到等等。是在考虑的多种用户输入时可能犯下的错误进行相应的匹配和处理。
-
测试报告及覆盖率报告生成的辛酸史
测试报告
覆盖率报告
这个过程是对我来说,整个项目,最艰难的一个过程,完成它的耗时远远超过了我的估计值。总的来说,就是我使用的vs2017社区版没有这个功能并且其中较为流行的插件也均不支持vs2017的社区版(以下简称vs2017,因为专业版有现成的工具:))。
于是我展开了搏斗:
Round1:opencover和ReportGenerator
不适用!vs中缺少工具。另外vs2015版还可以支持Opencover的一个UI extension,简直不要太简单。
Round2:使用命令行运行测试文件的DLL,生成.trx文件,再转为html文件查看报告
我们在命令行可以运行测试命令,但必须要转为超级用户后才能生成.trx文件,否则会提示“拒绝访问”。接着,vs2017生成的.trx文件没有可以解析它的工具。我们尝试了多种,trx2html,还有GitHub上开源的工具,都无法解析它。
Round3:最后,我们得知了OpenCppCoverage这个软件可以通过命令行拿到覆盖率的html文件
起初,我们发现,这个命令只能通过调用.exe文件得到一次结果的覆盖率。但是经过一阵思考和对这个软件GitHub主页的参数分析,我们发现可以通过参数export_type=binary先生成覆盖率的二进制文件,再使用--input_coverage arg命令将多个测试样例覆盖得到的*.cov(二进制文件)进行merge。最后就得到了相当于整个测试文件的覆盖结果,再生成.html文件,方便可视化。先贴结果:
可以看出,覆盖率均达到95%以上,可视化效果也不错。html文件自取,提取码:lpw9 。
唯一的缺点就是需要自己生成不同的文件,手动运行多次命令,如下图
以后若要运行更多的测试,我们的思路就是写.bat脚本,也算事实行了半自动化的测试,时间有限,留给大家提出更好的方法。
命令实例:
OpenCppCoverage.exe --sources=Wordlist --export_type=binary -- Wordlist.exe -w file.txt
OpenCppCoverage.exe --sources=Wordlist ----input_coverage Wordlist.cov --export_type=binary -- Wordlist.exe -w file.txt
附几个参考过的链接
九、计算模块异常处理说明
因为计算模块的接口固定,使得诸如“没有出现-w和-c”、“-h后字母数超过一个”、“文件不存在”这样的异常无法在Core中处理,故在其他函数中以输出形式报出异常。本项目Core类实现的异常处理有:成环异常、-h后字母不是英文字母、-t后字母不是英文字母、链长度小于2四种异常。
自定义异常的通用结构如下:
struct ChainLessThen2Exception : public exception {
const char * what() const throw () {
return "length of chain is less than 2!";
}
};
在catch时,只需使用:
try {
// some function
}
catch(ChainLessThen2Exception& e) {
cout << e.what();
}
即可输出异常信息。
由于网上没有找到异常检测的方法,我们创新了以下格式来在单元测试中检测异常。可以看到,这个方法能够检测出是否捕获异常。
-
成环异常LoopException:对于enable_loop参数为false时,如果检测到环存在,抛出该异常。
测试样例
TEST_METHOD(UnitTest_Loop) { char* words[5] = { "aj", "hdfdrs", "jhgjh", "kjhjh", "sdfghj" }; int len = 5; char head = 0, tail = 0; try { core_test.gen_chain_char(words, len, result, head, tail, false); Assert::IsTrue(false); } catch(struct LoopException &e) { Assert::IsTrue(true); } catch(...) { Assert::IsTrue(false); } }
错误场景:"aj", "hdfdrs", "jhgjh", "kjhjh", "sdfghj"中出现环s..j-j..h-h..s,抛出LoopException异常。
-
首字母异常HeadInvalidException:如果head不为0,且head后面的字母不在'a'-'z'或'A'-'Z'范围内,抛出异常。
测试样例
TEST_METHOD(UnitTest_Head) { char* words[7] = {"ajsdjfhkhlkjhlakjshduhcuhuhuhuhvuhjhdkajsdfnajksdjkfhksdjahkjdhjdhje" }; try { core_test.gen_chain_char(words, len, result, 1, tail, true); Assert::IsTrue(false); } catch (struct HeadInvalidException &e) { Assert::IsTrue(true); } catch (...) { Assert::IsTrue(false); } }
错误场景:-h参数后跟1,非英文字母,抛出HeadException异常。
-
尾字母异常TailInvalidException:如果tail不为0,且tail后面的字母不在'a'-'z'或'A'-'Z'范围内,抛出异常.
测试样例
TEST_METHOD(UnitTest_Tail) { char* words[7] = {"ajsdjfhkhlkjhlakjshduhcuhuhuhuhvuhjhdkajsdfnajksdjkfhksdjahkjdhjdhje" }; try { core_test.gen_chain_char(words, len, result, head, 1, true); Assert::IsTrue(false); } catch (struct TailInvalidException &e) { Assert::IsTrue(true); } catch (...) { Assert::IsTrue(false); } }
错误场景:-h参数后跟1,非英文字母,抛出TailException异常。
-
链长度异常ChainLessThan2Exception:如果head不为0,且head后面的字母不在'a'-'z'或'A'-'Z'范围内,抛出异常。
测试样例
TEST_METHOD(UnitTest_Chain) { char* words[7] = {"ajsdjfhkhlkjhlakjshduhcuhuhuhuhvuhjhdkajsdfnajksdjkfhksdjahkjdhjdhje" }; try { core_test.gen_chain_char(words, len, result, head, tail, true); Assert::IsTrue(false); } catch (struct ChainLessThan2Exception &e) { Assert::IsTrue(true); } catch (...) { Assert::IsTrue(false); } }
错误场景:-h参数后跟1,非英文字母,抛出ChainLessThan2Exception异常。
此外,在Core类以外,我们已经对输入不合法、文件不存在等异常进行了处理,保证程序不会崩溃。
十、界面模块的详细设计过程
UI采用qt实现,由于我以前有过一次pyqt开发的经验,上手起来并不难。我们在底层放置了一些layout,同时也给每个分区设置了layout,这样做的目的是保证界面缩放时比例不变,但做出来才发现底层layout没能随窗口一起变化,导致缩放时比例仍然存在问题,最终决定固定窗口大小。具体结构如下:
实现过程即为拖动控件到layout上,设置layout内控件比例即可,并未用到qt代码,因此此处无需示范代码部分。需要注意的是,由于-c和-w以及两种输入方式都满足同一时刻有且只有一个button被选中,故采用了radio button的方式,这种button默认为互相冲突的,即不可同时选中。但当ui完成后才发现,本想四个按钮分为两组相冲突,实际却是四个按钮同一时刻只能选择一个。因此使用了QButtonGroup的形式进行分组。
界面主要包括上方的输入区,支持文件路径和直接输入单词;中间的参数和输出区,允许用户选择参数,得到正确结果和报错信息;下方的导出区,允许用户将结果导出到指定路径。
十一、GUI和计算模块的对接
由于GUI上实际只有两个按钮,因此GUI和计算模块只有两个函数进行对接。代码如下:
QtGui_Wordlist::QtGui_Wordlist(QWidget *parent): QMainWindow(parent)
{
ui.setupUi(this);
connect(ui.pushButton_generate_chain, SIGNAL(clicked()), this, SLOT(gen_chain()));
connect(ui.pushButton_save_in_file, SIGNAL(clicked()), this, SLOT(save_file()));
}
其中gen_chain()和save_file()分别是点击pushButton_generate_chain和pushButton_save_in_file的槽函数。槽函数的实现和正常实现基本相同,在槽函数中调用Core.dll中的api接口,唯一区别在于读取和输出的位置不同,GUI的数据由text()从box读入,由setPlainText()输出到box。
GUI实现的功能有:
从文本框读入文件路径:
从文本框中输入单词进行查找:
-w和-c两种方式的选择:
-h和-t的使用:
-r的使用:
文件导出:
十二、描述结对的过程,提供非摆拍的两人在讨论的结对照片
结对过程:因为第一堂软工课程坐在相邻的位置上,自然而然组成了结对作业的搭档。
为了达到作业目标的要求,只要双方的时间允许就会在一起进行结对编程。从最初的计划,设计,到编码,测试,再到最后的博客撰写,都存在着结对完成和明确分工两个状态的存在。
编码过程较为符合结对编程的要求,我们面向同一个电脑,对项目进行构建,两人轮流编码,轮流复审。在后期,做出了些许的分工。例如我负责测试的工作,他负责gui的工作。这时,项目有什么问题都可以进行随时的交流,效率很高。
在之后的博客撰写中,在共同内容的部分,也进行了分工,写各自较为熟悉的内容,可以更好地展示我们的项目和思想。
最后,非常感谢我的搭档,这整个任务对我也是一个不小的挑战,他帮助了我许多,让我坚持下来,也收获了不少的知识。
十三、结对编程的优缺点
优点:
- 随时复审,将“写bug”这种操作及时遏制,为之后的测试完修改节省了很多时间。
- 有难点随时交流,两个人的智慧可以提出更高明的解决办法。
- 高度集中地工作,不会有出神的状况。
缺点:
- 对编程中的每一个人实际上有更高的要求。是心智和道德修养上的要求。每一位程序员都需要习惯自己写代码被别人看着的这种方式,以及自觉遵守两人之间的规范。
- 不适用于两个人的能力差距过大的时候,为了完成任务,可能都会积压在能力较强的人身上,进而任务结果有可能偏向极端
- 不是对所有的项目都适用,例如:后期维护,验证测试,一个人会等待很长时间,是一种浪费。
张圆宁:
-
优点:积极主动;对新事物的尝试和熟悉很快;对问题有一定的探究能力,会寻找各种方法。
-
缺点:编程能力有待提高,尤其在速度方面还需要进步;对解决某些客观问题上(例如软件本身的限制等方面)没有足够的耐心;对问题的分析不够完善。
牛宇航:
-
优点:勤于思考,善于钻研;有很强的编程能力;对问题有很强的应变能力。
-
缺点:无
十四、PSP实际结果
见第二节。
十五、松耦合测试——成功
- 合作小组
16061200 陈治齐 16061076 顾展鹏
-
问题1:我们的main函数中有部分对于异常的处理,这些异常都是封装好了在core.h头文件中。在使用我们的主函数和GUI及对方的Core.dll之后,主函数因为找不到Core.h中的自定义异常,编译不通过,对方的exe和GUI无法运行。
解决办法:只要删除我们main函数中的异常处理模块即可正常运行,更为正规的流程应该是在互换公用模块的时候分享并扩充异常处理模块。
解决结果:修改main中异常处理后成功运行,通过正确样例。
-
问题2:对方的主函数和GUI无法使用我们的.dll文件,原因在于对方是动态调用,我们是静态调用。对方忘记修改.def文件以适应我们新的.dll。
我们组内使用的是静态调用:静态调用比较简单,编译DLL项目前,给.h文件中的函数前加上__declspec(dllexport) ,以生成.lib文件。将.lib文件拷贝到其他项目中后,只需引用.h头文件即可使用.dll(.cpp)文件的函数和类。
合作组使用的是动态调用:加载dll文件,在.def文件中写明.dll中的函数。若能够正确从.dll中取到函数所在地址,直接调用即可完成DLL的动态调用。
解决办法:对方应当更新.def文件为我方的.dll中的函数,即可运行。
修改结果:对方更新.def后成功运行我方.dll,通过正确样例。
十六、心得体会
此次的作业对我是一个比较大的挑战,尤其是投注了太多精力解决一些版本不兼容,版本不支持的问题,真的让我一度怀疑这门课的训练目标以及课程组是否仔细评估过这许多因素带给我们的困难,作业量和完成时间的比例以及实际操作的可行性。总之,这第一个作业给了我太多意料之外的负担,为了弥补这几天实习一天后还要写软工的睡眠不足,我要好好休息几天。