Loading

软工-结对编程作业#3

教学班级地址:BUAA_SE_2021_LR

GitLab项目地址:2021_Yilong_Li-Changyao_Tian_pair_work/task2

成员学号
L同学 3580
T同学 3636

结对项目实践反思

项目总体PSP规划

task1

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

task2

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

可以看到,与task1相比,task2的总体时间显著上升;但用于具体编码的实际时间并未增加,这主要是因为task2省去了最基础的架构设计相关的内容;而测试部分的实际时间则有了明显的上升,这主要是因为需求的不断变动和更新所致。

问题分析

针对前面两个阶段中出现的问题,分析问题的特征、产生的根源和对质量的影响程度。

首先,仅就最终提交至评测机的评测结果而言,非常幸运,两个阶段均未出现什么需要在此指出的问题。

但是在开发的过程中,还是经历了一些反复和迭代的,尤其是第一次作业,其最终对应的提交历史记录如下:

image-20210407104027470

可以看到,在3月25日当天,晚22点ddl之前,共计进行了4次提交,包括21: 47分的几乎极限的压线操作。虽然从最终弱测的结果来看,这几次提交的结果都是AC,那究竟是为何要进行这样的临时修改呢?

答案便是性能

具体而言,最后三次提交涉及到的在性能上的改进包括以下三点:

  1. 路径解析过程复杂度的降低
  2. size由动态获取转变为静态缓存
  3. 绝对路径absPath由静态缓存转变为动态获取

下面依次对这三点展开进行分析。

1. 路径解析过程复杂度的降低

在我们的实现中,路径解析过程涉及的核心方法为private String nextNodeName(),该方法每次将返回当前解析节点的name,同时对解析器的状态进行更新。

在原有的实现中,对解析器的更新是通过对当前剩余待解析路径curPath进行更新来实现的,具体代码如下:

 private String nextNodeName() {
     int idx = this.curPath.indexOf('/');
     String name;
     if (idx == -1) {
         name = this.curPath;
         this.curPath = "";
     } else {
         name = this.curPath.substring(0, idx);
         this.curPath = this.curPath.substring(idx + 1);
     }
     return name;
 }

在我最初的理解中,substring()方法只是将字符串的引用进行了拷贝,所以时间复杂度应该是忽略不计的才对。但后来,在阅读了substring()的源码后发现,这货居然每次调用时是在底层调用String的构造方法,强行将对应的子串的全部内容进行了拷贝。这就直接使得在最坏情况下,nextNodeName()的复杂度变成了\(O(n)\)。于是整个解析过程的复杂度变成了\(O(n^2)\)。(n为输入的路径长度)

这显然是不可接受的,于是在反复思考之后,我们最终决定将路径的解析变为对路径先根据'/'进行划分,再在nextNodeName()中调用相应位置的结果即可,如此复杂度就重新变成了\(O(n)\)。修改后的nextNodeName()方法如下:

private String nextNodeName() {
    String name;
    if (this.idx < this.nodeNameList.size()-1) {
        name = this.nodeNameList.get(this.idx);
        this.curPath = this.nodeNameList.get(this.idx+1);
    } else if (this.idx < this.nodeNameList.size()){
        name = this.nodeNameList.get(this.idx);
        this.curPath = "";
    } else {
        name = "";
        this.curPath = "";
    }
    this.idx++;
    return name;
}

这里之所以保留curPath是为了跟其余原有部分兼容,同时也有助于省略对一些特殊情况的判断。

2. size由动态获取转变为静态缓存

在最初版本的实现中,目录和文件的大小计算是通过递归调用实现的。当时没有想太多,所以就直接很简单的写了一个方法,目录的size()方法代码如下:

 @Override
public int size() {
    int size = 0;
    for (Map.Entry<String, Node> ent : children.entrySet()) {
        size += ent.getValue().size();
    }
    return size;
}

但这样一来,会存在一个潜在的风险,那就是Stack Overflow。对于递归层数很深的情况,该方法就无力应对了。

于是,在第一阶段中,最终我们决定采用静态缓存的方式实现,即在获取size()时直接返回缓存的size属性的值,而在对文件或目录的内容进行修改时,动态递归更新其本身和父节点的size。为了避免栈溢出,这里的递归采用while循环实现。核心更新代码如下:

public void updateSize(int newSize) {
    int delta = newSize - this.getSize();
    Node node = this;
    while (!node.getName().equals("/")) {
        node.setSize(node.getSize() + delta);
        node = node.getFatherDir();
    }
    node.setSize(node.getSize() + delta);
}

但是,在第二阶段作业中,由于出现了硬链接,导致一个文件对应的上层父节点不再只有一个,此时再进行这样的递归更新就不成立了。于是,我们又重新回退到了动态获取size的模式,取消了当时增加的size缓存。不过为了避免栈溢出,这里利用了queue+while的方式实现,核心代码如下:

@Override
public int getSize() {
    int size = 0;
    LinkedList<Node> subNodes = new LinkedList<>();
    subNodes.add(this);
    while (!subNodes.isEmpty()) {
        Node curNode = subNodes.poll();
        if (curNode.isDir()) {
            MyDirectory curDir = (MyDirectory) curNode;
            for (Map.Entry<String, Node> entry : curDir.getChildren().entrySet()) {
                subNodes.add(entry.getValue());
            }
        } else {
            size += curNode.getSize();
        }
    }
    return size;
}

一个size方法,在需求分析和设计阶段时看起来非常不起眼,但是在实现的过程中却经历了来来回回三次的修改迭代,这可不就是工程开发的魅力所在。

3. 绝对路径absPath由静态缓存转变为动态获取

在最初的设计中,绝对路径absPath是作为节点的一个final属性在初始构造时就被创建的,这是因为当时很多方法如equals()、contains()等都需要用到,为了性能所以进行了这样的处理。

但是,在压力测试中,我们却发现,当目录很深时,如果将absPath进行缓存处理,会造成堆内存溢出。于是,我们最终选择将absPath重新调整为递归动态获取,即以时间换空间。

可以看到,上述的这三个问题均与程序本身的正确性无关,而是与其时空性能的平衡有关。对于正确性,我认为主要是要在需求分析部分加以处理;而后者,则正是在实际的工程实践才需要真正面对并解决的。虽然最终没有对评测的结果产生太大影响,但在实际的发布中,却极有可能遇到这种压力的情况,因此在开发过程中也必须要妥善考虑这些问题。

当然,也正是因为有了task1中性能方面的开发经验,在task2中,这一方面的问题基本上就少有出现了。

需求分析总结

总结结对项目中的需求分析实践体会,并分析哪些bug是因为需求分析不足而带来的。

在本次结对编程中,所谓的需求分析,说到底就是对指导书要求的透彻理解和把握。这和在现实开发中对目标用户进行画像和充分调研本质上是两码事。特别地,对于由于需求变动(也就是指导书自身的修改)导致的程序的bug也不再在这部分中提及,相关的体会和感想可以参考本人上一次结对编程总结中的有关内容

这里,我将主要侧重于task1中唯二的两次因需求分析不足导致的迭代过程,并对其进行简单的分析和总结。

1. <dirPath><filePath>是否默认了什么?

在task1中,最初的时候我就对<dirPath><filePath>这两个在指导书中频繁出现的参数的明确定义存在一定的疑惑,即它们究竟只是作为命令的路径参数的一种语言描述,还是本身就隐含了一种规范性约束?这一点我一开始想了很久都没完全想明白。

所谓只是一种路径参数,就是说无论是<dirPath>还是<filePath>,都是强调某个命令(比如lstouch)后面还需要跟着一个输入的字符串,这个字符串可以是文件的路径,也可以是目录的路径。但至于到底是哪一个,则取决于这个命令本身支不支持对该类型进行操作,但<*path>本身并不隐式包含这方面的信息,只是起了个名字而已。一般命令行程序在-h中都会采用这样的处理方式。

所谓隐含的规范性约束,就是指<dirPath>就说明这个输入应该是目录,<filePath>就说明这个输入应该是文件,否则就要报错。

之所以这个理解上的偏差会给我带来一定的误会,是因为touch命令在linux系统中是可以同时对文件和目录进行操作的,那么当输入的参数实质上是一个目录的路径时,按照指导书的要求,是否应该要报错呢?我当时困惑了很久。

不过最后在队友的建议下以及与其他同学充分讨论后,我还是选择了第二种理解方式,推翻了原始的第一种处理方法;因为我发现似乎每个<filePath>出现的地方前面都会带上『文件』两个字,这应该侧面证明了课程组其实是默认了参数中存在的这种隐含约束的。

不过在此,还是建议指导书在一开始的时候对于后文可能出现的各类符号做出明确的显式约束定义,以避免出现不必要的误会。

2. 路径长度的限制由谁保证?

另一个需求分析上的问题则是有关路径长度的限制究竟是由输入保证还是由程序保证,这一点当时我和我的队友L同学还争论了好一会儿。指导书中相关的表述如下:

文件命名规范 (此处省略)文件名非空且长度不可超过256个字符。

目录命名规范 (此处省略)目录名非空且长度不可超过256个字符。

路径规范 路径由若干目录名、若干分隔符 /,以及可选的文件名一起构成,最长包括4096个字符。(此处省略)

注意到这里的对文件和目录命名规范的表述与对路径规范的表述不尽相同,前者强调了不可,后者则只是说最长包括。那么既然前者是对程序的明确约束,后者是否可以理解为由输入保证最长就这么多呢?

当然最后我还是加上了对路径长度的检查,毕竟不怕一万,只怕万一。这里把这个列出来,也不是为了表达对指导书描述不清的不满,毕竟现实中不存在十全十美之物,更何况只是一次的结对作业的指导书呢?(而且与第二次比已经非常清晰了)

这里我真正想要强调的是,哪怕是用如此简洁明确的文字写就的需求,也依然有可能在理解的过程中对于某些看似极不起眼的细节产生分歧和偏差,进而可能会对程序的正确性与鲁棒性产生影响,更何况是现实中那些用文字都讲不清的东西了呢?

所以,这里也正体现出敏捷(Agile)的意义与价值:在前期需求分析阶段就把该明确的都明确固然重要,但后期对于开发中可能存在的与原始需求之间的偏差也要有充分的心理准备,因此团队内部进行及时的沟通协调与同步互审也就非常之必要了。

架构设计总结

总结结对中的架构设计实践体会,描述通过改进设计来提高程序的性能改进的思路和方法,并分析哪些bug是因为架构设计不足(特别的,需求变化)而触发的bug。

在架构设计方面,第一次的架构设计由我们两人共同完成,因此为了便于沟通,我们相互之间简单地撰写了一份设计文档,明确了各个类的基本功能和对外方法等。而第二次由于客观原因,架构设计部分主要由我(T)一人完成,因此并未给出系统的设计文档,只是对开发过程中遇到的一些细节进行了必要的整理和记录,以确保做到不遗漏,也可以及时与指导书的更新作对比,从而对相应的代码进行改动。

至于在具体的架构上,task1和task2本身在编码过程中并未对架构做出什么变动,但是从task1到task2,由于软硬链接的出现,因此进行了一定程度上的重构,从而保证程序的可维护性,降低冗余。

具体而言,为了支持软硬链接,我借鉴了linux系统中对文件的处理方式,将文件的一些元数据抽取出来,额外构建了Inode类。这样,原先的Node类只需存储一个指向Inode的指针即可;这样做最大的好处在于可以不用为硬链接单独构建类,而是直接把其当作另一种初始化文件的方式即可。如此一来,整个项目中与硬链接直接相关的除了linkHard方法外,就只剩下一个public MyFile(String name, MyDirectory fatherDir, MyFile srcFile)的构造方法了——可以说是极大地减少了编程的额外负担。

此外,另一个较大的变动在于将路径解析的相关方法从MyFileSystem中彻底地独立出来,封装进了Parser类中。后者从原先简单的检查器升格成名副其实的解析器。其实这一步在task1中就可以做,但当时由于MyFileSystem本身还不是很复杂,且不存在软链接的重定向需求,所以就没有进行这样的处理。如此一来,MyFileSystem中的各种路径解析,以及是否需要重定向,都只需要调用Parser中提供的相关方法即可,非常方便;特别地,Parser内部在路径重定向时也只需要重新构造一个新的Parser对象对其路径进行处理即可,客观上避免了多余的代码操作。

两阶段的UML结构图如下:

task1 UML图

(插图)

task2 UML图

image-20210402144623785

从上述开发过程中可以看到,架构设计的意义其实不太侧重于程序本身的性能与正确性,而是更强调其向后兼容的能力以及面向开发者的可读性与可维护性。但我本人对此的观点是,如果没有新的需求出现的话,还是应该以简洁为第一指导原则,正所谓as simple as foolish(UNIX)。

沟通与管理总结

总结结对过程中的进度、质量和沟通管理实践体会,并分析哪些哪些bug是因为两个人的理解不一致而导致。

进度/质量/沟通管理

在每次实现前,我们会先进行线下讨论,之后则借助IDEA的 Code With Me 插件进行协同。这能在一开始就确定整体的框架,让我们能在之后的实现中少走弯路。

进度管理方面,主要有事前、事中两个阶段:

  • 在线下讨论时明确DDL,双方在DDL前将各自负责的部分实现完毕后进一步讨论
  • 结对过程中发现了新的Bug或需要讨论的地方,则使用IDEA的//TODO进行标记

质量管理方面,我们在线下讨论时统一了代码规范,通过高覆盖率的单元测试确保结果的正确性。

沟通管理上,我们主要借助Code With Me、微信消息、腾讯会议进行研发过程中的实时交流。得益于一开始有规划的接口设计,沟通过程所做的更多是对于细节实现部分的确认。

理解不一致导致的Bug

第一次作业中,L同学并未尝试课程组下发官方包的实际结果,而在单元测试时写了错误的测试样例(如删除文件时只需要返回绝对路径,具体的描述在官方包中补充),之后T同学及时通过注释与微信纠正了L同学的理解,让Bug得到了解决。

个人建议

提出建议:根据三个阶段的结对项目的实践经验,对如何更好的实施和管理结对项目提出自己的建议。

From L同学

  1. 按照实际情况确定结对方式。标准的结对编程是“两个人一台电脑”,而借助插件等工具我们能用更多方式进行协同开发,可以不用拘泥与原教旨主义的结对编程,寻找让双方都更加舒适的方法。
  2. 一开始就做好规划工作。这次结对编程中T同学良好的设计习惯给了我很大启发,先设计再实现的思想也能避免不必要的试错与重构。
  3. 及时寻求其他同学的帮助。在第二次结对项目中,指导书的复杂性让跨组的讨论显得至关重要。Issue区上同学们的讨论促成了对较模糊行为结果的清晰定义。

From T同学

在上一次的结对博客中,我有提过一些想法和思考。不过至于如何让结对项目变得更好,我觉得可能只有在充分实践了之后才知道。毕竟,结对只是一种形式,形式能够带给我们什么呢?我想它最大的意义应该是,在多年以后,如果自己真的进入工业界并又一次结对编程的话,会生发出:“哦,原来这才是结对编程啊”的感慨吧。

在此,我姑且给出一些关于具体实施和管理结对项目的不成熟的建议,希望能够对课程组有所帮助。

  • 组队:采用随机抽签+自愿组合的方式进行,先随机,再允许两两之间自由调整;
  • 任务:有明确的分工空间,确保能够让结对双方都能发挥自己的长处;
  • 时间安排:增加实验学分,将结对编程的过程以讨论课的形式展开,而不是完全放在课下;
  • 具体形式:双方交替进行编程,并非单纯意义上的分工:而是鼓励一方教会另一方新的知识并让其有尝试的机会;
  • 考核手段:增加展示与互评阶段,展示阶段主要侧重于体现软件在架构、性能等方面的优越性;互评阶段匿名进行,评的不是对对方表现的得分,而是对结对编程整个过程的体验性评价。
  • 其他:除了领航员和驾驶员外,还应该增设“观察员”角色,由助教担任,可以线上线下相结合,观察结对过程双方的默契度等。

当然,这些都是非常粗糙的建议,还是那句话,一切都只有在充分实践了之后才知道答案。

CI体验感想

通过这次结对编程,你对CI的使用体验如何?你对这一工具有何认识?

本次结对编程中,我们的CI主要包含4个Job:Build、Test、Submit、Get Score。只需要在第一次作业中编写.yml文件,之后就不需要对CI流程做更多设置。在这里也感谢MY在Issue区提供的配置方法,方便了同学在README页面显示单元测试覆盖率。

本次作业中所用到的CI功能比较基础,未来也许可以增加代码准入规则(不允许使用过时方法、代码风格检查)等进一步强化同学对于CI/CD的使用。

结对编程感想

软件工具使用总结

描述在本次结对编程的过程中,你们使用了哪些软件工具,是如何应用于实践的。

软件工具 类型 如何运用于实践
gitlab 代码托管平台 提交作业,Issue区讨论,构建CI/CD
IDEA 开发集成环境 编辑代码,构造单元测试,借助Code With Me插件实现结对编程
ubuntu 20.04 参考工具 对比Linux文件系统运行结果
微信 即时通讯工具 在微信上确定参会时间、分享Code With Me链接等
腾讯会议 线上会议工具 语音、投屏等方式,交流双方工作进展
Typora Markdown编辑器 本地查看指导书、撰写结对项目博客等

个人感想

From T同学

总的来说,结对编程的整体体验还是比较愉快的,也算是帮助我复习了一下一年前OO课学到的内容和很久没有碰过的Java开发。在第一次的结对中,虽然时间上比较难错开,但我们还是在Code With Me插件等工具的帮助下体验了一把严格意义上的结对编程。但之后,由于客观原因(双方时间协调不开等),结对无可避免地慢慢变成了分工。

从我的角度来看,结对编程整体上是优劣参半吧。先说优点,正如我在第一次结对感想中所言,结对最重要的是在思维模式上实现两人互补在编程习惯上实现两人互鉴。我觉得,单就这一点已足以说明结对的魅力了。

但是结对的问题在于,它对于双方的协作提出了非常高的客观要求。与传统的分工合作相比,结对对实时同步的依赖无疑要大的多;而两个双胞胎都尚且不能做到生活作息时间表完全一样,更何况是两个生活在北航6系的同学呢?这一点,几乎是在先天上就宣判了结对这一编程形式难得长久。

另外,在结对中可能还会涉及到的就是结对双方的参与感和获得感失衡的问题。如果一方写嗨了怎么办?他会一口气把所有的都写完还是让另一个人继续写?又或者当一方有事时,另一方的投入势必要被动增加,此时又该怎样在后期对此进行调整和补偿呢?

至少,我理想中的结对编程,应该是一个只发生在工作时间,双方事先进行了充分的沟通协商,并且编程过程双方始终保证充分积极性的过程。但在现实中,在校园里,大概是比较难实现的了。

汉堡 From T同学 To L同学

L同学是个很认真的同学,也很有自己的想法和主见,在结对的过程中提出了不少非常宝贵的意见,对代码整体的优化做出了很大帮助,特别是在第一阶段。其中,他对于try-catch块的理解给我的印象最为深刻,也直接导致了许多方法的结构进行了重写和调整。

不过可能是因为非常忙碌的缘故,感觉L同学每次线上线下交流的时候都多多少少有那么一点佛系?可能是白天实习太累了吧。不过,还是希望L同学能够再热情积极一点,这样或许会更有助于接下来团队项目的开展~

另外,task2中L同学负责的测试部分也非常的细致:虽然时间有限,但还是考虑到了很多特殊的边界情况,对于验证程序的正确性起到了非常大的帮助!

From L同学

在上次的博客中我已经将自己的吐槽之魂燃烧殆尽了,所以现在回过头来看,这段时光还是很有意义的。第二次作业的结果也并非老师与助教的本意,但他们负责任的态度相信能让下一届同学的结对体验更上一层楼。

汉堡 From L同学 To T同学
  • 面包:T同学有很强的责任心,说的少做的多,虽然同一时间也在准备冯如杯等项目,在结对作业中依旧承担了更多的工作,非常感谢大佬。
  • 肉:感觉T同学的代码风格有着NOIP风?初期有些过于简练了,但之后也很快做了调整。
  • 面包:T同学yyds!期待T同学在团队项目中的作品。
posted @ 2021-04-09 08:58  Shaun_Yao  阅读(135)  评论(2编辑  收藏  举报