技术开发 频道

在大型遗留系统基础上运作重构项目

【IT168 技术文章】

    现状

    eMAN是客户的一个核心业务平台。该产品采用了典型的C/S结构,负责处理大量请求和计算的后台部分采用C++开发,负责响应用户操作和处理业务逻辑的前台部分采用Java开发;此外该产品还计划在新版本中提供基于Web的前台,这部分也采用Java开发。

    在ThoughtWorks为该产品的开发团队提供咨询时,eMAN产品已经发布了十多个版本,最新版本代码量超过40万行,其中15万行是Java代码。一次又一次的赶工给它留下了大量的“技术债”:系统缺乏测试,代码质量低劣,“copy & paste”的痕迹比比皆是,维护和新功能开发举步维艰。我们这个咨询项目的主要目标之一就是为这个产品找出重构的办法。

    原则

    可以用两种不同的角度来看待一个软件:程序员的角度,商业的角度。

    从程序员的角度看来,“成功的软件”意味着所有测试都通过、代码结构良好、并且容易理解和维护。从商业的角度看来,“成功的软件”意味着它所创造的价值超出在它身上付出的代价。

    和别的任何story一样,重构的story(以及其他“技术债”类型的story)也应该符合INVEST的标准。尤其是,它们的工作量应该得到估算,它们应该按照业务价值排列优先级。因为归根到底,重构(以及其他任何开发任务)都归结为“花在代码上的成本”与“对业务创造的价值”之间的权衡。

    按照定义,重构意味着“在不改变功能性行为的前提下改进代码的组织结构”。如果代码基础本身脆弱而没有测试覆盖,重构的成本就会很高,因为你需要花很大力气来确认自己的修改没有改变功能性行为。

    如果偿还“技术债”的成本非常高,那么与之对应的业务价值就必须更高,否则偿还这些债务就将得不偿失。其结果是:一段代码,从程序员的角度看来越糟糕,从商业的角度来说就越不应该去动它。

    综上所述,如果有人说“这一大堆代码都需要重构”,这样的说法很有可能是值得商榷的。你需要把重构划分成细粒度的、可控的story,为这些重构story制定验收标准,评估它们的优先级,估算它们的工作量,然后逐一实现它们,并且放弃一些得不偿失的重构。

    在eMAN项目中,我们按照软件功能模块划分了story,而不区分是新功能开发还是重构。比如说一个典型的story可能是:

    作为系统管理员我要从服务器列表中选择一个服务器从而让我可以登录到选中的服务器。

    不管是新开发还是重构,这个story的验收条件是一致的:功能通过验收测试,代码符合质量要求。在实际工作中我们发现,对于同样的story,新开发和重构的工作量差别不大。这也使得迭代计划可以在story的基础上照常进行,不必特意区分重构和新功能开发。

    持续集成

    前面已经提到,重构story也同样应该是可验收的。除了确保其功能性行为仍然保持原样不变之外,这类story还有更多的验收条件:单元测试覆盖率、代码复杂度、编码规范等指标都应该符合项目要求。这些指标也同样适用于新功能开发的story。这些指标的报告应该自动化地生成,及时地展现给所有项目成员,为此我们需要一个持续集成环境。

    eMAN项目采用CruiseControl作为持续集成工具。每次开发者往代码库中签入(check in)代码时,就会触发CruiseControl对项目进行构建,构建的内容包括编译、连接(对于后台部分)、单元测试、测试覆盖率统计、代码复杂度统计、编码规范检查、部署到测试环境、功能测试等。由于前台运行在Windows上而后台运行在Linux上,我们用两台持续集成服务器分别构建前台与后台,然后再把两者集成起来进行功能测试。

    在建立持续集成环境的过程中,我们发现eMAN项目以前缺乏有效的项目自动化(automation)机制。虽然前后台分别有一些脚本用于执行编译、部署、启动应用等常规任务,但项目自动化机制还有较大的欠缺:

    缺乏版本控制。原来的版本控制库中只有项目源代码和运行时配置文件,开发阶段的配置和自动化脚本都不在版本控制中。

    环境依赖。原来的自动化脚本对操作系统、安装的软件环境甚至项目路径等因素都有依赖,每个开发者从版本控制库获取代码之后还需要复杂的配置才能让系统在本地运行。

    自动化不彻底。原来的自动化脚本没有覆盖到构建的所有环节,并且各个环节之间没有连通,开发者必须执行多个步骤的操作才能完成构建。

    在这样的自动化脚本基础上,持续集成环境无法发挥出它最大的价值,因此我们对自动化脚本做了一系列改进,达到的效果是:只要在一台干净的机器上安装Java和Ant,然后从版本控制库签出(check out)项目,在项目目录里执行ant即可完成整个构建。于是完整的构建不仅在持续集成服务器上频繁运行,还在每个开发者的工作机器上更加频繁地运行。

    在这个过程中我们用到的工具(前台部分)包括:

    持续集成服务器:CruiseControl

    项目自动化工具:Ant

    测试覆盖率和代码复杂度统计工具:Cobertura

    代码风格监测工具:Checkstyle

    安全网

    正如前文提到的,对于重构性质的story,测试覆盖率是一项重要的验收条件。这是由重构任务的特性决定的。

    重构(名词):对软件内部结构的一种调整,目的是在不改变“软件之可观察行为”的前提下,提高其可理解性,降低其修改成本。

    重构之前,首先检查自己是否有一套可靠的测试机制。

    ——Martin Fowler,《重构》

    没有一套可靠的测试机制,重构就无从谈起,因为你根本就无从知道自己做的调整是否改变了“软件之可观察行为”,甚至可能已经搞得系统不能运行还一无所知。而eMAN的现状正是如此:验收测试无法自动运行,单元测试更是在上一个版本交付之后就再也没有运行过。简而言之,eMAN目前没有测试。

    对这种没有测试的系统进行重构,就像是编织一张网:先针对一小块功能编写验收测试,在这张“粗网”的保护下再逐渐给代码添加单元测试,有了粗细两层网的保护再深入重构。随着重构的开展,这张频繁自动化运行的安全网也渐渐铺开。从不断提升的测试覆盖率和不断降低的代码复杂度,我们就能清晰地看到重构的进展情况。

    为什么要首先编写验收测试?当然了,如果代码本身结构良好,单元(类、方法)之间关系清晰,你也可以直接添加单元测试——但这样的代码基础就不需要大动干戈地专门组织重构了。我们在eMAN项目中发现,那些最需要重构的代码也是最难进行单元测试的,而没有测试我们又不敢动手重构。(在“典型案例”一节我们将介绍几种阻碍单元测试的常见情况和解决办法。)这时验收测试就可以在系统外围担任“看门人”,给我们一个起点:在调整代码结构以便单元测试时,我们至少知道这些调整没有破坏系统的功能。

    在eMAN项目中,我们用Rational Functional Tester(RFT)来做验收测试。我们还评估了另一种针对Swing应用的功能测试工具Abbot。相比之下,RFT最大的优势在于独立性:测试工具与被测应用在不同的Java虚拟机里运行;而Abbot则是在当前虚拟机环境下运行被测应用,如果被测应用与Abbot引用同样的第三方包,就可能出现版本冲突。但RFT也有一些明显的劣势:测试案例编写难度大,占用系统资源多,与Ant集成不佳,而且价格不菲。读者应该根据自己项目的情况谨慎选择。

    除了用RFT实现前台Swing应用的验收测试之外,我们还用Selenium实现了前台Web应用的验收测试。对Abbot的研究也没有浪费,我们用它来实现了Swing界面的单元测试。从理论上来说,任何一段代码都可以并且应该被测试,但适当的工具能让测试事半功倍。组合多种测试工具,从不同层面、不同角度对系统进行测试,才能织起一张可靠的安全网。

    与通常说的“测试驱动开发”(TDD)相比,这种重构项目的节奏略有些不同:不是标准的“红-绿-重构”,而经常是“绿-重构-绿”。不过,这两种节奏都是敏捷项目中很常见的,下面的图就同时包含了两者。

    值得一提的是,图中的弧线不仅代表开发中的一项活动、系统状态的一次变迁,而且还代表一次在结对中转移键盘的机会。在eMAN项目中,我们经常以这样的方式工作:一个人给现有代码补上一段测试,把键盘推给身边的同伴,后者重构被刚才的测试覆盖的那段代码。以这样的节奏稳步前进,确保了知识能够在结对的过程中得到传递。

    典型案例

    下面列举了一些eMAN系统(前台部分)中较常见的代码质量问题。我们没有列出一些更常见的“坏味道”(例如大类、长方法等),因为Martin Fowler在《重构》一书中已经把它们描述得足够清楚,而且针对它们的重构也相对容易。本文中列出的是一些相对规模较大、较为复杂的情形。

    我们相信类似的情形也存在于很多其他系统中,但我们并不打算宣称这个列表足以包治百病。大规模重构是一件极其复杂的细致活,很多时候你需要根据当前情况寻找适合自己的解决方案。

    无法在测试环境中创建被测对象

    对象在创建过程中自己尝试获得所需的依赖对象,就可能在单元测试环境下因无法创建依赖对象而导致被测对象的创建失败,从而不能把被测对象放进单元测试。例如AuthorizationService类的创建过程如下:

    在单元测试环境下SecurityBaseModule.getSecuityModuleRef()返回null,因此尝试创建AuthorizationService会抛出NullPointerException异常。

    重构办法:

    通过构造函数的参数传入依赖对象,而不自己创建。

    使用被测对象的地方负责创建依赖对象。随着重构进行把这一责任不断上推。

    把初始化动作与构造函数分开,构造函数只用于获得依赖关系。如果还需要更复杂的初始化动作,在单独的初始化函数中进行。

    重构目标:

    通过调用构造函数、传入null作为依赖对象引用,能在单元测试中创建被测对象。

    无法在测试环境中运行被测方法

    如果被测方法在计算过程中自己尝试获得所需的依赖对象,就可能在单元测试环境下因无法满足依赖对象的要求而导致测试失败,从而无法对希望测试的方法进行单元测试。例如SessionManager.isAdminGroupUser方法如下:

    在单元测试环境下RpcInvoker的调用尝试必定会抛出异常,于是对isAdminGroupUser方法的调用必定会返回false。如果让RpcInvoker通过网络进行真实的RPC调用,不仅工作量大,使测试不可靠,而且这样的测试实际上主要是在测RpcInvoker的工作是否正确,变成了集成测试而不是SessionManager的单元测试。

    重构办法:

    通过构造函数的参数传入依赖对象并保存在成员变量中,需要使用依赖对象时通过成员变量调用。被测方法不直接创建依赖对象。

    使用被测对象的地方负责创建依赖对象。随着重构进行把这一责任不断上推。

    单元测试中用mock框架(推荐JMock)创建依赖对象。在每个测试案例(即测试方法)中独立设置对mock对象的期望,发现明显的重复时再抽取公共代码。

    重构目标:

    通过调用构造函数、传入mock对象,能在单元测试中创建被测对象。

    对mock对象设置适当的期望,能调用被测方法,并覆盖到正常和异常路径。

    不恰当的对象获取方式

    不必要的Singleton模式。如果一个对象本身没有内部状态,只是根据外界状态进行计算,这样的对象不需要是Singleton的。例如CommonTool类和SessionService类:这两个类只是把请求转发给其他对象处理,它们不需要使用Singleton模式。

    通过其他对象获取。从其它对象中取出自己需要的依赖对象,“由谁提供某个对象”的决策相当随机,有时通过一条长链来获得自己真正需要的依赖对象。例如要得到RightPaneXMLParser对象,就需要通过下列方式:  

    重构办法:

    如前所述,对象所需的依赖对象全部以构造函数参数的形式获得,将“创建对象”的责任不断上推,直至系统顶端的某个位置聚集了系统中绝大部分的对象创建逻辑。

    在系统顶端分离出一个全局工厂对象,该对象负责创建系统中所有的对象,并组装对象之间的依赖关系。其他地方原则上不作对象创建。

    在少数不直接被这个系统顶端调用的地方(例如对外暴露给第三方的接口),从全局工厂请求自己需要的对象。

    引入轻量级IoC容器(建议在PicoContainer和Spring Core之间选择),替代这个全局工厂对象。

    重构目标:

    系统中主要业务对象的创建都在全局工厂进行。

    业务对象是否Singleton能够以配置的方式管理。

    对象依赖关系复杂

    通过上述重构,尤其是改为通过构造函数参数来获得依赖对象之后,在一段时期会发现各个对象的构造函数堆积了大量的参数。例如经过重构之后的UserLoginDlgHelper的构造函数签名如下:

    共有5个依赖对象从构造函数传入,显得构造函数相当臃肿。这实际上反映出对象本身的臃肿:这个对象承担了太多责任,因此需要依赖大量其它对象。过长的构造函数参数列表是一个直观的指标,让我们能够清晰地看到对象依赖的情况。经过观察可以发现,这些依赖对象都分别只在几个方法中被用到,这意味着UserLoginDlgHelper对象本身的职责可以分为几个相对独立的方面,可以被拆分为几个独立的对象。

    重构办法:

    以依赖对象的使用情况为线索,把大对象拆分成多个小对象。大对象中原有的方法不删除,而是把调用委派给小对象。

    逐一修改大对象的使用者,让它们使用拆分出来的小对象。

    大对象中的方法无人使用时即可删除,依赖对象无人使用时即可从构造函数参数列表中删除。

    重构目标:

    每个对象的依赖对象数比较合理。

    数据缺乏对象封装

    系统中一些数据以原生类型或者简单容器的形式呈现,“对数据的操作”与数据本身脱离,散落在系统各处。这样做的坏处是不容易看清数据代表的含义和处理这些数据的逻辑。特别是在数据结构发生改变时,必须找出所有操作这些数据的地方,同时修改,一旦有遗漏就很容易引入隐晦的错误。

    例如“登录信息”在系统中用Vector数据类型表示,其中各个字符串分别代表DS服务器IP、DS服务器端口、认证模式、安全模式等信息。“共有多少个字符串”和“各个位置上的字符串表示什么含义”等信息在ServerInfoTable类中以常量的形式保存:

    系统中共有5个类、十多个地方引用这些常量,还有一些地方直接用魔法数来访问这些信息。如果新增一列数据,需要修改的地方很多,很容易出错。

    重构办法:

    创建类型来封装原生数据,同时提供对象与原生数据之间的双向转换方法。

    找出原生数据的“索引常量”(如前面的ServerInfoTable),以它为线索,逐个方法进行重构,把其中使用原生数据的逻辑改为使用新对象。在方法入口处把原生数据转换为对象,出口处把对象转换回原生数据。

    一条调用链重构完成后,即可修改其中各个方法的签名,由传递原生数据改为传递对象。

    如果某些操作不涉及其他业务对象,只是操作这组数据,把这样的操作移到新建的类中。

    在调用链附近留意检查是否有不通过“索引常量”而用魔法数直接访问数据结构的情况。

    重构目标:

    与特定数据结构相关的描述和操作都封装在对象内部,“索引常量”和魔法数被删除。

    改变数据结构只需要一处修改。

    小结

    大规模遗留系统的重构一直是困扰众多软件组织的难题。所谓“冰冻三尺非一日之寒”,大型系统中的质量问题是经年累月堆积下来的,要解决这些问题也只能从价值最高的地方入手,耐着性子一点点重新恢复代码质量。经过这个咨询项目,eMAN产品开发团队在原有代码的基础上划分了细粒度的重构story,建立了持续集成环境,并按照共同探索出的节奏,以测试为驱动、以代码质量为导向,不断重构以改进代码质量,并且积累了一些常见问题的解决办法,为大规模遗留系统的重构找出了一条切实可行的路子。

0
相关文章