Thoughtworks的Sam Newman在Mythoughtworks的Software Development小组中给出了Evolutionary Architecture的一些资源。其中一个是Martin Fowler与Rebecca Parsons在QCon SF 2009的一次演讲,题目为Agilists and Architects: Allies not Adversaries Presentation。演讲主要谈到了在敏捷方法中的架构活动(在Martin Fowler的演讲中,播放了黑客帝国中的一个片段,很有意思)。另一个资源则是同样作为thoughtworker的Neal Ford在IBM developerWorks发表的Evelutionary Architecture and Emergent Design(演进架构与紧急设计)系列。这是很棒的一个讲解演进架构的系列文章,谈到了TDD、代码复用、连贯接口、DSL、重构、惯用法模式、指标等与演进架构和紧急设计有关的内容。
事实上,关于演进式架构已经是老调重弹。Martin Fowler在2004年发表的文章Is Design Dead中谈到了计划式设计与演进式设计之间的区别。在我的书《软件设计精要与模式》第一章中,也简单阐述了我对二者的理解。书中给出了一个建筑学的隐喻:拙政园与周庄。拙政园是计划式设计的典范,没有详尽的计划,也许就不会有疏朗典雅的拙政园。周庄却并非某人在某一时刻灵感捕捉后的设计成果,而是经历了数百年的历史沧桑,渐进地增添与更替各种建筑,最后形成现在这般灵秀的水乡风貌。在书中,我写道:
演进的设计,同样需要遵循架构设计的基本准则,它与计划的设计唯一的区别是设计的目标。演进的设计提倡满足客户现有的需求;而计划的设计则需要考虑未来的功能扩展。演进的设计推崇尽快地实现,追求快速确定解决方案,快速编码以及快速实现;而计划的设计则需要考虑计划的周密性,架构的完整性并保证开发过程的有条不紊。
最近正在阅读George Fairbanks的著作Just Enough Software Architecture,书中除了计划式设计和演进式设计之外,还提到了第三种设计:Minimal planned design(最小计划设计),这算是一种中庸之道的选择。书中认为,演进式设计需要与一些敏捷实践配合,包括重构、测试驱动设计与持续集成(evolutionary design must be paired with supporting practices like refactoring, test-driven design, and continuous integration.)George认为计划式设计背后隐藏的思想是在构造开始之前,制订的计划可以设计出很好的细节(The general idea behind planned design is that plans are worked out in great detail before construction begins)。他还提到:
当架构为并行的多个团队所共享时,计划式架构设计就具有实践意义,在子团队开始工作之前,这种计划式设计颇有效用(Planned architecture design is also practical when an architecture is shared by many teams working in parallel, and therefore useful to know before the sub-teams start working)。
书中还写道:(对于多团队开发而言)计划式架构定义了高层的组件与连接器,并与局部的设计相匹配,而子团队则设计这些组件与连接器的内部模型。架构常常会保证整体的不变量与设计决策,例如建立并发策略、连接器的标准集、分配高层职责或定义某些局部的质量属性场景(a planned architecture that define the top-level components and connectors can be paired with local designs, where sub-teams design the internal models of the components and connectors. The architecture usually insists on some overall invariants and design decisions, such as setting up a concurrency policy, a standard set of connectors, allocating high-level responsibilities, or defining some localized quality attribute scenarios)。
至于最小计划设计,则介乎于演进式设计与计划式设计之间。支持这种设计的人认为:如果完全采取演进式设计,可能会使得设计走向死胡同;而计划式设计又非常难,因为事先对系统并没有全面的了解,可能导致设计错误。在2002年Bill Venners对Martin Fowler的采访中,Martin Fowler认为,最合理的分配是20%的计划式设计,80%的演进式设计(I think 80 percent of the time evolutionary design works for me as well. )。在George的书中,作者认为需要权衡计划式与演进式设计。一种做法是在项目初期进行计划式设计,确保架构能够处理最大的风险。之后,就可以通过局部的设计来应对需求的变化,或者采用演进式设计,通过推行重构、测试驱动设计与持续集成对架构进行演化。
整体而言,这三种方式的设计各有优劣,我们应根据具体的场景,具体的项目,具体的团队进行针对性地分析。应该把握“因地制宜”的原则,认识到不同的项目需要不同的设计方式。对于不同的开发团队,做出的选择也会不同。例如,如果开发团队精于重构、测试驱动设计,并能很好地实施持续集成,就可以考虑采用演进式设计或最小计划设计。当然,就我个人的意见,比较倾向于Minimal planned design。至于它在演进式设计与计划式设计之前的权衡,不必完全照搬Martin Fowler给出的比例。
在Sam Newman给出的演进式架构资源中,还有一篇coding the architecture的文章Just enough architecture。这篇文章则从方法学的角度分析来如何获得恰如其分的架构。这是文章中非常漂亮的一幅图:
文章以及上图所表达出来的含义是:传统的瀑布式采取事先设计的做法,可以认为是计划式设计;敏捷方法学倾向于演进式设计;处于其中的RUP则更像是前面提到的最小计划设计。文中主要还是关注我们在架构过程中如何做到架构的“just enough”。事实上,这一观点在George Fairbanks的著作Just enough software architecture中被反复提到,要做到这一点,就需要采用风险驱动模型(Risk-Driven Model)。RDM的架构步骤分为三步:
其实风险驱动模型的三个步骤很容易理解,关键是我们应该如何识别风险,如何排列优先级,又该如何确定解决或控制风险的技术,并进行合理地评估,这是风险驱动模型的难点。我认为RDM带来的益处在于它给出了一个非常具有实践意义的驱动原则与方法,它告诉架构师,当我们在对系统进行架构时,需要从一开始就要重视风险,例如系统的安全性、可伸缩性、安全等诸多与质量属性有关的技术风险。个人认为:风险驱动加上场景驱动,以及技术约束,就等于敏捷架构。大体如下图所示:
风险驱动主要用于处理质量属性相关的架构内容,而场景驱动则用于处理与功能需求相关的架构内容,而技术约束则是架构层面,可能是产品线、环境等能够对系统架构产生直接影响的约束因素。至于敏捷架构的目标,就是设计恰如其分的架构。
处理遗留系统,几乎是每个程序员都不可能绕过的一件麻烦事儿。因为时间压力,技能不足以及功能复杂等诸多原因,常常使得遗留系统的代码变得糟糕混乱,可读性与维护性差,无法保证功能的可测试性,纠缠不清的代码让类、方法之间紧紧耦合在一起。如果遗留系统能够正常工作,那么我们还可以置之不理,即使代码接近腐烂的边缘,我们还可以得过且过。倘若我们需要维护遗留系统,或者需要为它添加新的功能,又或者需要将新的系统与遗留系统进行集成,就必须正视遗留系统带来的问题了。
处理遗留系统,首先需要分析和了解遗留系统,尤其这个遗留系统并非你开发时,更需如此。我们可以考虑双管齐下的办法。一是从业务逻辑方面去了解。相比新系统而言,遗留系统的唯一好处就是它往往是可以运行可以使用的。因此,最好的办法是直接运行遗留系统,通过实际操作了解系统的各个功能点、业务流程。这样的直观感受可以最快地帮助你了解该系统:它能够做什么?它能达成什么目标?它的范围是什么?它存在什么问题?其二则需要从系统架构出发,了解遗留系统的逻辑结构和物理分布。可以阅读架构文档和源代码,如果能够咨询遗留系统的设计者或开发人员,就更好。尽快地描绘出遗留系统的轮廓图,可以帮助你从技术的宏观角度剖析遗留系统的结构与组成。再结合你对该系统业务的理解,快速地掌握遗留系统。如果需要阅读源代码,最好能够从主程序入口开始,找到一些主要的模块,了解其大体的设计方式与编码习惯。由于之前对系统架构已有了解,阅读代码时,不应在一开始就去理解代码实现的细节,而应结合架构文档,比对代码实现是否与文档的描述一致,并充分利用自己的技术与经验,找到阅读代码的终南捷径。例如,如果我们知道该系统采用了MVC架构,就可以很容易地根据Url找到对应的Controller对象,并在该对象中寻找业务功能实现的脉络。又例如我们知道系统采用了SSH框架,而我们又非常熟悉SSH框架,就可以基本忽略系统基础设施的部分,直接了解系统的业务实现。如果是Swing系统,而且在界面中混杂了大量的业务逻辑和界面逻辑,则需要找到系统实现的特点,譬如系统的业务都是通过菜单项进行操作,就可以在界面中找到相关的菜单对象,然后根据这些对象的Action操作,一步一步跟踪。甚至可以利用调试的方法,设置断点,来摸清楚系统的运行机制与执行顺序。
分析遗留系统需要有的放矢,根据目标快速锁定范围。例如,如果我们是因为系统性能出现问题,而要去分析遗留系统,就无需过于关心业务逻辑,而应从性能分析入手。考虑数据库的访问,IO操作,缓存机制,资源的使用等诸多方面。这就需要借助一些经验和技术了,当然也可以考虑使用一些工具,用以诊断性能瓶颈。我们曾经在一个项目中,发现遗留系统的性能问题非常严重。根据分析,我们发现系统对字符串的处理存在问题,大量使用了String类型的对象完成字符的拼接。而在进行数据库查询时,很多代码是直接性地一次将相关表的数据“拉”到内存的集合对象中,然后利用过滤器在集合对象中进行筛选。而在对数据进行更改时,又没有很好地利用业务特性,完成一次提交,导致产生多次数据库访问。在系统的某些公共模块中,重复多次加载了Spring的配置文件。还有某些占用了较大资源的对象,对于系统用户而言应该是同一个对象,但却没有设计为单例。因为我们抱着改善遗留系统性能的目的,所以在分析遗留系统时,就应该事先确定可能导致性能损耗的地方,而不是全方位地去了解整个系统。
在维护遗留系统时,需要根据不同的场景做出不同的决策。简言之,我们需要排定优先级。如果时间紧迫,则解决问题是第一要务。尽快通过出错情况和记录日志辨别出错原因,定位出错代码,并解决之,而不是去考虑设计的优雅,代码的重用。我在一次解决报表显示的问题时,采用的就是这种做法。虽然与错误相关的代码相当丑陋,但由于时间紧迫,解决Bug才是最要紧的,所以我基本上没有去修改或改善既有代码的结构,仅仅解决了问题就结束了对遗留系统的处理。这个问题的处理过程在我的另一篇博客《精益求精,抑或得过且过》中详细论述。是的,我在此时选择了得过且过的做法,主要原因就在于优先级列表中,问题的解决才是最高优先级。在这次处理过程中,我部分地利用了copy & paste的做法,并通过引入新方法的方式来解决bug,而不是直接修改出现问题的方法。这是因为该方法的引用点存在多处,由于遗留系统并没有单元测试的保护,我不能草率地修改,否则可能导致一个bug的修复,结果引入更多无法预测的Bug。
这样的处理方式其实是我不甘心的选择。在时间允许的情况下,我会考虑对相关代码进行一些小的重构,例如提取方法或提取类等。虽然这些重构不能改变遗留系统的本质,但至少可以提高代码的可读性,并能在一定程度下去除代码重复。
这一实例实际引出了单元测试的必要性。如果我们的开发都能够在单元测试的呵护下进行,即使当它随着时间的推移,慢慢变成了丑陋的遗留代码,因为有单元测试的保护,在对它进行手术时,成功的几率却会变得更大。因此,认为编写单元测试是浪费时间的观点,事实上是一种短视的做法。缺乏单元测试,就是一种技术债(technical debt)。现在欠下的,将来总是要还的。我们不能忽略软件的维护成本。事实上,在软件成本中,维护成本所占的比例远远大于开发成本。Kent Beck在《实现模式(Implementation Patterns)》一书中认为:“维护的代价很大,这是因为理解现有代码需要花费时间,而且容易出错。知道了需要修改什么以后,做出改动就变得轻而易举了。掌握现在的代码做了哪些事情是最需要花费人力物力的部分,改动之后,还要进行测试和部署。”
当然,在处理遗留系统时,仍然可以进行单元测试。但它需要的技能更高,付出的精力更多。若要完成对遗留系统的单元测试,首要必须解决的就是依赖。解除依赖是面向对象系统开发中似乎亘古不变的话题。无论你翻阅哪一本面向对象著作,都会提到这个问题。依赖的起源其实在于对象的协作。根据单一职责原则,一个类显然不能承担太多的职责。如果一个系统的所有功能都是一个对象完成的,那么这个对象就成了邪恶的“上帝”对象。它的粒度太大,因此难以重用,不够灵活,细节纠缠,无法维护。这样的设计是我们必须避免的。既然一个类不能承担太多职责,系统要完成客户要求的所有功能,就必须让许多对象互相协作,就像人类社会的人际交往一般,于是,就产生了对象之间的依赖。凡事有利必有弊,这种细粒度的对象协作,虽然保证了对象的重用性、灵活性,却也因为协作带来的依赖,导致对象之间存在一定的耦合度,变得难以替换。由于一个对象依赖另一个对象,就使得我们在单元测试时,无法独立地测试我们的目标对象。
依赖虽然不可避免,但如果能够保证对象之间的良好协作,就能够最大程度保证系统的松耦合。对象协作一般可以分为类协作、接口协作与回调协作(回调协作通常可以看做是接口协作的一种特殊方式,关于这三种协作的特点与方式,我会在以后的文章中谈到)。依赖最弱的是接口协作,这也是我们努力达到的目标,它符合“面向接口设计”的基本原则。同时,也能够保证对象的封装性,通过将实现细节隐藏起来,从而降低对象之间的依赖程度。
知易行难,要对付遗留系统中的依赖问题,通常需要付出百倍的努力。Michael Feathers在其大作《修改代码的艺术(Working Effectively with Legacy Code)》中,给出了很多好的解除遗留代码依赖的实践。例如他提出的接缝类型,隐藏依赖,以及影响结构图。解除依赖需要合理地利用封装与抽象,包括对方法参数的封装与抽象。对于遗留系统而言,Feathers提出的影响结构图尤其有用。当你需要修改某个方法时,影响结构图可以帮助你分析该方法的依赖传播途径,避免因为修改对其他代码造成影响。具体做法可以参考该书。当然,现在有很多IDE工具事实上还支持生成依赖图,它可以在一定程度上帮助你寻找依赖的方向与结构。不过,手工方式的分析尤其是通过手绘影响结构图仍然不可缺少,它更能帮助你深入地理解遗留系统。为遗留系统绘制包图(Package Diagram)同样非常重要,它既能帮助你理解遗留系统的结构,又能为我们找到系统中可能存在的双向依赖和循环依赖,找到分解不合理的包,这就为我们的系统重构奠定了良好的基础。
如果遗留系统非常复杂,以至于无法重构,同时我们又需要在遗留系统的基础上增加新的特性和功能,好的做法就是做好新、旧功能之间的隔离。暂时可以不去考虑遗留系统的原有结构和代码(大多数情况,这些遗留系统其实是可以正常工作的),而只需要为新增的功能做好单元测试,并以好的设计原则与编码规范来要求新功能的实现。同时,我们还可以编写一些自动化的回归测试用例(例如使用Cucumber结合Watir为Web系统编写回归测试),保证新增的功能不会影响到原有系统。如果新增功能的实现需要调用原有系统中的某些类或方法,而这些类和方法却又难以分解,则可以考虑参照原有实现编写新的类或方法,新的类一定要做到合理的封装与抽象,保证它的高内聚与松耦合;也可以在新的类中不去真正实现,而是通过运用Adapter模式来重用旧有的类。
只要不是更改开发平台,通常情况下,我们不会考虑重写遗留系统。如果需要重构遗留系统,就必须采取“分而治之,小步前进”的策略。可以首先选择实现较为容易,或者独立性较好的模块进行重构。将遗留系统逐步提取为一些可重用的模块与类,就可以形成“星星之火,可以燎原”的态势。其中,对于原有类或模块的调用方,由于在重构时可能会更改接口,因此可以考虑引入Facade模式或Adapter模式,或者对接口进行另一层的包装,或者对接口进行适配,使系统慢慢被替换,最后演化为一个结构合理的良好系统。在重构时,甚至可以考虑将一些独立性很好的功能,提取为单独进程下的应用或服务,通过Web Service、Socket或Restful的方式进行调用,既保证了重用,又能够使遗留系统变得更简单,成功瘦身。
我们还需谨记的一点是,虽然模块乃至服务的重构对系统的改造更加重要,但我们却不能忽视代码的质量。小到方法的提取,以及变量的命名都非常重要。也许它不会给系统带来根本的改变,但它却能够改善代码的可阅读性,进而提高系统的可维护性。所谓”聚沙成塔“,无论是系统还是模块,其实都是这些类和方法以及变量组成的。
对遗留系统的处理不可能“一蹴而就”,必须遵循循序渐进的做法,逐步改善。处理遗留系统影响深远,成本也非常高,所以我们也不能因为脑袋一发热,就开始气势汹涌地“提枪上阵”,错将风车当做了魔鬼,结果撞得头破血流。必须对整个遗留系统进行审慎的分析,并结合具体情况考虑这项工程的复杂度、成本与预算,了解团队的重构与设计能力。处理遗留系统的前路漫漫,我们固然需要上下而求索的决心与勇气,却也不能在茫然失措中迷失了前进的方向。遗留系统的处理,必须慎之,慎之,再慎之!
注:本文发表自张逸的网站: 捷道-设计恰如其分的架构