# 14 | 架构现代化 :如何改造老城区后端? 你好,我是姚琪琳。 在上一节课中,我们学习了如何重构遗留系统的前端,一共分成了八个步骤,其中最后一个步骤是API治理。这其实就是去重构遗留系统的后端。 在第九、十节课,我们已经从代码层面讲解了如何重构和分层。而这节课,我们主要会从架构层面,带你看看在面对一个新需求或技术改进的时候,如何更好地重构和组织后端代码。 如果说在气泡上下文中开发新的需求,类似于老城区旁边建设一个新城区,那么在遗留系统中开发新的需求,就类似于在老城区内部开发新的楼盘。这就必然要涉及到拆迁的问题。 拆迁终归是一个声势浩大的工程,居民要先搬到别的地方,再拆除旧的建筑,盖起新的楼宇,一番折腾之后,老居民才能搬进新家。不过软件的好处就在于它是“软”的,不需要这么费劲儿。你可以很容易地复制、删除和添加新的代码,轻松地实现一个架构的变迁。 ## 修缮者 绞杀植物模式适合于用新的系统和服务,替换旧的系统或旧系统中的一个模块。在旧系统内部,也可以使用类似的思想来替换一个模块,只不过这个模块仍然位于旧系统中,而不是外部。我们把这种方式叫做**修缮者模式**。 ![图片](https://static001.geekbang.org/resource/image/4e/4a/4efdb7aa98481f6096a665cfd514484a.jpg?wh=1920x584) 在修缮时,我们通过开关隔离旧系统待修缮的部分,并采用新的方式修改。在修缮的过程中,模块仍然能通过开关对外提供完整功能。 这就好比是在老城区中修路,如果断路施工对交通的影响就太大了。更常见的做法是修缮其中的半条路,留另外半条来维持交通。不过,这必然会造成一定的拥堵。但在软件中就好办多了,我们可以将道路(待修缮的模块)“复制”出来一份,以保障通行正常。等原道路修缮好之后,再删除掉复制出来的道路即可。 我曾经用修缮者模式去修复过一个性能问题。当时一个API的请求特别慢,我在本地修好后,在生产环境改观不大。我推测这应该是数据分布导致的问题,本地环境的数据分布无法准确模拟生产环境。但当时的安全策略不允许我们访问生产数据库。 于是,接下来做调优时,我并没有直接修改这个API,而是将API复制了一份出来,一个用来维持老的功能,一个用来性能调优。同时添加了一个针对这个API的Filter,根据开关来决定要调用哪个API。通过收集调优API中的日志,不断地优化,直到解决性能问题。这时再清理掉旧API、Filter和开关。 这样做的好处是,由于你无法预测修缮过程中会产生哪些问题,这种通过开关保留回退余地的方法,显然是更灵活的。 上节课在学习前端重构时,我留的思考题是如何实现前端的增量演进和随时回退,其实也是这种修缮者模式的思想。将所有要修改的页面复制出来一份,然后再加入开关,就可以放心地重构页面了。 在第六节课讲代码增量演进时,我给你举的那个例子,就是在没有单元测试的情况下,通过修缮者的方式来重构的。我们把代码复制出来,重构完之后,通过开关在调用端切换,以完成A/B测试,从而实现安全地重构。 ```java // 旧方法 public List getThem() {  List list1 = new ArrayList();  for (int[] x : theList)    if (x[0] == 4)     list1.add(x);  return list1; } // 新方法 public List getFlaggedCells() { return gameBoard.stream().filter(c -> c.isFlagged()).collect(toList()); } // 调用端 List cells; List cellsRefactored; if (toggleOff) { cells = getThem(); // 其他代码 } else { cellsRefactored = getFlaggedCells(); // 其他代码 } ``` 当时我卖了一个关子,说还有一种更优雅的重构方式,你还记得吗? ## 抽象分支 这种优雅的方式就是,把要重构的方法重构成一个**方法对象**,然后提取出一个接口,待重构的方法是接口的一个实现,重构后的方法是另一个实现。按这种方式重构之后的代码如下所示: ```java public interface CellsProvider { List getCells(); } public class OldCellsProvider implements CellsProvider { @Override public List getCells() { List list1 = new ArrayList(); for (int[] x : theList) if (x[0] == 4) list1.add(x); return list1; } } public class NewCellsProvider implements CellsProvider { @Override public List getCells() { return gameBoard.stream().filter(c -> c.isFlagged()).map(c -> c.getArray()).collect(toList()); } } ``` 在调用端,你只需要通过工厂模式,来根据开关得到CellIndexesProvider的不同实现,其余的代码都保持不变。在通过A/B测试之后,再删除旧的实现和开关。 这种方法不但可以进行安全地重构,还可以用新的实现替换旧的实现,完成功能或技术的升级。我们把这种模式叫做[抽象分支(Branch by Absctration)](https://martinfowler.com/bliki/BranchByAbstraction.html)。 当我们进行大的技术改动时,通常需要花费较长的时间。比如用MyBatis替换Hibernate,或用Kafka替换RabbitMQ。 传统的做法是,在当前的产品代码分支上创建一个新的分支,大规模去重写。这个分支发布之前要经历很长一段时间,直到最后全部修改完成后,才能把分支合并到产品代码分支上。更糟糕的是,这样做合并时的代码冲突会非常严重,而且架构调整后,首次上线大概率会出问题,交付风险非常高,无法做到增量演进。 为了解决这样的问题,Martin Fowler提出了抽象分支模式。可以在不创建真实分支的情况下,通过技术手段,将大的重构项目分解成多个小步骤,每个小步骤都不会破坏功能,都是可以交付的,这样就可以逐步完成架构的调整。 ![图片](https://static001.geekbang.org/resource/image/63/5e/63cbe62a638d900d6acb31aa3081775e.jpg?wh=1690x810) 它的基本步骤是这样的。先为旧实现创建一个抽象层,让旧的模块去实现这个抽象层。注意**,这里的抽象层并不一定是接口,有可能是一系列接口或抽象类**。 然后,让部分调用端代码依赖这个抽象层,而不是旧的模块。同样要注意,**这个替换是逐步进行的**,不是一次性全部替换掉。等全部调用端都依赖抽象层后,开始编写新的实现,并让部分模块使用新的实现。这个过程也是逐步进行的,一方面可以更好地验证新实现,另一方面也可以随时回退。当全部调用端都使用新的实现后,再删除旧的实现。 有的时候你需要让新旧实现同时存在,对不同的调用端提供不同的实现,这也是很常见的情况。 由于新代码一直可以工作,因此你可以不断提交、不断交付、不断验证。 在实际工作中,抽象分支的运用还是非常广泛的。我以前曾经面对过一个技术改动,在初始化Redis的时候,改为从配置文件中读取密码,而不是从数据库中读取密码。对于这样一个替换,你可能直接三下五除二就完成了,但我领悟了抽象分支之后,发现可以用更加优雅的方式实现这个替换。我把整个思考和实现的过程写成了一篇[博客](https://mp.weixin.qq.com/s/X_7RC567aYrrO_MzykmdRQ),你可以当做加餐,阅读一下。 ## 扩张与收缩 有的时候我们要修改的是接口本身(这里的接口是指方法的参数和返回值),这时候就不太容易通过抽象分支去替换了。 我们还是拿前面第六节课用过的例子做讲解,以前返回的是List,而现在我们想打破这个接口,返回List。因为List仍然存在严重的基本类型偏执的坏味道,而且本来已经提取了Cell类,又通过getArray返回数组,简直是多此一举。 这时你可以使用[扩张-收缩(expand-contract)模式](https://martinfowler.com/bliki/ParallelChange.html),也叫**并行修改(Parallel Change)模式**。它一般包含三个步骤,即扩张、迁移和收缩。这里的扩张是指建立新的接口,它相比原来旧的代码新增了一些东西,因此叫做“扩张”;而收缩是指删除旧的接口,它比之前减少了一些东西,因此叫“收缩”。 一般来说,它会在类的内部新建一些方法,以提供新的接口(即扩张),然后再逐步让调用端使用新的接口(即迁移),当所有调用端都使用新的接口后,就删除旧的接口(即收缩)。 拿刚才这个例子来说,提取完方法对象后的代码如下所示: ```java public class CellsProvider { public List getCells() { List list1 = new ArrayList(); for (int[] x : theList) if (x[0] == 4) list1.add(x); return list1; } } ``` 你可以在这个方法对象中进行扩张,新增一个方法,以提供不同的接口: ```java public class CellsProvider { public List getCells() { // 旧方法 } public List getFlaggedCells() { return theList.stream().filter(c -> c.isFlagged()).collect(toList()); } } ``` 然后,我们让调用端都调用这个新的getFlaggedCells方法,而不是旧的getCells方法。在替换的过程中,新老方法是同时存在的,这也是为什么这个模式也叫并行修改。 等所有调用端都修改完毕,就可以删掉旧方法了。 ![图片](https://static001.geekbang.org/resource/image/36/ba/362de65418e023394bd7ce1bc6c1baba.jpg?wh=1920x929) 在老城区改造的过程中,这种扩张与收缩模式也是很常见的。去年我所在的城市完成了一次取暖线路改造,从以前的小区锅炉房供暖改成了全市的热力供暖。施工方并没有将小区内旧的供暖管道直接连到市政热力的管线上,而是在旧的管线旁边新铺了一条管线(即扩张),连接到市政管线。 在供暖期,两条管线是并行运行的,一旦新管线发生问题,可以很快地切回旧的小区供暖。等并行运行一段时间后,判断新管线没问题了,再重新挖沟,拆除旧管线(即收缩)。 有的时候市民不理解为什么天天挖坑,但实际上这么做,都是为了保障供暖的安全性和高可用性啊。 ## 再谈接缝 如果你够细心,一定会发现在抽象分支中,我们提取的接口其实是一个**接缝**。没错,**接缝不但可以用来在测试中替换已有的实现,它本身其实也是一个业务变化的方向**。在开发过程中,你需要时刻去关注接缝,关注这种可能会产生变化的地方。 比如你的项目中使用了RabbitMQ作为消息中间件,发送和接受消息的代码和RabbitMQ的SDK紧密耦合,这会带来两方面隐患,一方面当你想替换MQ的时候,需要修改全部调用点,另一方面,它也不好写测试。 当你意识到它其实是一个接缝的时候,就可以很轻松地通过一系列接口来隔离SDK。当需要替换MQ的时候,只需要提供一套新的实现类。这时的实现类应该叫做**适配器(Adaptor)**,它其实也起到了防腐层的作用。而在单元测试中,你可以通过测试替身构建一组Fake的实现类,以提供内存中的MQ功能。 这样的方案,既优雅又灵活。 除了代码中蕴含着很多接缝,架构中也存在接缝。延续上面MQ替换的例子,因为有很多在途的消息还没有处理,这种技术迁移很难做到不停机地丝滑切换。 这时你可以利用这个**架构接缝**,使用事件拦截模式,将发往RabbitMQ中的消息也同步发给新的MQ(比如Kafaka)。 同时,消费端可以通过幂等API,来消除重复消费造成的问题。这样一来,系统中就有两个消息中间件同时存在,同时提供消息机制。当基础设施搭建好之后,就可以实现新老MQ的无缝切换了。 ## 小结 又到了总结的时候。我们今天学习了不少用于替换旧实现的模式。修缮者模式和绞杀植物类似,可以用来改善单体内的某个模块。抽象分支模式可以通过一个抽象,优雅地替换旧的实现。而扩张收缩模式主要用于接口无法向后兼容的情况,一张一缩,一个接口就改造完了。 同时,除了代码中的接缝,架构中也存在接缝,你可以利用它们来实现架构中的替换。 无论是绞杀植物、修缮者、抽象分支还是扩张收缩,它们在实施的过程中,都允许新旧实现并存,这种思想叫做**并行运行(Parallel Run**)。这是我们贯彻增量演进原则的基本思想,希望你能牢牢记住。 我们说的绞杀植物、气泡上下文、修缮者、抽象分支、扩张收缩、并行运行等模式,其实概念上都差不多,之所以叫不同的名字,是因为它们解决的是不同的问题。比如绞杀植物模式解决的是新老系统的替换,修缮者模式解决的是一个服务内部模块的替换,而气泡上下文专门用于将新需求和老系统隔离开来。 ![图片](https://static001.geekbang.org/resource/image/cb/6b/cb88b22aa60de6224abe96325592a66b.jpg?wh=1920x799) 这就像不同的设计模式虽然叫不同的名字,但构造型模式用来解决不同场景下的对象构造,行为型模式用来处理不同场景下的行为选择。你必须深刻理解这些模式,才能做出正确的选择。 最后,我不得不再次感叹我的同事王健对于各种模式的高度抽象,他的十六字心法如余音绕梁,三日不绝。 > 旧的不变,新的创建,一步切换,旧的,再见。 ## 思考题 感谢你学完了今天的内容。今天的题目是这样的,请你来描述一下你当前所开发的需求是什么样的?你是否识别出了一些接缝?它能否利用抽象分支来开发? 期待你的分享。如果你觉得这节课对你有帮助,别忘了分享给你的同事和朋友,我们一起改造老城区。