【重构】一、重构的原则

重构的原则

0

这个系列是《重构——改善既有代码的设计(第2版)》的读书笔记,没错就是不久前2019年5月才出版的第二版中文译本。这是继1999第一版后时隔20年的第二版,距2009年的第一版再版也过去了10年。

我对重构在过去的样子不是很了解,无法体会重构从无到有是具有划时代意义的,但是在实际的开发中,重构无处不在,大多数时候,重构的结果是很好的、过程是痛苦的。我一直把第一版当做一本字典,当我觉得代码需要重构的时候,翻一翻这本字典,然后适当地“重构”,现在,我想把第二版再读一遍,结合自己的开发和重构经验,整理成一本具有个人特色的字典。

什么是重构

重构(Refactoring),是对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高软件的可理解性,降低其修改成本。

重构和重写最大的区别在于:重构是用微小且保持软件行为的步骤,一步步达成大规模的修改,即使没有重构完成,软件依然可以使用,可以随时停下来;而重写可能会导致一段时间软件不能使用。

两顶帽子

在使用重构技术开发软件时,开发时间可以分配给两种截然不同的行为:添加新功能和重构。添加新功能时,不应该修改既有代码,只管添加新功能、添加测试并通过;重构时不能添加新功能,只管调整代码结构,不应该添加任何测试,除非有遗漏的测试或接口发生变化时才允许添加测试。

这两种状态在开发过程中会不停变化,可能刚刚尝试添加新功能,发现重构一下以前的代码会更容易扩展,重构完成后又继续添加新功能,添加完新功能后,又觉得代码写得难以理解,于是又继续重构……这就像两顶帽子,工作中时常戴上不同的帽子工作,无论何时都应该清除自己戴的是哪一顶帽子,并且明白不同帽子对自己和程序的要求。

为什么要重构

重构改进软件的设计

如果没有重构,软件内部设计或架构会逐渐变质,因为开发者很容易只为了短期目而修改代码,忽略了整个程序的整体设计,也可能开发一段时间后才真正明白代码应该有的结构。

重构使软件更容易理解

能写出计算机能理解的代码并不能称为好的程序员,只有写出让其他程序员都能理解的代码才能被称为好的程序员。没有必要觉得自己写出了让别人能够理解的代码时多么无私的一件事,因为这份代码未来的维护者很可能就是自己。

重构能帮助找到bug

重构有助于理清程序的结构和逻辑,缩小bug的范围,还可能发现隐藏的bug。

重构提高编程速度

在初级程序员眼里,重构和单元测试似乎严重占用了编程时间,因为他们接触到软件开发的时间还不够、做一个产品的周期还不够长、还没有接触到由糟糕的设计和混乱的代码组成的难以修改的软件。没有持续重构的系统,问题会在未来的某一天显现,添加新功能的难度成倍增加、修改bug的时间超过了开发功能的时间、修复一个bug会导致更多的bug出现。而一个持续重构的系统,好处会在未来的某一天变得明显,而这个时间点和未持续重构系统问题出现的时间点可能刚好吻合。

什么时候重构

三次法则
第一次做某件事只管去做;第二次做类似的事会产生反感,但无论如何还是可以去做;第三次再做类似的事,你就应该重构。
正如老话所说:事不过三,三则重构。

预备性重构:让添加新功能更容易

重构的最佳时机是在添加新功能之前。在动手写代码之前,先看看现有的代码哪里可以复用,如果大部分可以复用但是小部分需要修改,为了这一小部分的修改写很多重复的代码可能不是一个好方法。

比如新功能需要使用已有功能某一方法的大部分代码,那么完全可以使用提取函数方法重构,将这部分代码复用,因为如果这部分代码导致了某一个bug,那么修改此bug会导致两处修改,并且假设这部分代码需要更换成新的逻辑,也会导致两处修改。

再比如可以预见到这个模块以后还会添加类似的新功能,那么可以是用某种设计模式来替换现在的代码,以便于后续更容易、更优雅地扩展。

帮助理解的重构:使代码更容易懂

修改某一部分代码之前,必须要先理解这部分代码。如果一段代码过了一段时间自己得重新花好几分钟时间才能理解,一直在思考“这段代码到底干了啥”,可能是因为某个函数或变量的命名导致困惑,或者某个方法实在是太复杂了,这都是重构的机会。

把注释写得足够详细并没有什么问题,但是如果代码本身就能体现逻辑,那注释就显得多余了。我不需要在一个名为username的变量上添加/* 用户名 */的注释,我也不需要在queryUserById()的方法调用上注释/* 通过用户ID查询用户信息 */,因为这些变量名或方法名就是最好的注释,如果某个变量或方法必须添加注释,那一定是这个变量表达的意义和它的实际意义不匹配。

同样的,很多程序员喜欢在if...else...语句中写大量注释,好像能帮助别的程序员理解,但实际上注释恰恰反映了这段代码有些难懂,一定可以通过重构找到更合适的方法,让这段代码脱离复杂的注释也能被人理解。

捡垃圾式重构

有时候自己已经理解代码的逻辑,或者正在干其他紧急的事情,但是发现一些做的并不好的地方,可以稍微地重构一下。比如两个不同名的方法,内部逻辑是一样的,一些if语句完全可以合并,这些“垃圾”如果留在原地,可能并不会对整个软件的修改和理解带来太多的负担,但是保不齐未来某一天会垃圾成山。

童子军有一条规则:“让营地比你刚来时更干净。”在软件开发过程中也是如此,如果你使用了某段代码,或者修改了某段代码,如果发现了垃圾,那么立即清理垃圾。有时候可能清理垃圾会花费好些时间,但即便如此,清理垃圾通常都是值得的。

有计划地重构和见机行事的重构

上面几条重构时机都是“见机行事”的,并没有特地地安排一段时间重构,但是有计划地重构有必要吗?在敏捷开发中,可能会在几次迭代后有一个短暂的修复时间,这段修复时间可以看做是有计划地重构,这相当于在整个软件生命周期中的两顶帽子交替。

但是有计划地重构应当尽量的少,因为重构是小的修改步骤汇集成大的修改,各种见机行事的重构汇集成整个模块的重构,如果需要花时间专门重构,那么一定是见机行事做得不够好,或者设计架构到了不得不改的地步。

长期重构

大多数重构可以在较短的时间完成,比如几分钟几小时,但有的重构可能需要好几周才能重构完成,比如基础模块的重构。因为在重构过程中,程序必须持续可用,可以在基于抽象或接口开发,如果没有可以在重构时引入,抽象或接口同时支持新旧实现,一旦新的实现重构完成,立即将旧的实现替换。

复审代码时重构

很多公司都会做code review,但是很多时候都是复审者单独浏览代码,代码作者并不在旁边,或者复审代码时走马观花,并不会尝试去理解代码,也不会给出有效的建议,因此复审时最好及时指出代码中的缺陷,让不规范的代码在进入代码仓库之前就解决,否则复审就失去了意义。

怎么对leader说

如果leader很重视技术,你说你花了一定的时间在重构上,导致开发进度有一些滞后,leader会理解并支持;但是如果leader没有这种意识,他只会责备你没有按时交付功能。但是如果回过头来想想,没有重构导致的bug和添加新功能的难度是不是影响了之后的开发,在有一定年限的开发经验后,这种意识应该越来越强烈,不要告诉leader,老老实实加班重构,一个月后,添加完新功能准时下班的自己会感谢一个月前加班重构的自己。

何时不应该重构

如果丑陋的代码隐藏在一个API之下,而这个API已经被长时间测试验证过,没有新功能需要添加的时候,可以忍受补充够API之下的代码;如果一个模块重写的难度比重构容易,就别重构了,但要命的是,往往需要花一点儿时间尝试重构后,才能了解一块代码重构的难度,所以决定重构还是重写因人而异,需要丰富的经验和判断力。

重构的挑战

延缓新功能的开发

比如现在手中的一个项目,PO一直在赶进度,但是代码已经开始腐败,作为开发,我知道应该花些时间重构了,否则这个产品一定会变得难以维护,但是PO现在需要的就是进度。这也许是前期工作没有做好的后遗症,因为重构的好处就是添加新功能更快,可以花更少的工作量创造更大的价值,重构的好处也许不会马上显现,但是在未来的一个月或几个月会逐渐浮现;而不重构的坏处也许一开始并不会暴露出来,甚至还有不错的进度,但是和重构的好处一样,一个月或几个月后,添加新功能和BUG排查将是一件令人沮丧的事情。

所以戴上重构的帽子时,不会添加任何新功能,因此延缓新功能的开发不可避免,但是我认为这点时间是值得的,哪怕是加班去完成这件事,因为重构可以让以后戴上添加新功能的帽子时效率更高。但也应当在两顶帽子之间做出取舍,比如只需要添加一个很小的功能,并不会增加未重构代码的复杂度,那么可以在赶进度的情况下暂时添加,然后再戴上重构的帽子,因为进度优先,重构并不影响现有功能的使用。

代码所有权

细粒度代码所有权的情况比较少见——细到某个接口属于一个团队甚至只属于某个开发者,其他团队或开发者无法修改这个接口,当进行较大规模的重构时,这种代码所有权边界会妨碍重构,虽然可以使用桥接模式、代理模式、适配器模式等等来隐藏无权限修改的接口或类,但是这又增加了系统的复杂度。

因此不建议代码所有权粒度太细,目前也没有见到有这种情况的项目或团队。

分支

版本控制有两种方式:feature分支开发完成后merge到主分支或将主分支rebase到feature分支;在一个dev分支开发,每次提交代码之前必须先pull并解决冲突,最后在commit并push。这两种版本管理方式我都有使用过,前一种在开发过程中很爽,没有任何多余的代码影响自己的feature开发,但是一旦开发完成后要merge到主分支时,解决冲突是一个让人头大的问题,特别是feature分支开发持续一两周,和主分支的差异越来越大,冲突的机率随之增加,其中一种解决方法时定时从主分支pull代码,让feature分支和主分支的差异变小,尽早解决冲突;后一种方式没有复杂的分支管理,每个成员每天都必须提交一次代码,所有开发者当天的代码都在同一个分支上持续集成(Continuous Integration, CI),这种方式可以降低代码合并难度,相比于前一种方式,持续集成少了复杂的分支管理。

回到重构,如果某个成员重构了一处代码,比如修改了某个方法名,但是另一个成员没有及时拉取新的代码,依然使用了旧的方法名,那么无论谁先提交,另一方都需要做二次修改,但是这个问题可以在持续集成的时候尽快发现,而不是使用feature分支两周后才发现。

测试

重构是在不改变软件可观测行为的情况下改变软件的内部结构,这是重构的重要特征。我们如何保证“不改变程序可观测行为”呢?一个很重要的保障就是单元测试。比如重构一个复杂的方法,如果有足够多的测试支撑,我可以随心所欲的重构,只需要重构完成后跑一跑这个方法的所有单元测试,看看是不是所有可能的输入都得到了原先期望的结果,如果是那么至少可以保证本次重构没有改变程序的可观测行为。

单元测试配合持续集成,可以尽早的发现问题所在,比如jinkens配合sonar做持续集成,在代码进入仓库之前跑一次单元测试,可以保证此次重构不对程序已有单元测试的逻辑产生影响。

遗留代码

接手维护过老系统的人可能都一听到别人写的代码都是闻风丧胆,如果没有测试就添加测试,这看起来不太可能,因为不是自己写的代码,连用例都不太清楚,不能保证自己能覆盖所有分支。所以对于遗留代码,还是遵循童子军原则,每次触碰一块逻辑时,首先完善或建立单元测试,然后对这一小块逻辑重构,等到一个小模块的单元测试基本完成时,在准备整体重构,等到多个模块单元测试完善时,在进行更大规模的重构。这听起来是个大工程,但对于一个功能较为稳定的老系统,真正会频繁修改的代码一般是引起系统不稳定的因素(Bug或功能完善),如果只重构这些频繁改动的逻辑,重构这部分代码将会得到丰厚的回报。

数据库

对于持续迭代的敏捷开发模式,数据库设计可能会随着迭代的进行而更改,如果代码已经部署在生产环境,但数据库的更改没有及时应用到生产环境数据库,比如一个字段名修改了,那将会是一个线上bug或生产事故。

解决重构可能导致的数据库变更有两种方案:如果生产数据库是固定的,可以选择原始的人工执行sql脚本(有的自动化部署和审批系统可以有相关功能);但假设生产数据库有多套,比如产品部署在各个客户现场,现在要针对某一版本的系统做一次升级,甚至是每个客户现场的版本还不一致,这种情况如何找到每个本版升级到最新版本需要执行哪些sql?这时候最简单的方法是将sql脚本的变更也纳入版本控制,比如flyway,liquibase等工具,这是任何一个持续发布的、用数据库存储数据的多环境产品首先就应该想到的事情。

总结

重构作为敏捷开发的重要特性,每个敏捷团队成员都必须在重构上有足够的能力和热情,但这些重构意识的缺乏,让很多敏捷团队只是徒有其名,甚至认为敏捷==快,敏捷是快,但敏捷并不仅仅是快。

重构之前必须有足够的单元测试支撑,重构的第一块基石就是单元测试,重构后必须有及时的持续集成,以最快的方式告诉其他成员:我重构了,你拉下代码。

大多数系统都是重构不足,几乎不会出现重构过度,因此知道什么是重构、为什么要重构、何时重构、重构的挑战在哪里是很重要的,但这也仅仅是第一步,后面如何去重构,也就是重构的方法,才真正涉及到代码实践。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章