跳转至

06 以增量演进为手段:为什么历时一年的改造到头来是一场空?

你好,我是姚琪琳。

今天我们来聊聊遗留系统现代化中的HOW,也就是第三个原则,以增量演进为手段。

很多团队在一阵大张旗鼓的遗留系统改造后,终于迎来了最终的“梭哈”时刻。尽管事先可能在各种测试环境测过无数遍了,但上线生产环境仍然如履薄冰。

和遗留系统项目“相爱相杀”十几年,我可以肯定地告诉你,这种一次性交付的大规模遗留系统改造,几乎不可能一上线就成功,必然会有各种或大或小的问题,甚至导致不得不全量回滚,交付日期一拖再拖。哪怕你的“战前准备”历时一年,甚至更久,到头还是一地鸡毛。

你可能会有疑问,你见过很多大厂的案例,都是一次性上线的。没错,的确是这样,但大厂之所以有勇气这么做,是因为他们有很强的人力、物力支撑,客观条件允许这么做。对于资源有限的小公司、小项目,还是应该衡量一下改造的难度和运维的能力,以控制风险为主。

怎么控制风险呢?我的答案是增量演进。这节课,我带你把这个概念搞通透,顺便演示下代码和架构的增量演进怎么做。

什么是增量演进?

什么是增量?什么又是演进呢?这要从演进式架构开始说起。

我在北美的同事Neal Ford和Rebecca Parsons,在《演进式架构》这本书中给演进式架构下了精准的定义:支持跨多个维度的引导性增量变更的架构

这么多的限定词,你乍一听挺懵,别急,我给你解释一下就清楚了。其中,多维度是指技术、数据、安全、运维等不同的看待架构的视角;引导性是指在适应度函数的引导下,向着正确的方向演进架构;而增量变更是指以小步快跑的方式,细粒度地构建和部署软件,同时在一定程度上允许新旧两种实现并行运行。

我这里说的遗留系统中的增量演进,借鉴了演进式架构中“增量”的概念。我们可以把已有的遗留系统作为“存量”,而每一次的优化、改进作为“增量”。“演进”则要求我们将这些增量划分成非常小的粒度。这些小的增量也可以随时部署到各种环境来进行验证,每次验证的最小单元都是这些小的增量,而不是整个的改造结果。

同时,新改进的实现和老的实现是并存的,一旦在验证时发现问题,可以随时回退到老实现。

因此,增量演进是指,以增量的方式,不断向明确的目标前进的过程

虽然理论上,可演进的架构才更容易实现小的增量变更,但大多数遗留系统的架构显然不是可演进的。这时候我们怎么实现相对细粒度的增量交付呢?我们从代码和架构两个维度为例,具体分析一下。

代码的增量演进

在代码现代化方面,我们的主要目标包括三类:修补测试、代码重构、代码分层。接下来我将以代码重构为例,向你演示如何实现增量演进。

下面的代码来自《代码整洁之道》第2章“有意义的命名”,Bob大叔举了这样一个例子来吐槽糟糕的命名。这段代码来自一个扫雷游戏,想实现获取所有被标记过的单元格的目的。

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测试,从而确保重构的正确性。

除了复制代码的方式外,还有一种更巧妙的方法来实现无测试的安全重构,并完成增量交付。这里我先卖个关子,等到后面的模式篇再来介绍这种方法。

重构完的代码可以像下面这样,只有一行,十分精练:

public List<Cell> getFlaggedCells()  {
  return gameBoard.stream().filter(c -> c.isFlagged()).collect(toList());
}

在这里我就不介绍具体的重构过程了,毕竟我们的重点是增量交付。重构代码的方法,我们后面模式篇再展开讲,这里也顺便推荐郑晔的《代码之丑》专栏。

在原方法的调用端,我们可以像这样引入开关,来实现这个增量:

List<int[]> cells;
List<Cell> cellsRefactored;
if (toggleOff) {
  cells = getThem();
  // 其他代码
}
else {
  cellsRefactored = getFlaggedCells();
  // 其他代码
}

开关的值通常都写到配置文件,或存储在数据库里。我们可以通过修改这个配置,不断验证新代码的行为是否和旧代码完全一致。直到经过了充分的测试,我们有了十足的信心,再来删掉开关,将旧代码完全删除。

我的同事,《说透中台》专栏的作者王健,曾经把这种重构手法总结为“十六字心法”,非常形象、贴切:

旧的不变,新的创建。一步切换,旧的再见。

“旧的不变”是指先不动旧方法;“新的创建”是指创建一个跟原来方法功能相同的新方法,你可以通过先复制再重构的方式,来得到这个新方法,也就是整个系统的一个增量;“一步切换”是指,在充分测试之后,新的方法可以完全替代旧方法了,就将开关切换到新方法上;“旧的再见”则意味着删除旧方法以及相应的开关,一个演进到此也就结束了。

你会发现,这十六字心法不光适用于代码重构,也可以推广、复用,用在架构、安全、性能等其他维度,作为增量演进的指导方针。

架构的增量演进

如果说代码的重构还可以在短时间内完成并上线,那架构的重新设计就很难一蹴而就了。这其实就更加需要小步上线,随时验证了。

你可能会说:“骗人的吧?你要是说代码的改动可以小步前进,我还相信,但是架构调整这么大的动作,怎么可能增量演进呢?”这其实就是我们一直想要强调的,越是大的改进,越要频繁上线去验证,不要等到最后来个“大惊喜”。

对于架构或系统的替换,Martin Fowler提出了绞杀植物模式。这源于他一次在澳大利亚旅行时发现的奇观,一棵巨大的古树被榕树的藤蔓缠绕,许多年以后最终被榕树所取代。

“老马”(国内对于Martin Fowler的昵称)想到了一种与之类似的系统替换的方式,也就是新建一个系统,让它与旧系统并存且缓慢增长,直到某一天完全取代旧的系统。于是,老马就给这种方法起了一个名字,叫绞杀植物模式。

这里稍微说个题外话,这个模式一开始的名字是Strangler,国内通常的翻译是“绞杀者模式”。2019年老马在个人网站上修订了这篇博客,将模式重新命名为Strangler Fig。原因是这个模式虽然越来越流行,但是名字太血腥太暴力。Strangler Fig直译成中文是绞杀无花果,听上去有点莫名其妙。其实Strangler本身就有绞杀植物的含义,因此我个人倾向于把这个模式翻译为绞杀植物模式。

使用绞杀植物模式最主要的好处,就是降低风险。作为绞杀植物的新系统可以稳定提供价值,并且频繁发布。你还可以很好地监控它的状态和进度。

这种新旧系统或架构同时存在、同时运行、逐渐替换的方式,就是我们的增量演进所追求的目标。

假设我们有这样一个单体系统,包含员工、财务和薪酬三个模块,其中员工和薪酬模块都会调用通知模块来发送邮件或短信。上游服务或前端页面通过HTTP API来访问不同的模块。

图片

如果我们希望将薪酬模块迁移到独立的服务中,应该如何使用绞杀植物模式,以增量演进的方式做拆分呢?

我们可以分四步完成拆分。

第一步,建立开关。要实现增量演进,开关是必不可少的。一方面可以通过开关来控制A/B测试,以验证功能不被破坏,另一方面一旦新实现有问题,也能迅速回退到旧实现。

你可以将这个开关实现在API调用薪酬模块的地方,当开关打开的时候,调用新的薪酬服务,当开关关闭的时候,仍然调用已有的薪酬模块。这个开关可以是粗粒度的一个开关,也可以是细粒度的每个功能点一个开关。我建议你把开关尽可能设小一些,在实战中这种方式可以获得更小的增量演进和回滚。

图片

现在的薪酬服务还是一个空壳,没有任何实现。如果打开开关,应该得到一个501 Not Implemented错误。

第二步,增量迁移。按迭代逐步将薪酬模块的功能迁移到薪酬服务中。假设我们需要4个迭代来完成全部的迁移工作,迭代0的工作主要是为开发开关和搭建新服务的脚手架,其余迭代就可以按计划来迁移不同的功能了。

图片

在这一步,我建议你从迭代0开始,就把薪酬服务部署到生产环境中。虽然迭代0中的薪酬服务还没有任何功能,但这可以让你先测试整个部署的过程,以及服务的连通性。否则你就要在迭代1交付的时候既测试部署,又测试功能了。

你可能会注意到,我们虽然在迭代0部署了薪酬服务,但是开关并不会打开,因此并不意味着交付了薪酬服务的功能。我们将软件部署和软件交付(或软件发布)的概念做了区分,相信你能体会到它们之间的差别。

从迭代1开始,就会有迁移完成的增量发布到薪酬服务中了,你可以打开开关来测试这一部分的功能。

第三步,并行运行。对于有一定规模的架构演进,我强烈建议你将开关和旧代码保留一段时间,让新旧代码并行运行几个迭代。

对于遗留系统来说,这样做好处是利用新旧实现并行的这段时间,让隐藏的坑逐步浮现出来,直到我们对新实现有十足信心。这里说的“隐藏的坑”意思是指,隐藏在代码和架构深处的,那些任何人都不曾知道的问题。它们随时可能会暴露出来。多并行一段时间,可以让“子弹飞一会儿”,看看是否能够暴露出这些问题。

并行运行和绞杀植物模式一样,也是一种常用的架构现代化模式,我们会在模式篇里详细介绍。

第四步,代码清理。删除旧代码和开关,切记不要忘了这一步。很多遗留系统的架构演进都没有完成这一步,导致很多无用的代码留在系统中。它们除了给人带来迷惑之外没有任何用处。

完成这四步之后,我们就实现了架构的增量演进过程。你会发现,架构的增量演进与代码的增量演进一样,也完美契合了“旧的不变,新的创建,一步切换,旧的再见”这十六字心法。

小结

又到了总结的时候。为什么历时很久的遗留系统改造会以失败而告终呢?一是因为直到最后一刻才上线,失去了持续验证的机会;二是上线后发现有问题,只能硬着头皮热修复,或者整体回滚,缺乏细粒度的回退机制。

而增量演进原则可以有效解决这个问题。它一方面鼓励我们持续交付改造的功能或新的实现,不断在生产环境验证;另一方面拥有细粒度的开关,也使得回退变得十分灵活,一旦发现问题,我们只需要关闭引起问题的那个开关即可。

以增量演进为手段这个原则的指导下,我对代码和架构的演进步骤做了比较详细的演示。此外,在软件系统的其他维度,如数据、安全、性能、运维等,也可以用同样的方式完成改进。

增量演进的思想不仅体现在遗留系统现代化之中,我们平时做设计的时候,也应该遵循增量演进的思想。一方面给予回退的可能,小步地上线,另一方面,也可以先上线一个简单的方案,然后再随着遇到的问题去不断演进这个方案。

我发现很多架构师在设计一个方案时喜欢一步到位,但这其实是错误的。这个世界上根本不存在完美的架构,所有的架构都应该是通过不断演进而浮现出来的,在演进的过程中我们应该根据当前上下文和约束的改变而不断调整,最终得到一个“差不多的”或者“刚刚好”的架构。

而一步到位的思想,轻则导致过度设计,重则完全走错了方向,因为没能尽早上线去收集反馈。虽然很多一步到位的决策,最后结果是走对了方向,那也不能说明你有眼光,只能说明你运气好。

另外,我还剧透了绞杀植物模式、并行运行模式等遗留系统现代化模式。想要了解更多的模式,欢迎你继续学习接下来的模式篇。

到此为止,我讲完了遗留系统现代化的三大原则。从下节课开始,我们将进入模式篇的学习,你将在这一部分看到很多似曾相识的模式,它们是进行遗留系统现代化强有力的工具和方法。

思考题

感谢你学完了今天的内容,我给你留了三道思考题,你可以任选一个或者多个说说想法:

1.在你的项目中,是如何做代码和架构的重构的?是否曾经使用过类似增量演进的方式呢?期待你分享一下自己团队经验。

2.除了复制代码并重构之外,我在文中还提到了另外一种方法,用来在无测试的情况下完成安全重构,你想到是什么了吗?

3.在架构的增量演进一节中,单体系统中的薪酬模块对通知模块是有依赖的,那么新的薪酬服务拆分出去之后,应该如何实现对外发通知的功能呢?

欢迎你在评论区留下你的思考,也欢迎你把这节课分享给你的同事和朋友,我们一起讨论、进步。

精选留言(14)
  • hk 👍(5) 💬(1)

    姚老师你好,我想问下,如果重构涉及到数据库表的改造,比如直接用新表,旧表不用了,也可以按并行运行和绞杀植物模式去处理吗?按个人理解,当然理解浅薄,数据库相对于代码和架构,应该是最难的,还牵涉到老数据的问题。所以想请教下老师在这块有什么经验和方法可以更换的处理数据库演进这块?谢谢

    2022-11-17

  • 刘大明 👍(3) 💬(1)

    试着回答一下第二个问题。 在无测试的情况下重构,我首先想到的第一步,就是通过ide的快捷键直接调用重构的一些操作。在不济,就参考<重构>一书里面的一些操作手法,也可以保障重构的有效性。 上家公司每次重构或者上线一个新功能都喜欢加开关,来手动的去做开关导流,但是确有个坏毛病,旧的代码也不清理,这样就导致,代码里面充斥着各种开关判断。 问题1.关于开关的管理,老师有什么好的经验吗?或者好用的开源软件来去管理,每次开关多达几十个,可能到了后面,很多开关都不知道是什么意思。 问题2.关于导流,老师有什么好的经验吗?上家公司都是我早上上班之后,手动的开启开关,当客户反馈新功能报错或者不好用的时候,在手动的关闭开关,有没有什么好办法,只切部分流量到新功能这种。

    2022-04-22

  • Geek_d8e0b2 👍(1) 💬(1)

    目前重构的项目基本上表结构都变化了怎么去做开关呢,非常难受

    2022-07-09

  • aoe 👍(1) 💬(2)

    目前正在做一个关于分布式事务最终一致性的重构,开始前的方案有两个: 1、业务开始前生成一个 traceId,将它保存在所有步骤中(工作量太大,放弃) 2、优先解决出问题最多的服务,提升事务的可靠性 - 可行,采用;就按老师说的来:建立开关、增量迁移、并行运行、代码清理 - 因为刚在02课学习了遗留系统现代化的三个原则—2. 以价值驱动为指引 - 完成这个功能带来的收益最大

    2022-04-22

  • Geek_70dc13 👍(0) 💬(1)

    问题三有点抽象.新薪酬服务在对接一遍通知服务(毕竟最终也是要的)另外新薪酬服务只替换了部分功能,也不妨碍通知功能..没太理解

    2022-07-22

  • Geek_6a6d13 👍(0) 💬(1)

    文中在如何实现绞杀植物模式的第三步说,"并行运行和绞杀植物模式一样,也是一种常用的架构现代化模式",可以理解成并行运行既是绞杀模式包含的一部分,同时也可以单独出来成为一种模式,是吗?

    2022-05-13

  • 子夜枯灯 👍(0) 💬(1)

    现在在做的项目重构功能最大一个难处,在于简历测试机制。一个持续开始四五年的项目完全没有单元测试及自动化测试,全靠人力测试。在于验证重构质量上面,老师有哪些推荐的开源工具或者思路提供么?谢谢

    2022-05-10

  • killer 👍(0) 💬(1)

    细粒度的每个功能点一个开关,有可能是一个mq的监听方法,或者某个方法级别的,我们要自己开发这种开关工具吗?有没有现成的解决方案

    2022-04-22

  • 公号-技术夜未眠 👍(1) 💬(0)

    干货满满,看得出来老师实战经验很丰富

    2022-04-22

  • Jaising 👍(0) 💬(0)

    构建测试网的前提是建立可测试性,这不仅在遗留系统甚至很多新建系统都不是一个架构考量,我建立可测试性的方法是借助框架,比如 Spring 周边有很多成熟的可选,然后做接缝提取可测试函数,最后尽可能做到独立的单元测试,JPA 就不用连接数据库,AMQP 就不用连接消息队列。

    2024-09-20

  • 伊诺 👍(0) 💬(0)

    最新公司,迭代老系统; 网关采用三种模式 1、只发老核心; 2、只发新核心; 3、新老并行发,通过响应比对新老接口 字段差异;

    2023-11-30

  • zyz 👍(0) 💬(0)

    老师您好,比如说设置开关这个问题,因为把薪酬单独写一个服务,数据库也应该是重构的吧?这种情况下数据同步也需要开关吗?像这种新、旧数据库同步采用什么方式比较好呢?

    2023-11-06

  • _立斌 👍(0) 💬(0)

    想请教一下老师关于开关的题外话,目前我们的项目也充斥着很多开关,而且大部分都是版本控制的作用。比如这个版本前,应该是这个旧的逻辑,这个版本后,就是新的逻辑。但是旧的逻辑又不能完全删掉,因为涉及到历史存量数据的处理与展示,请问老师这种情况有没有比较好的处理方式呢?

    2023-10-08

  • Spoon 👍(0) 💬(0)

    问题3:使用MQ发消息

    2023-03-06