# 06 | 以增量演进为手段:为什么历时一年的改造到头来是一场空? 你好,我是姚琪琳。 今天我们来聊聊遗留系统现代化中的HOW,也就是第三个原则,以增量演进为手段。 很多团队在一阵大张旗鼓的遗留系统改造后,终于迎来了最终的“梭哈”时刻。尽管事先可能在各种测试环境测过无数遍了,但上线生产环境仍然如履薄冰。 和遗留系统项目“相爱相杀”十几年,我可以肯定地告诉你,这种一次性交付的大规模遗留系统改造,几乎不可能一上线就成功,必然会有各种或大或小的问题,甚至导致不得不全量回滚,交付日期一拖再拖。哪怕你的“战前准备”历时一年,甚至更久,到头还是一地鸡毛。 你可能会有疑问,你见过很多大厂的案例,都是一次性上线的。没错,的确是这样,但大厂之所以有勇气这么做,是因为他们有很强的人力、物力支撑,客观条件允许这么做。对于资源有限的小公司、小项目,还是应该衡量一下改造的难度和运维的能力,以控制风险为主。 怎么控制风险呢?我的答案是增量演进。这节课,我带你把这个概念搞通透,顺便演示下代码和架构的增量演进怎么做。 ## 什么是增量演进? 什么是增量?什么又是演进呢?这要从演进式架构开始说起。 我在北美的同事Neal Ford和Rebecca Parsons,在《演进式架构》这本书中给演进式架构下了精准的定义:**支持跨多个维度的引导性增量变更的架构**。 这么多的限定词,你乍一听挺懵,别急,我给你解释一下就清楚了。其中,多维度是指技术、数据、安全、运维等不同的看待架构的视角;引导性是指在**适应度函数**的引导下,向着正确的方向演进架构;而增量变更是指以小步快跑的方式,细粒度地构建和部署软件,同时在一定程度上允许新旧两种实现并行运行。 我这里说的遗留系统中的增量演进,借鉴了演进式架构中“增量”的概念。我们可以把已有的遗留系统作为“存量”,而每一次的优化、改进作为“增量”。“演进”则要求我们将这些增量划分成非常小的粒度。这些小的增量也可以随时部署到各种环境来进行验证,每次验证的最小单元都是这些小的增量,而不是整个的改造结果。 同时,新改进的实现和老的实现是并存的,一旦在验证时发现问题,可以随时回退到老实现。 因此,**增量演进是指,以增量的方式,不断向明确的目标前进的过程**。 虽然理论上,可演进的架构才更容易实现小的增量变更,但大多数遗留系统的架构显然不是可演进的。这时候我们怎么实现相对细粒度的增量交付呢?我们从代码和架构两个维度为例,具体分析一下。 ## 代码的增量演进 在代码现代化方面,我们的主要目标包括三类:修补测试、代码重构、代码分层。接下来我将以代码重构为例,向你演示如何实现增量演进。 下面的代码来自《代码整洁之道》第2章“有意义的命名”,Bob大叔举了这样一个例子来吐槽糟糕的命名。这段代码来自一个扫雷游戏,想实现获取所有被标记过的单元格的目的。 ```java public List getThem() {  List list1 = new ArrayList();  for (int[] x : theList)    if (x[0] == 4)     list1.add(x);  return list1; } ``` 然而你不难发现,这段代码的坏味道远不止getThem、theList这种**晦涩的命名**,还包括**魔法数字**、**基本类型偏执**等。 面对如此多的坏味道,我相信对代码有洁癖的你,已经摩拳擦掌准备重构了吧?但是请别急,如果你直接改代码,在没有测试的情况下,有信心保证正百分之百正确吗? 在遗留系统中,到处充斥着这样的糟糕代码,而且没有测试覆盖。我们可以选择先补测试,然后再开始重构。这也是我强烈推荐的方式,因为这样的步子迈得更稳、更扎实。 但有时代码本身并不可测,还要先完成可测试化改造。我的初衷就是单纯地重构这段代码,现在又要可测试化,又要加测试,似乎外延越来越广了,工作量也随之越来越大。有没有办法不用加测试,也能安全地重构呢,并且完成增量式交付呢?答案是肯定的。 这种方法其实很简单,就是**先把代码复制出来一份**,**在复制的代码处进行重构**。等重构完毕,再通过某种开关,来控制新旧代码的切换。在测试时,可以通过开关来做A/B测试,从而确保重构的正确性。 除了复制代码的方式外,还有一种更巧妙的方法来实现无测试的安全重构,并完成增量交付。这里我先卖个关子,等到后面的模式篇再来介绍这种方法。 重构完的代码可以像下面这样,只有一行,十分精练: ```java public List getFlaggedCells() { return gameBoard.stream().filter(c -> c.isFlagged()).collect(toList()); } ``` 在这里我就不介绍具体的重构过程了,**毕竟我们的重点是增量交付。**重构代码的方法,我们后面模式篇再展开讲,这里也顺便推荐郑晔的《[代码之丑](https://time.geekbang.org/column/intro/100068401?tab=catalog)》专栏。 在原方法的调用端,我们可以像这样引入开关,来实现这个增量: ```java List cells; List cellsRefactored; if (toggleOff) { cells = getThem(); // 其他代码 } else { cellsRefactored = getFlaggedCells(); // 其他代码 } ``` 开关的值通常都写到配置文件,或存储在数据库里。我们可以通过修改这个配置,不断验证新代码的行为是否和旧代码完全一致。直到经过了充分的测试,我们有了十足的信心,再来删掉开关,将旧代码完全删除。 我的同事,《[说透中台](https://time.geekbang.org/column/intro/100036501?tab=catalog)》专栏的作者王健,曾经把这种重构手法总结为“**十六字心法**”,非常形象、贴切: > 旧的不变,新的创建。一步切换,旧的再见。 “旧的不变”是指先不动旧方法;“新的创建”是指创建一个跟原来方法功能相同的新方法,你可以通过先复制再重构的方式,来得到这个新方法,也就是整个系统的一个增量;“一步切换”是指,在充分测试之后,新的方法可以完全替代旧方法了,就将开关切换到新方法上;“旧的再见”则意味着删除旧方法以及相应的开关,一个演进到此也就结束了。 你会发现,这十六字心法不光适用于代码重构,也可以推广、复用,用在架构、安全、性能等其他维度,作为增量演进的指导方针。 ## 架构的增量演进 如果说代码的重构还可以在短时间内完成并上线,那架构的重新设计就很难一蹴而就了。这其实就更加需要小步上线,随时验证了。 你可能会说:“骗人的吧?你要是说代码的改动可以小步前进,我还相信,但是架构调整这么大的动作,怎么可能增量演进呢?”这其实就是我们一直想要强调的,越是大的改进,越要频繁上线去验证,不要等到最后来个“大惊喜”。 对于架构或系统的替换,Martin Fowler提出了**绞杀植物模式**。这源于他一次在澳大利亚旅行时发现的奇观,一棵巨大的古树被榕树的藤蔓缠绕,许多年以后最终被榕树所取代。 “老马”(国内对于Martin Fowler的昵称)想到了一种与之类似的系统替换的方式,也就是新建一个系统,让它与旧系统并存且缓慢增长,直到某一天完全取代旧的系统。于是,老马就给这种方法起了一个名字,叫绞杀植物模式。 这里稍微说个题外话,这个模式一开始的名字是Strangler,国内通常的翻译是“绞杀者模式”。2019年老马在个人网站上修订了这篇博客,将模式重新命名为Strangler Fig。原因是这个模式虽然越来越流行,但是名字太血腥太暴力。Strangler Fig直译成中文是绞杀无花果,听上去有点莫名其妙。其实Strangler本身就有绞杀植物的含义,因此我个人倾向于把这个模式翻译为绞杀植物模式。 使用绞杀植物模式最主要的好处,就是降低风险。作为绞杀植物的新系统可以稳定提供价值,并且频繁发布。你还可以很好地监控它的状态和进度。 这种新旧系统或架构同时存在、同时运行、逐渐替换的方式,就是我们的增量演进所追求的目标。 假设我们有这样一个单体系统,包含员工、财务和薪酬三个模块,其中员工和薪酬模块都会调用通知模块来发送邮件或短信。上游服务或前端页面通过HTTP API来访问不同的模块。 ![图片](https://static001.geekbang.org/resource/image/cb/6c/cbda599236da28ff9a9a35763bed276c.jpg?wh=1920x1110) 如果我们希望将薪酬模块迁移到独立的服务中,应该如何使用绞杀植物模式,以增量演进的方式做拆分呢? 我们可以分四步完成拆分。 第一步,**建立开关**。要实现增量演进,开关是必不可少的。一方面可以通过开关来控制A/B测试,以验证功能不被破坏,另一方面一旦新实现有问题,也能迅速回退到旧实现。 你可以将这个开关实现在API调用薪酬模块的地方,当开关打开的时候,调用新的薪酬服务,当开关关闭的时候,仍然调用已有的薪酬模块。这个开关可以是粗粒度的一个开关,也可以是细粒度的每个功能点一个开关。我建议你把开关尽可能设小一些,在实战中这种方式可以获得更小的增量演进和回滚。 ![图片](https://static001.geekbang.org/resource/image/9e/c7/9e6c3ecee98743aebe30a142e3c559c7.jpg?wh=1920x995) 现在的薪酬服务还是一个空壳,没有任何实现。如果打开开关,应该得到一个501 Not Implemented错误。 第二步,**增量迁移**。按迭代逐步将薪酬模块的功能迁移到薪酬服务中。假设我们需要4个迭代来完成全部的迁移工作,迭代0的工作主要是为开发开关和搭建新服务的脚手架,其余迭代就可以按计划来迁移不同的功能了。 ![图片](https://static001.geekbang.org/resource/image/dc/b8/dcdcee412c348eb0bc3f495782e26ab8.jpg?wh=1920x1116) 在这一步,我建议你从迭代0开始,就把薪酬服务部署到生产环境中。虽然迭代0中的薪酬服务还没有任何功能,但这可以让你先测试整个部署的过程,以及服务的连通性。否则你就要在迭代1交付的时候既测试部署,又测试功能了。 你可能会注意到,我们虽然在迭代0**部署**了薪酬服务,但是开关并不会打开,因此并不意味着**交付**了薪酬服务的功能。我们将软件部署和软件交付(或软件发布)的概念做了区分,相信你能体会到它们之间的差别。 从迭代1开始,就会有迁移完成的增量发布到薪酬服务中了,你可以打开开关来测试这一部分的功能。 第三步,**并行运行**。对于有一定规模的架构演进,我强烈建议你将开关和旧代码保留一段时间,让新旧代码并行运行几个迭代。 对于遗留系统来说,这样做好处是利用新旧实现并行的这段时间,让隐藏的坑逐步浮现出来,直到我们对新实现有十足信心。这里说的“隐藏的坑”意思是指,隐藏在代码和架构深处的,那些任何人都不曾知道的问题。它们随时可能会暴露出来。多并行一段时间,可以让“子弹飞一会儿”,看看是否能够暴露出这些问题。 **并行运行**和绞杀植物模式一样,也是一种常用的架构现代化模式,我们会在模式篇里详细介绍。 第四步,**代码清理**。删除旧代码和开关,切记不要忘了这一步。很多遗留系统的架构演进都没有完成这一步,导致很多无用的代码留在系统中。它们除了给人带来迷惑之外没有任何用处。 完成这四步之后,我们就实现了架构的增量演进过程。你会发现,架构的增量演进与代码的增量演进一样,也完美契合了“旧的不变,新的创建,一步切换,旧的再见”这十六字心法。 ## 小结 又到了总结的时候。为什么历时很久的遗留系统改造会以失败而告终呢?一是因为直到最后一刻才上线,失去了持续验证的机会;二是上线后发现有问题,只能硬着头皮热修复,或者整体回滚,缺乏细粒度的回退机制。 而增量演进原则可以有效解决这个问题。它一方面鼓励我们持续交付改造的功能或新的实现,不断在生产环境验证;另一方面拥有细粒度的开关,也使得回退变得十分灵活,一旦发现问题,我们只需要关闭引起问题的那个开关即可。 在**以增量演进为手段**这个原则的指导下,我对代码和架构的演进步骤做了比较详细的演示。此外,在软件系统的其他维度,如数据、安全、性能、运维等,也可以用同样的方式完成改进。 增量演进的思想不仅体现在遗留系统现代化之中,我们平时做设计的时候,也应该遵循增量演进的思想。一方面给予回退的可能,小步地上线,另一方面,也可以先上线一个简单的方案,然后再随着遇到的问题去不断演进这个方案。 我发现很多架构师在设计一个方案时喜欢一步到位,但这其实是错误的。这个世界上根本不存在完美的架构,所有的架构都应该是通过不断演进而浮现出来的,在演进的过程中我们应该根据当前上下文和约束的改变而不断调整,最终得到一个“差不多的”或者“刚刚好”的架构。 而一步到位的思想,轻则导致过度设计,重则完全走错了方向,因为没能尽早上线去收集反馈。虽然很多一步到位的决策,最后结果是走对了方向,那也不能说明你有眼光,只能说明你运气好。 另外,我还剧透了绞杀植物模式、并行运行模式等遗留系统现代化模式。想要了解更多的模式,欢迎你继续学习接下来的模式篇。 到此为止,我讲完了遗留系统现代化的三大原则。从[下节课](https://time.geekbang.org/column/article/511924)开始,我们将进入模式篇的学习,你将在这一部分看到很多似曾相识的模式,它们是进行遗留系统现代化强有力的工具和方法。 ## 思考题 感谢你学完了今天的内容,我给你留了三道思考题,你可以任选一个或者多个说说想法: 1.在你的项目中,是如何做代码和架构的重构的?是否曾经使用过类似增量演进的方式呢?期待你分享一下自己团队经验。 2.除了复制代码并重构之外,我在文中还提到了另外一种方法,用来在无测试的情况下完成安全重构,你想到是什么了吗? 3.在架构的增量演进一节中,单体系统中的薪酬模块对通知模块是有依赖的,那么新的薪酬服务拆分出去之后,应该如何实现对外发通知的功能呢? 欢迎你在评论区留下你的思考,也欢迎你把这节课分享给你的同事和朋友,我们一起讨论、进步。