再读重构

《重构–第二版》在我的书单里面待了好长一段时间了,趁着放假有时间读了一遍。这本书作为我司首席科学家老马的大作,同时又有大熊和林丛羽的翻译加持,值得每个人认真的反复的学习。

重构作为敏捷实践的精髓之一,在我们这个以敏捷为立身之本的公司里应当属于大家信手拈来的基本技能了。虽然说重构的基本思想长期不过时,但是第一版《重构》毕竟已经是20年前的事情了,20年以来软件开发行业兴起了无数新的编程思想、语言、工具、框架等,现在回过头去看第一版,会发现不仅纸质书籍难以买到,而且知识上也总觉得有点脱节。新版本以JavaScript语言作为示例,重新思考并改进了第一版本中的众多重构手法,结合了多年来一些新的观点和思考,带给了我们一套更为丰富完善的重构体系。

通读一遍本书,很多让我产生共鸣的地方,同时本书让我对于我们日常的一些实践有了新的看法,对于我们经常讨论的一些问题也有了新的结论。下面想摘录一些重要的观点,并分享几点我的理解,与大家一起学习。

重构的几个要点

书中讲到的几个重构要点值得每个人在实践重构的时候引起注意:

  • 重构不改变原有程序的可观测的行为
  • 把添加新功能和重构当做两件不同的事情来对待,就像两顶帽子,在开发过程中我们经常两顶帽子换着戴
  • 小步重构,更安全的前进,让代码绝大部分时间处于可工作的状态
  • 捡垃圾式的重构:发现一个垃圾时,不想跑题太多,同时也不想将垃圾留在原地;如果此时很容易重构,就立即完成,否则就记录下来,等后续再来重构
  • 绝大多数的重构是见机行事的,而非单独安排的一项工作
  • 重构的唯一目的是让我们开发更快,用更少的工作量创造更高的价值;重构不是来自“整洁的代码”“良好的工程实践”等道德要求,而是纯粹从经济角度出发的考量
  • 自测试代码是重构的基石,也是持续集成的关键环节。要想“敏捷”做到名副其实,必须要做好这三大实践–自测试代码、持续集成、重构(自测试代码和重构一起构成了TDD)

各种基础重构手法组合使用,以实现高级的重构目的

我常常见到团队中有人在得到一个好的设计之后,就完全忽略之前的代码,重新进行一遍实现。然而重新实现一遍代码真的不难,难点在于如何让之前的遗留代码还能正常工作。我们常常要手工去处理这些遗留代码,不仅非常花费时间,还很容易出错,如果之前的代码自动化测试不足,那基本上等于自己给自己挖了一个大坑了。有时,我们还可能会让代码中存在两个版本的逻辑,给新的API一个版本号,这就更让人疑惑了。试想,如果我们在修改已有的代码,我们是否应该用新的API呢?新的API真的能和已有的老版本API兼容吗(常常是不行的,比如我们可能会将数据写入到两个不同的数据库表)?

老马的书中反复地乐此不疲地强调小步重构,以便可以随时回退到上一个可工作的版本再次来过。我能想象在没有重构工具支撑时,这是多么重要。但是,现在我们的自动化重构工具已经比较成熟(特别是静态类型语言),我们在做重构时,常常以较大的步子快速完成,我自己也经常这么做。这使得当我们要做一个巨大的重构的时候,我们常常忘了通过小步重构来实现。上面的不计后果的重写某个模块就是这样的一个例子。

实际上通过一系列的小步重构,我们几乎可以完成任意的巨大重构。比如书中的例子,以子类取代类型码的重构。这是一个很复杂的重构了,但是我们可以通过这样一系列步骤安全的进行:

  • 用封装变量将类型码封装到一个函数内部
  • 创建一个工厂函数,根据类型构造不同的子类
  • 创建其中一个子类
  • 在工厂函数中替换构造出来的对象
  • 对每个类型重复上述操作
  • 去除类型码
  • 使用函数下移重构和以多态处理条件表达式重构来处理原来的函数调用处的代码

营地法则

对于如何让我们代码越来越健康,而不是逐渐腐化,我常常在团队里面给大家这样分享:我们每个人都应当在提交代码时停下来想一下,我们这次提交是让代码更健康了,还是更腐化了,还是没变化?只有我们每次提交代码都至少没有让代码更腐化,代码才能越来越健康。一些腐化的例子比如我们引入了一个坏味道,我们测试覆盖率降低了等等。而一个健康的例子就是我们在增加功能的时候,顺便重构了原来的代码,让其可读性更高,可复用性更好等等。

在老马谈论捡垃圾式重构法时提到了营地法则–我们应该至少在离开营地时,让营地比我们到来时更干净。如果每次经过一段代码,都让其变得更干净,积少成多,垃圾总是会被处理掉。

仔细一想,这样的观点竟然出奇的一致。与大家共勉。

使用PR进行代码复审还是专门组织代码复审活动?

在谈论到如何在代码复审进行重构时,老马提到我们可以在阅读他人代码之后,如果有一些点子,就可以直接进行重构,从而获得比想象出来的更直观的认识。这样的代码复审活动最好能有代码作者一起pair,不仅可以有机会充分理解原来的代码,还能一起分享重构的意图。

现在有些团队热衷于使用Pull Request的方式进行代码复审,并在代码后面留下大篇大篇的评论,以便让代码作者去修复问题。这样的风潮大概是兴起于github的流行,多分支管理策略如git flowgithub flow中建议以Pull Request的方式进行代码复审和合并。看起来由于多了一道关卡,我们合入主干的代码质量能有所提高。但是这样的分支管理策略是不够敏捷的,它之所以适合在github上推行,是由于github上面的项目多是由分布式的团队完成。有一大群水平参差不齐的,对代码理解也差异巨大的开发者可能要一起修改代码,并且大家还要跨地域跨时区进行协作。要支持这样的团队开发,多分支策略有其优势。但是对于我们日常的敏捷团队而言,大家每天坐一起,专注在同一个项目上,这样的多分支策略就显得过于重量级了,发挥不出团队可以随时沟通交流的优势。

那么对于敏捷团队而言,更好的代码复审方式是怎么样的呢?我们可以尝试少数人pair的方式进行复审,我个人更推荐还是要回归到每天的团队代码复审活动中来。

重构与单分支管理策略

与上面代码复审类似,敏捷团队中更推荐的是单分支管理策略,因为这样的分支策略可以让我们更快的集成,从而更好的支持重构。试想,如果不是单分支,当我们在代码库里面进行重构之后,又必须得经过一个复杂而耗时的流程才能合入主干,这个时候合并代码会有多痛苦。我们很可能发现在一个两天前的提交中使用的一个接口,却已经被另一个人通过重构改名了。有人说我们可以通过频繁拉取主干的代码来缓解这个问题,但是合并是双向的过程,当修改太多的时候,每次的合并操作也会让人累的够呛,而且我可能长时间看不到别人的修改,潜在的合并代码风险无形中变大了。总之,特性分支游离的时间越长,合并越痛苦。基于单分支的策略,只要代码能通过测试,我们就可以随时将本地可用的代码推送到分支上,随时集成。这对于重构带来的压力将会小很多很多(将减少很多没必要的合并操作和破坏代码的机会)。

单分支管理策略不是说完全不使用分支,我们还是可以适当使用分支完成一些探索性的工作,或者完成产品发布工作。在这里,快速的合并代码和快速的集成是最重要的。

所以,对于一些热衷于git flow或者github flow的团队,我们可能尤其需要注意这个问题。

重构与测试

测试是重构的保护伞,我们需要有一组可靠的测试才能放手进行重构。测试的重要性不言而喻,相信我司大家都认可这一点。这里我想引用书中的观点,并提及的一点是我们应该何时停止写测试。

有一些测试规则建议会尝试保证我们测试一切的组合,虽然这些建议值得了解,但是实践中我们需要适可而止,因为测试达到一定程度之后,其边际效用会递减。如果编写太多测试,我们可能因为工作量太大而气馁。我们应该把注意力集中在最容易出错的地方,最没有信心的地方。

一些测试的指标,如覆盖率,能一定程度上衡量测试是否全面而有效,但是最佳的衡量方式可能来自于主观的感受,如果我们觉得对代码比较有信心,那就说明我们的测试做的不错了。

不依赖于某个特定测试框架进行测试

在老马的测试例子中,用到了mochachai两个JavaScript的测试工具,恰好这两个框架也是我写JS代码时最喜欢用的工具,除此之外,还有一个用来模拟对象的库sinon。这三个工具是大概两年前我从我们的QA处得知的,用起来十分趁手,一直沿用到现在。

这三个工具的一大特点就是不依赖任何的其他框架而独立存在,属于那种小而美的工具。这里我想提这三个工具的原因是我经常发现不少同学喜欢使用某个框架自带的测试基础设施进行测试,而非使用这些小而美的工具。比如很多人喜欢使用Angular提供的TestBed进行测试,或者喜欢使用Spring提供的SpringBootTest注解进行测试。然而这样的测试的问题在于其过度依赖于某个框架,测试运行过程中会执行到大量的框架代码,从而导致测试运行缓慢,出错了也不好定位问题。事实上我们的代码中主要关注的是应用自身的业务逻辑,这些业务逻辑才是测试的重点,而非框架的逻辑。当然有时候我们可能对框架的配置代码信心不够强(比如spring的配置还是比较复杂的),这个时候,我们可以有针对性的写少数几个测试来验证这些配置就足够了。对于应用自身的业务逻辑代码的测试,我们完全没必要基于某个具体框架来做,试想如果这些测试都是仅仅基于junit实现的,那这些测试将非常容易理解,非常容易迁移(比如当前的一个基于Spring的后端项目,可以根据需要很容易的迁移到Android移动平台)。

基于工具而非某个特定框架去组织测试的实践在我的平常工作中获益良多,而基于某个特定框架实现的测试往往维护困难。有了这样的认识,我们再来看本书中的所有测试,我们将发现它们都是简单而有效的。

(严格来说,mocha或者junit应该也算一种测试框架,但是他们的好处在于非常轻量级,没有任何其他框架的依赖和束缚。在这里我想表达的是我们要弱化某个具体的框架,转而使用一些通用框架和工具进行测试。)

最后

当然书中的精华还有很多很多,我尤其喜欢前四章以及后续重构名录中的动机部分,这些部分详细的回答了为什么做以及何时做这两个问题。总之,本书值得当做一本程序员的字典时常翻阅。