You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

178 lines
15 KiB
Markdown

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 06 | 以增量演进为手段:为什么历时一年的改造到头来是一场空?
你好,我是姚琪琳。
今天我们来聊聊遗留系统现代化中的HOW也就是第三个原则以增量演进为手段。
很多团队在一阵大张旗鼓的遗留系统改造后,终于迎来了最终的“梭哈”时刻。尽管事先可能在各种测试环境测过无数遍了,但上线生产环境仍然如履薄冰。
和遗留系统项目“相爱相杀”十几年,我可以肯定地告诉你,这种一次性交付的大规模遗留系统改造,几乎不可能一上线就成功,必然会有各种或大或小的问题,甚至导致不得不全量回滚,交付日期一拖再拖。哪怕你的“战前准备”历时一年,甚至更久,到头还是一地鸡毛。
你可能会有疑问,你见过很多大厂的案例,都是一次性上线的。没错,的确是这样,但大厂之所以有勇气这么做,是因为他们有很强的人力、物力支撑,客观条件允许这么做。对于资源有限的小公司、小项目,还是应该衡量一下改造的难度和运维的能力,以控制风险为主。
怎么控制风险呢?我的答案是增量演进。这节课,我带你把这个概念搞通透,顺便演示下代码和架构的增量演进怎么做。
## 什么是增量演进?
什么是增量?什么又是演进呢?这要从演进式架构开始说起。
我在北美的同事Neal Ford和Rebecca Parsons在《演进式架构》这本书中给演进式架构下了精准的定义**支持跨多个维度的引导性增量变更的架构**。
这么多的限定词,你乍一听挺懵,别急,我给你解释一下就清楚了。其中,多维度是指技术、数据、安全、运维等不同的看待架构的视角;引导性是指在**适应度函数**的引导下,向着正确的方向演进架构;而增量变更是指以小步快跑的方式,细粒度地构建和部署软件,同时在一定程度上允许新旧两种实现并行运行。
我这里说的遗留系统中的增量演进,借鉴了演进式架构中“增量”的概念。我们可以把已有的遗留系统作为“存量”,而每一次的优化、改进作为“增量”。“演进”则要求我们将这些增量划分成非常小的粒度。这些小的增量也可以随时部署到各种环境来进行验证,每次验证的最小单元都是这些小的增量,而不是整个的改造结果。
同时,新改进的实现和老的实现是并存的,一旦在验证时发现问题,可以随时回退到老实现。
因此,**增量演进是指,以增量的方式,不断向明确的目标前进的过程**。
虽然理论上,可演进的架构才更容易实现小的增量变更,但大多数遗留系统的架构显然不是可演进的。这时候我们怎么实现相对细粒度的增量交付呢?我们从代码和架构两个维度为例,具体分析一下。
## 代码的增量演进
在代码现代化方面,我们的主要目标包括三类:修补测试、代码重构、代码分层。接下来我将以代码重构为例,向你演示如何实现增量演进。
下面的代码来自《代码整洁之道》第2章“有意义的命名”Bob大叔举了这样一个例子来吐槽糟糕的命名。这段代码来自一个扫雷游戏想实现获取所有被标记过的单元格的目的。
```java
public List<int[]> getThem() {
 List<int[]> list1 = new ArrayList<int[]>();
 for (int[] x : theList)
   if (x[0] == 4)
    list1.add(x);
 return list1;
}
```
然而你不难发现这段代码的坏味道远不止getThem、theList这种**晦涩的命名**,还包括**魔法数字**、**基本类型偏执**等。
面对如此多的坏味道,我相信对代码有洁癖的你,已经摩拳擦掌准备重构了吧?但是请别急,如果你直接改代码,在没有测试的情况下,有信心保证正百分之百正确吗?
在遗留系统中,到处充斥着这样的糟糕代码,而且没有测试覆盖。我们可以选择先补测试,然后再开始重构。这也是我强烈推荐的方式,因为这样的步子迈得更稳、更扎实。
但有时代码本身并不可测,还要先完成可测试化改造。我的初衷就是单纯地重构这段代码,现在又要可测试化,又要加测试,似乎外延越来越广了,工作量也随之越来越大。有没有办法不用加测试,也能安全地重构呢,并且完成增量式交付呢?答案是肯定的。
这种方法其实很简单,就是**先把代码复制出来一份****在复制的代码处进行重构**。等重构完毕再通过某种开关来控制新旧代码的切换。在测试时可以通过开关来做A/B测试从而确保重构的正确性。
除了复制代码的方式外,还有一种更巧妙的方法来实现无测试的安全重构,并完成增量交付。这里我先卖个关子,等到后面的模式篇再来介绍这种方法。
重构完的代码可以像下面这样,只有一行,十分精练:
```java
public List<Cell> getFlaggedCells() {
return gameBoard.stream().filter(c -> c.isFlagged()).collect(toList());
}
```
在这里我就不介绍具体的重构过程了,**毕竟我们的重点是增量交付。**重构代码的方法,我们后面模式篇再展开讲,这里也顺便推荐郑晔的《[代码之丑](https://time.geekbang.org/column/intro/100068401?tab=catalog)》专栏。
在原方法的调用端,我们可以像这样引入开关,来实现这个增量:
```java
List<int[]> cells;
List<Cell> 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.在架构的增量演进一节中,单体系统中的薪酬模块对通知模块是有依赖的,那么新的薪酬服务拆分出去之后,应该如何实现对外发通知的功能呢?
欢迎你在评论区留下你的思考,也欢迎你把这节课分享给你的同事和朋友,我们一起讨论、进步。