写点什么

软件开发丨关于软件重构的灵魂四问

发布于: 2020 年 08 月 28 日

在软件工程学中重构就是在不改变软件现有功能的基础上,通过调整程序代码改善软件的质量、性能,使其程序的设计模式和架构更趋合理,提高软件的扩展性和维护性。



摘要



在本文中,您会了解到如下的内容:



先添加新功能还是先进行重构?



重构到底有什么价值?



如何评判这些价值?



重构的时机是什么?



如何进行重构?



1. 先添加新功能还是先进行重构?



问题:



官方资料,重构分析1.0版中。



有两顶帽子,一个是添加新功能,一个是重构



添加新功能时,你不应该修改既有代码,只管添加新功能,重构时你就不能再添加功能,只管改进程序结构。



一次只做一件事情。



这两个是否有矛盾,以哪个为准?前面有些可信材料版本不一,有的还要互相打架,是否可以统一一下?



回复:



关于添加新功能和重构是否矛盾的问题,是先添加新功能还是先进行重构?



我们要做的是观察这两个事情哪个更容易一些,我们要做更容易的那一个。



就是你不能一下子同时做这两件事情。因为同时做两件事情,会导致你工作的复杂度提升,容易出错。



一般而言,重构会改变程序的设计结构改动相对来说比较大。但是因为没有功能方面的添加,所以对应的测试案例我们不需要进行修改,那对我们来说,只要能够使得现有的重构修改能够满足我们的业务测试案例就可以了。



添加新功能意味着我们要添加对应的测试案例,以保证我们新的功能是可测的。这部分的修改一般会依托现有的程序结构,改动起来相对比较少,并且修改容易鉴别。



在绝大多数正常情况下,我们一般是先添加功能,提交完成以后,再新的修改需求中对代码进行重构。



从大的方向上来说是分两步走的,这两个任务不能混为一谈。



一次只做一件事情,一次提交只包含一个任务,这是为了避免在工作中人为的增加复杂度,这个复杂度包含代码修改,审查,测试等各个方面。



避免复杂度的上升,是我们在软件开发过程中时刻要谨记的一个原则



俗话说,一口吃不成胖子,心急吃不了热豆腐。做事情要一步一个脚印,稳扎稳打,步步为营。



2. 重构的价值和评判效果



问题:



哪种类型的代码重构是高价值的?



1. 在网上跑了这么多年也没啥问题,为什么要动他?



2. 重构前后功能又没啥变化,当前收益是啥?



3. 若是提高可维护性,可扩展性的话,怎么评判效果呢?



回复:



这是关于重构价值和评判结果的问题。



这几个问题问的都很好。



我们来看第1个问题,就是"在网上跑了这么多年也没啥问题,为什么要动"的问题?



这里的关键点就在于到底有没有问题。是不是说在客户那边客户看不到问题,就算是没问题。



当然不是的,在我们软件开发当中,在交付给客户以后,客户那边看到的是黑盒,他不知道我们内部的逻辑存在多少的漏洞。



如果我们的内部逻辑存在很多的漏洞。假设偶然某一天,某个客户发现了一个漏洞,它可以通过这一个漏洞进入到我们的系统内部,这样进入我们的内部,会发生什么样的状况,我们可以自己想象。



在公司的内部发言中专门提到了UK对我们产品的一个评价,外层是铜墙铁壁,内层是很脆弱的,客户或者黑客一旦进入到我们的内部以后,他就可以为所欲为了,从这一点上来说,我们一定要对我们现有的代码进行重构,以避免这样的问题。



我们再来看第2个问题。重构前后功能又没啥变化,当前收益是什么?



重构最大的收益是解决如下的问题:



代码太多重复问题,单个函数体或者文件或者攻城过大的问题,模块之间耦合度太高的问题等等。



以上问题归根结底就是一个问题,就是复杂度过高的问题。



现在来谈一谈复杂度的问题,软件开发中的复杂度当然是越低越好。一般谈到复杂度,我们可能想到了各种逻辑上的复杂度,设计上的复杂度,实际上在软件过程中复杂度涉及到方方面面,我们来看一下,具体有哪些方面我们需要注意复杂度的问题。



第一是命名规则。先举个例子,我定一个变量叫word。有的人喜欢把它写成wd。这个就增加了这个变量定义的复杂度,你从wd很难明白,这个变量是word的意思。



不管是变量的命名还是函数的命名,我们都希望看到名字,我们应该能够理解这个变量或者函数大体是关联到什么样子的事情。



所以谨慎的使用缩写是避免命名规则复杂度提高的重要前提。



第二是程序逻辑的复杂度。线性顺序执行的复杂度为1, 出现分支以后要乘以分支的个数。分支可以是条件判断也可以是循环。所以尽可能的避免分支的出现是降低程序逻辑复杂度的重要手段。



如果程序分支不可避免,要尽可能的把程序分支放到最高的逻辑层。这样做的目的是为了避免在下层处理的时候出现发散式的分支。发散式的分支会急剧的增加程序的复杂度。



复杂度越高,程序越难维护,复杂度超过一定程度,人类程序员是无法处理的。



第三是架构设计的复杂度。架构设计涉及到模块设计和系统设计。要尽可能的把一些公用的模块或者子系统抽取出来,比如安全相关的,日志相关的,工具相关的等等,这些公用的功能可能会被所有其他的业务模块或系统所调用。



在调用这些公用功能的时候,越简单越好,并且调用者不需要关心具体的内部实现,只需要知道如何使用就可以了。



这样做的目的是让程序员专注到业务代码的设计上来。



第四是系统部署的复杂度。系统部署包含几个不同的阶段如开发阶段,测试阶段和生产阶段。不管是哪个阶段,部署的步骤越少越不容易出错。有些系统天然的需要很多指令的配置,如果是这样的情况,需要编写一个批处理的文件来简化外部使用者的部署步骤,把多个步骤变成一步。



与部署相关联的还有集成部分。如果能够实现自动化或者从模板中创建那是非常好的状态。



第五是测试的复杂度。测试分白盒测试和黑盒测试。白盒测试的复杂度直接关联着代码层级的复杂度,代码层级的复杂度越高,当然白盒测试的复杂度也就越高。



白盒测试需要注意的一个重要问题是不要使白盒测试这部分的代码脱离实际业务代码的设计。也就是说白盒测试它的依附对象就是我们实际的业务代码,从架构设计上说是一个附属层,不要试图在这里使用什么软件设计艺术或者所谓的编程艺术。



这种代码的风格就是简单直接,复杂度线性化。



黑盒测试的复杂度来自于业务需求分析。要有非常清晰的文档说明,需要对测试步骤和预期结果写的非常清楚。



第六是技术的复杂度。技术的发展趋势一般是越发展越简单,功能越强大。那么在设计和开发的过程中,要避免使用老旧的技术。关于技术框架的选择,要提前做好调研。前端选什么框架,要不要选择某些UI库,后端选什么框架,要不要选择某些程序库,原则上是为了简化我们的学习过程,提高开发效率,增强整个项目的可维护性。需要具体问题具体分析。



第七是队伍结构的复杂度。队伍构成一定要短小精悍,人多不一定好办事。像亚马逊提倡的是两张披萨团队,意思是说整个团队两张pizza就能吃饱。大体估算就是10人左右的一个队伍。当然这只是一个参考指标。



整个队伍的目标一定要明确。所有的人都向着那个目标迈进,分工可以不同,但是目标一定要一致。



目标+分工是队伍成功运作的关键。具体来说就是把目标分成多个任务,每个任务里又可以分成小任务,那所有的人都去做对应的任务,自己让自己忙起来,而不是别人让你忙起来。



我们现在来看一下第3个问题,就是如何评判重构效果的问题。在上面的分析中,我们已经了解了重构的目标和最大的收益,就是复杂度的降低。



那么对应的,就是代码的重复率大大降低了,单个函数体或者代码文件或者工程过大的问题不存在或者减少了,模块之间的耦合性降低了。



再进一步说,就是关于代码的可维护性和可扩展性上,我们需要关注这么几点:



一是代码的可读性,我们看到现有的代码就应该可以理解代码作者的意图是什么,这样我们在修改bug的时候就更容易把握。比如函数,类或者组件的功能要单一化,命名要友好,要删除一些误导性的注释,对于一些没用的代码,要毫不客气的抛弃。



二是设计模式的可参考性。设计模式的好处就是提供一种可以追寻的代码扩展轨迹,新的功能可以遵循这种轨迹模板进行添加,从而获得复杂度线性增长的效果。



三是白盒测试的完善性。尽管我们有非常强大的测试团队,对于黑盒测试方面有很多的经验和心得,但是现在我们有很多项目缺乏白盒测试案例,这使得开发者在进行重构的时候,面临非常尴尬的境地。没有充分的白盒测试案例,重构工作会举步维艰,有一种瞎子摸象的感觉。



现在就说一下白盒测试这一部分。测试的框架应该在项目开始阶段或者重构开始前搭起来。等部分代码成型的时候,逐步的添加必要的测试案例。测试案例的选取可以按照环形复杂度的计算方法来确定,也可以根据集成测试对应的用户需求来确定。



与代码相关的测试,一般有单元测试,集成测试和系统级的测试。



单元测试,一般被认为非常繁琐。单元测试的繁琐主要体现在测试案例的选取上, 如果使用全覆盖方式来选取测试案例的话,会产生大量的测试代码,以后维护起来也是一个负担。如果采用环形复杂度来选取测试案例的话,会产生适量的测试代码,但是环形复杂度的计算也是一个很大的时间开销。



集成测试跟客户的实际业务需求相关。在这个过程中需要理清接口的输入与输出,以及运行路径,然后据此来设计测试案例,写出测试案例代码。



开发人员一般不会拒绝写集成测试。因为她带来的好处是实实在在的,会极大的提高你的开发效率和调试效率。尤其是对于无界面的程序接口尤为重要。



系统级测试是大系统中子系统之间的集成测试。这个主要包含两个方面:



一个方面是有界面的自动化测试,通过这样的测试架构来模拟人类用户的使用过程,同时增加一些随机性的行为,试图能够找出系统的一些漏洞。



另一种是无界面的测试,体现在多个服务系统之间的调用上或者类似浏览器自动化框架的使用上。



一套完整的测试系统,可以帮助工程师提高开发效率,减少以后系统维护和重构的成本。



从测试的紧迫性上来说,集成测试最为必要,系统间的测试有时候使用手工测试通过一些测试工具来代替。单元测试可以有很广阔的讨论空间,这部分要具体问题具体分析。



3. 重构的时机



问题:



关于重构时机的说法,正确的是?



添加功能时,重构能够使得未来新增特性时更快捷、更流畅



在修复错误时,应该聚焦问题本身,不建议重构,可以避免引入新的问题



专家Review时重构,能够传递经验,改善设计,避免或减少代码持续腐化



回复:



关于重构的时机问题,现在我们有三个选项,我们就分别分析一下这三个选项。



第1个选项是说在添加功能的时候进行重构。这个选项的主要问题就是一个提交包含了多个任务。这属于人为的增加工作的复杂度。第1个缺点是会增加工作的难度,使得本来可以用工作量1解决的问题,变成了工作量2和3。第2个缺点是增加了代码审查的难度。本来你的提交中描述的是添加功能,结果发现里面的代码修改大部分与此描述无关。



所以第1个选项排除。



第2个选项是说在修复错误的时候应该聚焦问题本身,不建议重构,以避免引入新的问题。



聚焦是点睛之笔。我们在做任何事情的时候,都不要忘记初心,集中精力攻克问题,不要分心。



所以第2个选项是正确的。



第3个选项是说专家在审查代码的时候再重构。这里面的最关键问题是专家可能并不了解代码的业务需求和应用场景。他们能够看到代码存在不好的味道,但在不了解业务场景的情况下,让专家进行重构会带来很大的风险。



所以第3个选项也不正确。



4. 如何进行重构?



问题:



如何正确的进行重构?



回复:



下面我们来看看如何进行重构。



简单的代码重构我们都比较熟悉,比如说你通过工具就可以做一些整理,如变量重命名,函数抽取,类创建等等。



现在比较头疼的一个话题就是对老产品的重构,一些老产品涉及到上千万行,上亿行的代码。



关于老产品整改的问题。如果只是缝缝补补的话,可能起不到化繁为简的目的。其实做类似这种工作的话,有一个比较可行的方案。就是把现有的产品当做一个成型系统也就是现有运行的产品,不要做大的改动,顶多就是修改bug。



然后以这些成型的系统为基准,去写新的系统。相当于参照一个大的白盒就写一个小的白盒,这样新的小的白盒质量上肯定比大的白盒性能上要有优势。



这样子按部就班去做的话,就会比较靠谱。



有朋友会说上面的做法是重写,字面意义上没错的。



实际上不矛盾。区别就是重构的方式应该从下往上还是从上往下。比如说我们现在大部分的重构都理解为从下往上来做。也就是感觉这个文件里头有坏代码的味道,然后就改这个文件,这样做是没有问题的。



比如现在有些教练遇到的问题,就是发现上下文不是很清晰,这个代码为什么要这么写?为什么一个文件有1万行或者3万行,这个来龙去脉不是很清楚。



这个时候可能就需要从整个子模块来进行一个自上而下的分析。梳理出这个子模块的功能需求是怎样的,需要有多少个公共接口?内部公共接口的实现方式是不是应该像目前这样的?



一个文件能够写成1万行或者3万行,肯定是有一定历史原因的,绝大程度是由于全局把握的编程能力不够造成的。



像这种情况,如果从这个文件本身去做重构的话,难度非常之大,但是如果从上往下,从模块的整个设计角度来做重构的话,可能就容易一些。



对于这样的庞然大物,最好的办法就是分而治之。首先要确定系统的功能逻辑点,针对这些逻辑点,要编排好对应的检测点,也就是说等我们完成了重构以后,我们得确保我们的重构是没有问题的,这些检测点就是做这个的,我们可以理解成集成类的测试。



这些集成类的测试一定要确保可以在当前未重构之前的系统上正常运行。



有了这个设施以后,我们就可以开展我们的重构工作。重构的方法有很多,比如采用比较好的工具,函数和变量的命名改变,调用方式的改变等等。这些是在现有代码的基础上进行的重构。这里我们重点说一下重写的方式来实现重构。所谓重写呢,就是另外开辟一套代码底座。甚至可以选用不同的编程语言。



这种情况下重构首先要重用已有的业务逻辑,实现针对业务逻辑集成测试100%的通过率。



具体不管采用哪种方式都要一个模块一个模块的进行推进。验证完成一个是一个,千万不能急于求成,试图一次性的把某些问题搞定。如果出现很多次失败,有可能会消磨掉你的自信心。所以一定要一点一点的往前推进,始终是在进步当中。采用了这种方式以后,不管当前的系统有多么的庞大,你只要坚持做下去,就一定能够把重构工作彻底完成。



这个时候需要做的具体步骤可以参考如下:



1. 根据功能需求定义公共接口。



2. 根据公共接口写出测试案例代码。



3. 这个时候可以按照测试驱动开发的理念去填充代码。



4. 代码可以从现有的代码中抽取出来。



5. 在抽取的过程中进行整理重构。



这样,这个子模块完成以后,就可以尝试去替代现有的子模块,看看能不能在整个系统中安全的运行。



对于整个系统来说,我们又可以分成很多个子模块。然后又可以对各个子模块各个击破,最终完成对整个系统的重构。



如果一开始对整个系统进行重构的话,也是可以从自上而下的角度来看的。



比如说开始的时候先把所有的子模块看成一些占位符,假定他们已经完成他们的接口了。那对于整个系统来说,它本身就是一个子模块,属于提纲挈领的那个模块。



这个过程,从字面意义上可以理解成重写,实际上,它也是一个重构的过程,因为我们肯定会重用这个系统本身的一些现有代码和现有的逻辑。



上面我们是假定系统在已经完成的情况下进行的重构,其实重构可以贯穿于软件开发的始终。软件开发的首要目标是实现业务逻辑,能够解决客户的问题。这个目标实现以后,我们就要追求代码的干净度,复杂度能够降到最小,当前的技术能够用到最先进。



所以只要有机会,我们都应该对代码和设计进行重构。



结语



本文针对收到的几个关于重构方面的问题作了回答,侧重点各不一样,希望能够给存在相同困惑的朋友们有所启示



点击关注,第一时间了解华为云新鲜技术~



发布于: 2020 年 08 月 28 日阅读数: 1289
用户头像

提供全面深入的云计算技术干货 2020.07.14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
软件开发丨关于软件重构的灵魂四问