三. 选择缓解风险的技术
一旦识别出迁移过程中可能存在的风险,我们就可以有的放矢地选择相关技术,制订降低风险的解决方案。
寻找丢失的知识
只有体验过去,才能谋划未来。如果缺乏对遗留系统的足够认识,这种技术栈的迁移就很难取得成功。通常来讲,一个软件系统的知识,主要体现在如下三个方面,如下图所示:
在这三个方面中,团队成员拥有的知识无疑是最值得寄予厚望的。在迁移过程中,若有了解该系统的团队成员参与,无疑可以做到事半功倍。可惜,这部分知识又是最为脆弱的,它就好似存储在内存中的数据一般,一旦断电就会全盘丢失。遗留系统的问题恰在于此,由于系统过于陈旧,而人员的流动总是比较频繁,在对系统进行迁移时,可能许多当年参与系统开发的成员,已经很难找到。
缺乏团队成员在知识方面的传承,就只能寄希望于文档与代码。文档的问题有目共睹,无论采用多么严谨的文档管理办法,文档与真实的实现总是存在偏差。正如“尽信书不如无书”,文档可以提供参考价值,但绝对不能完全依赖于文档。毫无疑问,代码是最为真实的知识。它不会说谎,但却过于沉迷于细节,要通过代码来了解遗留系统的知识,一方面耗时耗力,另一方面也难免会产生“只见树木不见森林”之叹。
引入自说明的可运行文档,可以有效地将文档与代码结合起来。通过运用业务语言编写功能场景来体现业务需求,完成文档的撰写;同时,它又是可以运行的代码,通过直接调用代码实现,可以完全真实地验证功能是否准确。目前,有许多框架和工具可以支持这种规格文档,例如Java平台下的jBehave,Ruby语言编写的Cucumber,支持HTML格式的Concordion,以及ThoughtWorks的产品Twist[6]。
在我们的一个项目中,需要完成系统从WebLogic到JBoss的技术栈迁移。该系统是一个长达十年以上时间的遗留系统。虽然有比较完整的文档说明,但许多具体的业务对于我们而言,还是像一个黑盒,不知道具体的交互行为。此时,我们和客户一起为其建立了一个专门的项目,通过运用jBehave为该系统的业务行为编写可以运行的Story。
在编写Story时,我们参考了系统的文档,并根据文档描述的功能建立场景,确定输入和输出,判断系统的行为是否与文档描述一致。事实上,我们在编写Story的过程中,确曾发现系统的真实行为与文档描述不一致的地方。这时,我们会判断这种不一致究竟是缺陷,还是期待的真实行为。在编写Story的过程中,我们寻找回了已经丢失的知识,并进一步熟悉了系统的结构,了解到系统组件的功能以及组件之间的关系。通过这些不断完善的Story,我们逐渐建立起了一个完全反应了真实实现的可运行文档库,它甚至可以取代原来的文档,成为系统的重要知识。
及时验证,快速反馈
在对系统进行技术栈迁移时,我们常常会担心修改会破坏原有的功能。尤其是对于大多数遗留系统,普遍存在测试不足,代码紧耦合,可维护性差的特点。虽然遗留系统会因为这些缺点而受人诟病,但不可否认的是,这些遗留系统毕竟经历了长时间的考验,在功能的正确性上已经得到了充分的验证。在迁移到新的技术时,如果不慎破坏了原有功能,引入了新的缺陷,就可能得不偿失了。
为了避免这种情况发生,我们就需要为其建立充分的测试,并通过建立持续集成(Continuous Integration)环境,提供快速反馈的通道。一旦发现新的修改破坏了系统功能,就需要马上修复或者撤销之前的提交。
问题是我们该如何建立测试保护网?为遗留系统建立测试是一件非常痛苦的事情,为了减小工作量,我们首先应该根据技术迁移的目标,缩小和锁定系统的范围。例如,倘若我们要将系统从IBMMQ迁移到JBossMQ,那么就只需要验证那些与消息队列通信的组件。若要将报表迁移到JasperReport,就应该只检测整个系统的报表组件。另一方面,我们应尽量从粗粒度的测试开始入手。一个好消息是,在之前为了寻找失去的知识时建立的可运行文档,事实上可以看作是一种验收测试。它不仅提供了自说明的文档,同时还建立了覆盖率客观的测试保护网。这种验收测试是针对业务行为编写的完整功能场景,更接近业务需求。它的抽象层次相对较高,并不会涉及太多编程细节。即使实现模块(包括类)是紧耦合的,没有明显的单元边界,我们仍然可以为其编写测试。这就可以省去对类与模块进行解耦这一难度颇高的工作。
通常,我们会将这些测试作为持续集成的一个单独pipeline。每次对原有系统的修改,都要触发该pipeline的运行,以期获得及时的反馈。这样,就可以为原有系统建立一个覆盖范围广泛的测试保护网,使得我们可以有信心地对系统进行技术栈迁移。
针对一些核心场景,我们还可以为遗留系统编写集成测试。这种粗粒度的测试不需要对原有代码进行太多的调整或重构,唯一需要付出的努力是对集成测试环境的搭建。
对于遗留系统的集成测试,最好能够支持本地构建。因为若能在本地开发环境运行集成测试,就可以通过在本地运行构建脚本,快速地获得反馈,避免一些集成错误流入到源代码服务器中,导致持续集成Pipeline频繁出现错误。这种快速失败的方式,可以更好地验证错误,降低集成风险。在搭建本地集成环境时,可以选择一些轻量级框架或容器,提高部署性能。例如我们可以在本地运行Jetty这种轻量级的Web服务器,使用HSQL内存数据库来准备数据。对于某些集成极为困难的情况,也可以适当考虑建立Stub。例如对外部服务的依赖,可以建立一个Stub的Web Service。这种方式虽然没有真实地体现集成功能,但它却可以快速地验证系统内部的功能。
倘若因为一些外部约束,我们无法做到完全的本地构建,也应该提供足够的集成环境,采取混合的方式运行构建脚本。例如可以将正在进行迁移的系统运行在本地环境上,而将该系统需要访问的中间件或者数据库放到其他的集成环境下。我们还可以利用构建脚本如Gradle,建立多种部署环境,例如Dev、Local、Stub、Intg等,使得开发人员或测试人员可以根据不同情况运行不同环境的构建脚本。
做好充分的技术预研
所谓“技术栈迁移”,必然是指从一种技术迁移到另一种技术。在充分了解系统当前存在的问题后,还需要深思熟虑,选择合理的目标技术。通常,我们会识别出待迁移模块(或系统)希望达到的质量属性,然后就此功能给出候选技术,建立一个用于权衡的矩阵。接着,再对这些待选技术进行技术预研(Spike),预研的结果将作为最终判断的依据。这种决策是有理有据的,可以有效地规避迁移中因为引入新技术带来的风险。下图是我们在一个项目中对文本搜索进行的技术预研结果矩阵。
因为是技术栈迁移,必然要求目标技术一定要优于现有技术,否则就没有迁移的必要了。通过技术预研,既可以提供可以量化的数据,保证这种迁移是值得的;同时也相当于预先开始对目标技术展开学习和了解,及早发现技术难点和迁移的痛点。
在我曾经参与的一个项目中,我们针对报告生成器模块编写了自己的一个支持并发处理的Batch Job。但随着系统用户数量的逐步增加,在生成报告的高峰期,并发请求数超过了之前架构设计预见的峰值,且每个报告生成所耗费的时间较长。于是,我们计划引入消息队列技术来替换现有的Batch Job。我们对一些候选技术进行了前期预研,这其中包括微软的MSMQ、Apache ActiveMQ以及RabbitMQ,针对并发处理、可维护性、成本、部署、安全、分布式处理以及灾备等多方面进行了综合考虑。
技术选型从来都不是以单方面的高质量作为评价标准,即使某项技术在多个评判维度上都得到了最高的分数,也未必就是最佳选择。我们必须结合当前项目的具体场景,实事求是地进行判断,以期获得一个恰如其分的迁移方案。
新旧共存,小步前行
技术栈迁移的某些特征与架构的演化不谋而合,我们绝对不能奢求获得一个一蹴而就的完美方案,更不能盼望整个迁移过程能够一步到位。尤其针对那些因为战略调整而驱动的技术栈迁移,可能牵涉到架构风格或整个基础设施的修改或调整,单就迁移这一项工作而言,就可能是一个浩大的工程。这时,我们必须要允许新旧共存,通过小步前行的方式逐步以新技术替换旧技术。我们必须保证前进的每一小步,都不会破坏系统的整体功能。这种新旧共存的局面,可能导致在一段时间会出现架构风格或解决方案的不一致,但只要做好整体规划,最终仍能在一致性方面获得完美的答案。
在我们工作的一个项目中,需要将一个独立的系统彻底移除,并将该系统原有的功能集成到另一个系统。需要移除的目标系统目前以Web Service方式提供服务。我们选择的解决方案是渐进地移除该系统。假设待移除的目标系统为Target,要集成的系统为Integration,我们采用了如下的迁移步骤:
新旧共存并非一种妥协,而是迁移过程中必须存在的中间状态。Jez Humble介绍了ThoughtWorks产品GO的几次技术栈迁移[7],包括从iBatis迁移到Hibernate,从Velocity和JsTemplate转向JRuby on Rails的案例。文章提出了一种称为Branch By Abstraction(抽象分支)的迁移方法,执行步骤如下图所示:
图中的抽象层将客户端(Consumer)与被替换的实现进行了解耦,使得这种替换可以透明地进行。在对抽象层的实现进行替换时,可以规定替换纪律,例如对于新增功能,必须运用新技术提供实现;还可以通过持续集成的验证门自动验证,例如设置旧有技术在系统中的阈值,每次提交都不允许旧有技术的代码量超过这个阈值。整个迁移过程要保证这个阈值是不断减少,绝不能增加。
理清思路,持续改进
要完成遗留系统的技术栈迁移,不可避免地需要对代码实现进行修改或重构。这或许是迁移难度最大的一部分内容。我的经验是针对遗留系统进行处理时,不要从一开始就埋首于浩如烟海的代码段中,太多的细节可能会让你迷失其中。若系统是可以运行的,可以首先运行该系统,通过实际操作了解系统的各个功能点、业务流程。这样的直观感受可以最快地帮助你了解该系统:它能够做什么?它能达成什么目标?它的范围是什么?它存在什么问题?
接下来,我们需要从系统架构出发,了解遗留系统的逻辑结构和物理分布,最好能描绘出遗留系统的轮廓图,这可以帮助你从技术的宏观角度剖析遗留系统的结构与组成;然后再结合你对该系统业务的理解,快速地掌握遗留系统。在阅读源代码时,最好能够从主程序入口开始,找到一些主要的模块,了解其大体的设计方式与编码习惯。
由于之前对系统架构已有了解,阅读代码时,不应在一开始就去理解代码实现的细节,而应结合架构文档,比对代码实现是否与文档的描述一致,并充分利用自己的技术与经验,找到阅读代码的终南捷径。
例如,如果我们知道该系统采用了MVC架构,就可以很容易地根据Url找到对应的Controller对象,并在该对象中寻找业务功能实现的脉络。又例如我们知道系统引入了WCF来支持分布式处理,而我们又非常熟悉WCF,就可以基本忽略系统基础设施的部分,直接了解系统的业务实现。如果系统基于EJB 2.0实现,则完全可以根据EJB提供的Bean的结构,快速地定位到对应的服务接口与实现。这是因为许多框架都规定了一些约束或规范,从这些约束与规范入手,可以做到事半功倍。
在尝试理解代码的过程中,可以通过手工绘制或利用IDE自动生成包图、时序图等可视性强的UML图,帮助我们理解代码结构。Michael Feathers提出可以为遗留代码绘制影响结构图与特征草图[8],从而帮助我们去梳理程序中各个对象之间的关系,尤其是帮助我们识别依赖,进而利用接缝类型、隐藏依赖等手法去解除依赖。
了解了代码,还需要对代码进行修改。多数情况下,我们需要首先通过重构来改善代码质量。注意,技术栈的迁移并非重构,但重构可以作为迁移工具箱中一件最为重要的工具。例如,我们可以通过Extract Interface,并结合Use Interface Where Possible手法,对一些具体类进行接口提取,并改变对原来具体类对象的依赖。
重构时,必须采取“分而治之,小步前进”的策略。可以首先选择实现较为容易,或者独立性较好的模块进行重构。将遗留系统逐步提取为一些可重用的模块与类。其中,对于原有类或模块的调用方,由于在重构时可能会更改接口,因而可以考虑引入Facade模式或Adapter模式,通过引入间接层对接口进行包装或适配,逐渐替换系统,最后演化为一个结构合理的良好系统。需要注意的是,在重构时一定要时刻谨记,我们之所以进行重构,其目的是为了更好地迁移遗留系统的技术栈,而非为了重构而重构,从而偏离我们之前确定的目标。故而,重构与迁移应该是两顶不同的帽子,不能同时进行。