ThinkChat🤖让你学习和工作更高效,注册即送10W Token,即刻开启你的AI之旅 广告
每当有人大力推荐一种技术、工具或者架构时,我总是会观察这东西会遇到哪些挑战,毕竟生活中很少有晴空万里的好事。你需要了解一件事背后的权衡取舍,才能决定何时何地应用它。我认为重构是一种很有价值的技术,大多数团队都应该更多地重构,但它也不是完全没有挑战的。有必要充分了解重构会遇到的挑战,这样才能做出有效应对。 ### 延缓新功能开发 如果你读了前面一小节,我对这个挑战的回应便已经很清楚了。尽管重构的目的是加快开发速度,但是,仍旧很多人认为,花在重构的时间是在拖慢新功能的开发进度。“重构会拖慢进度”这种看法仍然很普遍,这可能是导致人们没有充分重构的最大阻力所在。 > ![](https://box.kancloud.cn/9cf522e33e311401bf0d755d003df8ea_19x20.jpeg) 重构的唯一目的就是让我们开发更快,用更少的工作量创造更大的价值。 有一种情况确实需要权衡取舍。我有时会看到一个(大规模的)重构很有必要进行,而马上要添加的功能非常小,这时我会更愿意先把新功能加上,然后再做这次大规模重构。做这个决定需要判断力——这是我作为程序员的专业能力之一。我很难描述决定的过程,更无法量化决定的依据。 我清楚地知道,预备性重构常会使修改更容易,所以如果做一点儿重构能让新功能实现更容易,我一定会做。如果一个问题我已经见过,此时我也会更倾向于重构它——有时我就得先看见一块丑陋的代码几次,然后才能提起劲头来重构它。也就是说,如果一块代码我很少触碰,它不会经常给我带来麻烦,那么我就倾向于不去重构它。如果我还没想清楚究竟应该如何优化代码,那么我可能会延迟重构;当然,有的时候,即便没想清楚优化的方向,我也会先做些实验,试试看能否有所改进。 我从同事那里听到的证据表明,在我们这个行业里,重构不足的情况远多于重构过度的情况。换句话说,绝大多数人应该尝试多做重构。代码库的健康与否,到底会对生产率造成多大的影响,很多人可能说不出来,因为他们没有太多在健康的代码库上工作的经历——轻松地把现有代码组合配置,快速构造出复杂的新功能,这种强大的开发方式他们没有体验过。 虽然我们经常批评管理者以“保障开发速度”的名义压制重构,其实程序员自己也经常这么干。有时他们自己觉得不应该重构,其实他们的领导还挺希望他们做一些重构的。如果你是一支团队的技术领导,一定要向团队成员表明,你重视改善代码库健康的价值。合理判断何时应该重构、何时应该暂时不重构,这样的判断力需要多年经验积累。对于重构缺乏经验的年轻人需要有意的指导,才能帮助他们加速经验积累的过程。 有些人试图用“整洁的代码”“良好的工程实践”之类道德理由来论证重构的必要性,我认为这是个陷阱。重构的意义不在于把代码库打磨得闪闪发光,而是纯粹经济角度出发的考量。我们之所以重构,因为它能让我们更快——添加功能更快,修复bug更快。一定要随时记住这一点,与别人交流时也要不断强调这一点。重构应该总是由经济利益驱动。程序员、经理和客户越理解这一点,“好的设计”那条曲线就会越经常出现。 ### 代码所有权 很多重构手法不仅会影响一个模块内部,还会影响该模块与系统其他部分的关系。比如我想给一个函数改名,并且我也能找到该函数的所有调用者,那么我只需运用改变函数声明(124),在一次重构中修改函数声明和调用者。但即便这么简单的一个重构,有时也无法实施:调用方代码可能由另一支团队拥有,而我没有权限写入他们的代码库;这个函数可能是一个提供给客户的API,这时我根本无法知道是否有人使用它,至于谁在用、用得有多频繁就更是一无所知。这样的函数属于已发布接口(published interface):接口的使用者(客户端)与声明者彼此独立,声明者无权修改使用者的代码。 代码所有权的边界会妨碍重构,因为一旦我自作主张地修改,就一定会破坏使用者的程序。这不会完全阻止重构,我仍然可以做很多重构,但确实会对重构造成约束。为了给一个函数改名,我需要使用函数改名(124),但同时也得保留原来的函数声明,使其把调用传递给新的函数。这会让接口变复杂,但这就是为了避免破坏使用者的系统而不得不付出的代价。我可以把旧的接口标记为“不推荐使用”(deprecated),等一段时间之后最终让其退休;但有些时候,旧的接口必须一直保留下去。 由于这些复杂性,我建议不要搞细粒度的强代码所有制。有些组织喜欢给每段代码都指定唯一的所有者,只有这个人能修改这段代码。我曾经见过一支只有三个人的团队以这种方式运作,每个程序员都要给另外两人发布接口,随之而来的就是接口维护的种种麻烦。如果这三个人都直接去代码库里做修改,事情会简单得多。我推荐团队代码所有制,这样一支团队里的成员都可以修改这个团队拥有的代码,即便最初写代码的是别人。程序员可能各自分工负责系统的不同区域,但这种责任应该体现为监控自己责任区内发生的修改,而不是简单粗暴地禁止别人修改。 这种较为宽容的代码所有制甚至可以应用于跨团队的场合。有些团队鼓励类似于开源的模型:B团队的成员也可以在一个分支上修改A团队的代码,然后把提交发送给A团队去审核。这样一来,如果团队想修改自己的函数,他们就可以同时修改该函数的客户端的代码;只要客户端接受了他们的修改,就可以删掉旧的函数声明了。对于涉及多个团队的大系统开发,在“强代码所有制”和“混乱修改”两个极端之间,这种类似开源的模式常常是一个合适的折中。 ### 分支 很多团队采用这样的版本控制实践:每个团队成员各自在代码库的一条分支上工作,进行相当大量的开发之后,才把各自的修改合并回主线分支(这条分支通常叫master或trunk),从而与整个团队分享。常见的做法是在分支上开发完整的功能,直到功能可以发布到生产环境,才把该分支合并回主线。这种做法的拥趸声称,这样能保持主线不受尚未完成的代码侵扰,能保留清晰的功能添加的版本记录,并且在某个功能出问题时能容易地撤销修改。 这样的特性分支有其缺点。在隔离的分支上工作得越久,将完成的工作集成(integrate)回主线就会越困难。为了减轻集成的痛苦,大多数人的办法是频繁地从主线合并(merge)或者变基(rebase)到分支。但如果有几个人同时在各自的特性分支上工作,这个办法并不能真正解决问题,因为合并与集成是两回事。如果我从主线合并到我的分支,这只是一个单向的代码移动——我的分支发生了修改,但主线并没有。而“集成”是一个双向的过程:不仅要把主线的修改拉(pull)到我的分支上,而且要把我这里修改的结果推(push)回到主线上,两边都会发生修改。假如另一名程序员Rachel正在她的分支上开发,我是看不见她的修改的,直到她将自己的修改与主线集成;此时我就必须把她的修改合并到我的特性分支,这可能需要相当的工作量。其中困难的部分是处理语义变化。现代版本控制系统都能很好地合并程序文本的复杂修改,但对于代码的语义它们一无所知。如果我修改了一个函数的名字,版本控制工具可以很轻松地将我的修改与Rachel的代码集成。但如果在集成之前,她在自己的分支里新添调用了这个被我改名的函数,集成之后的代码就会被破坏。 分支合并本来就是一个复杂的问题,随着特性分支存在的时间加长,合并的难度会指数上升。集成一个已经存在了4个星期的分支,较之集成存在了2个星期的分支,难度可不止翻倍。所以很多人认为,应该尽量缩短特性分支的生存周期,比如只有一两天。还有一些人(比如我本人)认为特性分支的生命还应该更短,我们采用的方法叫作持续集成(Continuous Integration,CI),也叫“基于主干开发”(Trunk-Based Development)。在使用CI时,每个团队成员每天至少向主线集成一次。这个实践避免了任何分支彼此差异太大,从而极大地降低了合并的难度。不过CI也有其代价:你必须使用相关的实践以确保主线随时处于健康状态,必须学会将大功能拆分成小块,还必须使用特性开关(feature toggle,也叫特性旗标,feature flag)将尚未完成又无法拆小的功能隐藏掉。 CI的粉丝之所以喜欢这种工作方式,部分原因是它降低了分支合并的难度,不过最重要的原因还是CI与重构能良好配合。重构经常需要对代码库中的很多地方做很小的修改(例如给一个广泛使用的函数改名),这样的修改尤其容易造成合并时的语义冲突。采用特性分支的团队常会发现重构加剧了分支合并的困难,并因此放弃了重构,这种情况我们曾经见过多次。CI和重构能够良好配合,所以Kent Beck在极限编程中同时包含了这两个实践。 我并不是在说绝不应该使用特性分支。如果特性分支存在的时间足够短,它们就不会造成大问题。(实际上,使用CI的团队往往同时也使用分支,但他们会每天将分支与主线合并。)对于开源项目,特性分支可能是合适的做法,因为不时会有你不熟悉(因此也不信任)的程序员偶尔提交修改。但对全职的开发团队而言,特性分支对重构的阻碍太严重了。即便你没有完全采用CI,我也一定会催促你尽可能频繁地集成。而且,用上CI的团队在软件交付上更加高效,我真心希望你认真考虑这个客观事实\[Forsgren et al\]。 ### 测试 不会改变程序可观察的行为,这是重构的一个重要特征。如果仔细遵循重构手法的每个步骤,我应该不会破坏任何东西,但万一我犯了个错误怎么办?(呃,就我这个粗心大意的性格来说,请去掉“万一”两字。)人总会有出错的时候,不过只要及时发现,就不会造成大问题。既然每个重构都是很小的修改,即便真的造成了破坏,我也只需要检查最后一步的小修改——就算找不到出错的原因,只要回滚到版本控制中最后一个可用的版本就行了。 这里的关键就在于“快速发现错误”。要做到这一点,我的代码应该有一套完备的测试套件,并且运行速度要快,否则我会不愿意频繁运行它。也就是说,绝大多数情况下,如果想要重构,我得先有可以自测试的代码\[mf-stc\]。 有些读者可能会觉得,“自测试的代码”这个要求太高,根本无法实现。但在过去20年中,我看到很多团队以这种方式构造软件。的确,团队必须投入时间与精力在测试上,但收益是绝对划算的。自测试的代码不仅使重构成为可能,而且使添加新功能更加安全,因为我可以很快发现并干掉新近引入的bug。这里的关键在于,一旦测试失败,我只需要查看上次测试成功运行之后修改的这部分代码;如果测试运行得很频繁,这个查看的范围就只有几行代码。知道必定是这几行代码造成bug的话,排查起来会容易得多。 这也回答了“重构风险太大,可能引入bug”的担忧。如果没有自测试的代码,这种担忧就是完全合理的,这也是为什么我如此重视可靠的测试。 缺乏测试的问题可以用另一种方式来解决。如果我的开发环境很好地支持自动化重构,我就可以信任这些重构,不必运行测试。这时即便没有完备的测试套件,我仍然可以重构,前提是仅仅使用那些自动化的、一定安全的重构手法。这会让我损失很多好用的重构手法,不过剩下可用的也不少,我还是能从中获益。当然,我还是更愿意有自测试的代码,但如果没有,自动化重构的工具包也很好。 缺乏测试的现状还催生了另一种重构的流派:只使用一组经过验证是安全的重构手法。这个流派要求严格遵循重构的每个步骤,并且可用的重构手法是特定于语言的。使用这种方法,团队得以在测试覆盖率很低的大型代码库上开展一些有用的重构。这个重构流派比较新,涉及一些很具体、特定于编程语言的技巧与做法,行业里对这种方法的介绍和了解都还不足,因此本书不对其多做介绍。(不过我希望未来在我自己的网站上多讨论这个主题。感兴趣的读者可以查看Jay Bazuzi关于如何在C++中安全地运用提炼函数(106)的描述\[Bazuzi\],借此获得一点儿对这个重构流派的了解。) 毫不意外,自测试代码与持续集成紧密相关——我们仰赖持续集成来及时捕获分支集成时的语义冲突。自测试代码是极限编程的另一个重要组成部分,也是持续交付的关键环节。 ### 遗留代码 大多数人会觉得,有一大笔遗产是件好事,但从程序员的角度来看就不同了。遗留代码往往很复杂,测试又不足,而且最关键的是,是别人写的(瑟瑟发抖)。 重构可以很好地帮助我们理解遗留系统。引人误解的函数名可以改名,使其更好地反映代码用途;糟糕的程序结构可以慢慢理顺,把程序从一块顽石打磨成美玉。整个故事都很棒,但我们绕不开关底的恶龙:遗留系统多半没测试。如果你面对一个庞大而又缺乏测试的遗留系统,很难安全地重构清理它。 对于这个问题,显而易见的答案是“没测试就加测试”。这事听起来简单(当然工作量必定很大),操作起来可没那么容易。一般来说,只有在设计系统时就考虑到了测试,这样的系统才容易添加测试——可要是如此,系统早该有测试了,我也不用操这份心了。 这个问题没有简单的解决办法,我能给出的最好建议就是买一本《修改代码的艺术》\[Feathers\],照书里的指导来做。别担心那本书太老,尽管已经出版十多年,其中的建议仍然管用。一言以蔽之,它建议你先找到程序的接缝,在接缝处插入测试,如此将系统置于测试覆盖之下。你需要运用重构手法创造出接缝——这样的重构很危险,因为没有测试覆盖,但这是为了取得进展必要的风险。在这种情况下,安全的自动化重构简直就是天赐福音。如果这一切听起来很困难,因为它确实很困难。很遗憾,一旦跌进这个深坑,没有爬出来的捷径,这也是我强烈倡导从一开始就写能自测试的代码的原因。 就算有了测试,我也不建议你尝试一鼓作气把复杂而混乱的遗留代码重构成漂亮的代码。我更愿意随时重构相关的代码:每次触碰一块代码时,我会尝试把它变好一点点——至少要让营地比我到达时更干净。如果是一个大系统,越是频繁使用的代码,改善其可理解性的努力就能得到越丰厚的回报。 ### 数据库 在本书的第1版中,我说过数据库是“重构经常出问题的一个领域”。然而在第1版问世之后仅仅一年,情况就发生了改变:我的同事Pramod Sadalage发展出一套渐进式数据库设计\[mf-evodb\]和数据库重构\[Ambler & Sadalage\]的办法,如今已经被广泛使用。这项技术的精要在于:借助数据迁移脚本,将数据库结构的修改与代码相结合,使大规模的、涉及数据库的修改可以比较容易地开展。 假设我们要对一个数据库字段(列)改名。和改变函数声明(124)一样,我要找出结构的声明处和所有调用处,然后一次完成所有修改。但这里的复杂之处在于,原来基于旧字段的数据,也要转为使用新字段。我会写一小段代码来执行数据转化的逻辑,并把这段代码放进版本控制,跟数据结构声明与使用代码的修改一并提交。此后如果我想把数据库迁移到某个版本,只要执行当前数据库版本与目标版本之间的所有迁移脚本即可。 跟通常的重构一样,数据库重构的关键也是小步修改并且每次修改都应该完整,这样每次迁移之后系统仍然能运行。由于每次迁移涉及的修改都很小,写起来应该容易;将多个迁移串联起来,就能对数据库结构及其中存储的数据做很大的调整。 与常规的重构不同,很多时候,数据库重构最好是分散到多次生产发布来完成,这样即便某次修改在生产数据库上造成了问题,也比较容易回滚。比如,要改名一个字段,我的第一次提交会新添一个字段,但暂时不使用它。然后我会修改数据写入的逻辑,使其同时写入新旧两个字段。随后我就可以修改读取数据的地方,将它们逐个改为使用新字段。这步修改完成之后,我会暂停一小段时间,看看是否有bug冒出来。确定没有bug之后,我再删除已经没人使用的旧字段。这种修改数据库的方式是并行修改(Parallel Change,也叫扩展协议/expand-contract)\[mf-pc\]的一个实例。