摘要
Custom House公司目前所使用的汇兑系统是与另一个老系统集成的。经过多年的演化,两套系统之间的关联与交互变得非常复杂,以至于对这两套系统的任何一处修改,都会带来一些难以预计的问题。而另一方面,从集成层(integration
layer)对系统进行重构,不仅风险较大,而且也很耗时。于是,对于这样的现状,我们需要对两套系统进行革命性地重构。
要实现这样的重构,就需要在两套系统中间引入防腐层,从而对两套系统进行隔离。防腐层对两套系统之间的概念模型以及功能行为的转换进行了合理封装,并且能够确保其中一个系统的领域层不会依赖于另一系统。通过将领域层从系统集成任务中解放出来,防腐层还允许其它的外部系统能够在不改变现有系统的领域层的前提下,与该系统实现无缝集成。防腐层的实现,将系统集成所需的开发工作量从30%降低到10%。
实现防腐层的最大挑战在于对“转换(translation)”任务复杂度的控制,而这是通过一种比较创新的方式实现的:即为老系统所隐喻的领域模型建立一个对象模型。我们的经验是,对某个外部系统领域模型的充分提炼,并不要求这个系统是以面向对象的方式实现的,而这也正是在两套系统的领域模型与功能行为之间实现精准的、可扩展的“转换”的关键所在。
关键字
领域驱动设计、防腐层、领域模型、集成、观察者模式
背景
跟大多数企业级应用程序一样,Custom House的汇兑系统需要跟另一套老的后台系统进行集成,以实现完整的业务处理流程。这套汇兑系统(SPOT)会处理绝大多数来自于前台的在线事务。当SPOT完成一个汇兑事务的同时,需要将信息发送到后台系统以便完成后续的操作,而这套后台系统就是我们需要集成的系统,我们称之为TBS。
SPOT是一套使用Microsoft .NET技术实现的面向对象企业级应用,而TBS则是一套基于数据表和数据记录等概念的Microsoft
FoxPro应用程序。
集成
为了集中数据导出、数据转换的处理以及跨平台通信,我们在SPOT和TBS之间创建了一个称之为TBSExport的枢纽组件(Gateway)。
图一 老系统中SPOT与TBS之间的依赖关系
说明:为了简化讨论,在此只列出了两种最重要的信息:Order和Customer,而实际上从SPOT发送到TBS的信息种类是非常多的。
正如上图所示,TBSExport组件会读入以通用数据结构(实际上是.NET
DataSet)的形式所组织的信息,并将其转换为TBS所能理解的数据格式,然后输出给TBS。例如,当客户下达一张订单后,SPOT会创建一个包含了订单信息的DataSet,然后调用TBSExport组件:
public class OrderManager
{
public void BookOrder(Order order)
{
//…book order and update order entry in database
DataSet orderDataSet = OrderDataSetBuilder.BuildTbsDataSet(order);
TbsExport.ExportOrder(orderDataSet);
}
} |
在获得了导出的请求之后,TBSExport会把这种请求委托给真正的导出器(Exporter)以完成数据的导出、转换和传输。
架构之腐化
这样的设计的确正常地运行过一段时间。不过随着时间的推移,SPOT和TBS都需要实现新的功能,于是,系统集成就成为了一个繁重的任务。开发人员开始感觉到两套系统之间的转换逻辑变得越来越难理解。在SPOT系统上线两年后,与TBSExport相关的开发任务占据了整个项目开发任务的30%左右。QA团队也将两套系统的集成部分认定为bug的重灾区。最终,有个项目的实现需要对TBSExport进行大幅修改,然而系统集成的复杂度注定了该项目的失败。于是,无论是项目管理者还是开发人员,都希望能够尽快对TBSExport进行革命性地重构。
那么,问题究竟出在什么地方呢?
SPOT的领域模型与TBS之间的紧耦合
从上面的代码中我们可以看到,SPOT向TBS发送信息时,它做了两件事情:
1.创建了一个包含特定数据的DataSet
2.调用了一个定义在TBSExport组件上的导出服务
在第一步中,SPOT的领域对象将自身转换为DataSet。由于这种转换代码在领域对象中随处可见,这就使得这些对象中真正用于处理业务的逻辑变得非常混乱,从而使问题变得复杂。之后,我们将这部分转换代码移到一些独立的类中,类似OrderDataSetBuilder等。这样做虽然能够使逻辑变得清晰,但领域层仍然受这些转换逻辑的约束,而这些转换逻辑却与SPOT本身的业务逻辑毫无关系。
在第二步中,数据导出的行为是由SPOT发起的。这就要求SPOT中的多数操作(比如创建新的客户、下订单以及确认支付等)都需要对TBSExport进行引用。最开始的时候,这种依赖关系仅存在于处理工作流的服务层中。但久而久之,这种依赖也影响到了领域层,从而导致SPOT的领域对象不得不包含一些与TBS相关的代码。例如,BankDeposit类包含了一个内部成员类型:TBSFile,而它却是定义在TBSExport组件之中。
public class BankDeposit
{
TBSFile tbsFile;
public void DoDeposit()
{
// domain logic for deposits…
tbsFile.Export();
}
} |
SPOT与TBSExport之间的紧耦合为系统维护带来了不少麻烦:
- 对SPOT领域逻辑的隔离/单元测试变得非常困难
- 在对TBSExport进行单元测试之前,需要花很大的功夫来准备一些SPOT对象
- 跟踪和修正与数据导出相关的Bug变得非常耗时。对TBS的一小点改动就很容易引起SPOT产生一些无法预知的问题,反之亦然
数据转换以一种较为底层的原始数据类型的方式进行
TBSExport向外界暴露了一系列接口,这些接口都使用DataSet作为方法的参数,而DataSet则以一种平展的结构保存着SPOT对象的数据。例如OrderDataSet,它保存了一些与SPOT中Order对象相关的数据。在获得DataSet之后,TBSExport还需要将这些DataSet转换为TBS能够识别的新的数据格式。由于SPOT和TBS分别基于两种完全不同的模型,因此这个转换逻辑是非常复杂的。在SPOT中,“Order”是一个聚合,它包含了一条或者多条“Line
Items”。每条“Line Item”又通过“Drawdown”对象关联了一个或多个“Contract”对象。每当需要向TBS导出一条Order时,与Order、Line
Item、Drawdown和Contract相关的信息都被一股脑地塞进了OrderDataSet中。
而另一方面,TBS却使用一种平展的表结构来表示不同类型的“汇兑交易”。每条TBS交易对应表中的一条记录。SPOT的领域概念在TBS中完全不存在,所以转换逻辑会将一个SPOT的Contract对象转换成两条TBS记录,一个SPOT的Line
Item对象转换成一条TBS记录,而将一个SPOT的Drawdown对象转换成两条TBS记录。于是,一个SPOT的Order对象就被无形地映射成了多条TBS记录。
图二 老的转换逻辑
OrderDataSet和TBS的交易数据表都仅包含了原始数据类型的数据,比如字符串(string)或者整数数据(integer)。为了确保转换的正确性,转换逻辑不得不去了解每条数据在两个系统下各自的含义,以及该数据在两个系统之间错综复杂的联系。这种底层的数据映射,不仅繁琐,而且很容易导致错误的出现:因为这种做法不仅需要涉及到每个数据的具体细节,而且还会在两个系统中出现大量的重复逻辑。比如,OrderExporter中就包含了超过3000行专门用于数据映射的代码。这种复杂性是导致混乱出现的根本原因,也致使系统组件变得难以维护。
业务逻辑过多地纠缠于TBSExport中的技术细节
在TBSExport组件的核心部分,包含了一系列的Exporter类,如下图所示:
图三 老的TBSExport设计
在上面的设计中,抽象类TBSExporterBase提供了创建和保存数据文件的具体实现。每个继承于该类的子类,都必须重写“PopulateTBSDataTable()”方法以实现相应的转换逻辑;同时还须重写“OutputToDatabase()”方法以便执行相应的数据库操作。这其实是两种完全不同的操作:其中一个对业务逻辑进行了处理(比如创建TBS的交易记录),而另一个则纯粹地执行了一些与技术相关的操作(比如将记录保存到磁盘)。通过下面的例子我们可以看到,这种既处理业务,又负责技术的类是多么的复杂,在这些类中,业务逻辑甚至还与数据行、数据表以及数据库连接等技术细节交织在一起:
public class CustomerExporter
{
protected override void PopulateTBSDataTable(DataSet dataSet)
{
DataRow drSpotCustomer = dataSet.Tables[0].Rows[0];
DataRow drTBSCustomer = tbsData.NewRow();
drTBSCustomer[COMPANY] = drSpotCustomer["CompanyName"];
//......more
tbsData.Rows.Add(drTBSCustomer);
}
protected override void OutputToDatabase()
{
OleDbCommand dbCommand = new OleDbCommand(tbsDBConnection);
//…
foreach (DataRow dataRow in tbsData.Rows)
{
dbCommand.CommandText = BuildQueryString();
dbCommand.Parameters[COMPANY].Value = dataRow[COMPANY];
//…more
dbCommand.ExecuteNonQuery();
}
}
} |
重构:引入防腐层
TBSExport与SPOT之间的关联不仅紧密,而且复杂,以至于每当需要对之进行扩展时,程序员都表现出了恐惧的心理。在2005年12月的时候,整个团队意识到,延续现有的开发方式已经不能很好地解决问题了:即使是对系统的一次很小的改动,都会对系统造成不同程度的负面影响,不仅耗时,而且风险很高。因此,我们决定对SPOT和TBS之间的系统集成部分大动手术。在Eric
Evans和领域语言(Domain Language)团队的帮助下,我们整个项目组,包括项目管理人员、开发人员以及QA,都全力以赴地对TBSExport进行重新设计。经过讨论,我们决定使用下面的设计方案:
- 实现一个新的TBSExport组件,它必须是独立的,并且具有完善的功能。团队需要确保该组件的设计是合理的,并对其进行了完整的单元测试
- 建立一种机制,通过这种机制将新的TBSExport组件与SPOT连接起来。需要注意的是,应该以一种松耦合的方式实现这种机制(也就是说,SPOT应该不会意识到该机制的存在)
- 在确定这种新的机制能够正常工作后,在SPOT中激活它,然后进行集成测试和回归测试
- 最后,删除旧的TBSExport代码,仅保存这个新的、松耦合的设计
这个计划使我们能够在不变动已有功能的基础上,重新设计一个新的TBSExport组件,因此,整个系统不会长时间地处于宕机状态。这个计划也使我们能够将新老两种实现方式放在一起进行对比,以确保新组件能够正确运行。
整个设计中最重要的一点是,将TBSExport设计成为衔接SPOT和TBS的防腐层。防腐层“并非是系统间的消息传递机制,更确切地说,它的职责是将某个模型或者契约中的概念对象及其行为转换到另一个模型或者契约中”。换句话说,我们要将这个组件设计为能够直接访问SPOT领域对象的隔离层,以负责完整的转换逻辑;而对于SPOT,我们只需要让其专注于自己的领域模型,而无需关注任何与转换相关的逻辑。为了达到这样的效果,我们做了以下工作:
重新设计TBSExport的外观接口(fa?ade interface),使其能够与SPOT的领域模型相接
请比较以下两个接口定义:
- 改动前:public void ExportOrder(DataSet orderDataSet);
- 改动后:public void OnOrderBooked(Order order);
老的接口定义需要SPOT将其领域对象转换成一个.NET的DataSet;而新的接口定义则直接将SPOT的领域对象用作函数参数。于是,SPOT只需要以自己的方式来使用这些接口即可,而无需做一些与业务无关的事情,比如“将对象转换成DataSet”。
提炼TBS的领域模型,并对TBS的行为进行抽象
老的TBS系统从一开始就不是面向对象的,但这并不表示它不包含一个领域模型,TBS的领域模型只不过是被大量的数据记录以及过程化程序所湮没而已。在重构的过程中我们发现,为了能够更加明确地表述TBS所包含的领域语义,从TBS中提炼出领域模型是非常必要的。在Order导出的案例中,虽然从TBS上看并没有一条明显的交易数据能够与SPOT中的Order相匹配,但在TBS中的确存在由多条TBS数据所表述的“Order”的概念:
图四 TBS所隐含的领域模型的一种表述
在完成了TBS领域模型的提炼后,我们就能够很自然地将SPOT中的Order对象转换为TBS的Order对象:
public class TbsOrderTranslator
{
public TbsOrder TranslateSpotOrder(Order spotOrder)
{
TbsOrder tbsOrder = new TbsOrder();
tbsOrder.Customer = MakeTbsCustomerId(spotOrder.CustomerId);
tbsOrder.Branch = spotOrder.Branch.BranchCode;
//….more
tbsOrder.Settlement = ComputeSettlement(tbsOrder);
return tbsOrder;
}
} |
在TBSExport中定义TbsOrder是非常重要的,它成为理解TBS中对象间关系的关键。两个系统都以一种更富意义的方式来表述各自的数据,这也使我们能够以对象的方式,而不是原始数据类型的方式,在两种模型之间进行转换。现在,我们就可以用它们各自的“通用语言”来对其各自的模型作进一步讨论。
接下来要做的就是将TbsOrder映射为TBS的数据记录。这是一个非常直接而且机械化的过程,并不包含任何业务逻辑。
将与TBS系统的通信部分从对象转换逻辑中分离出来
在老的Exporter类中,对象转换逻辑是跟与TBS系统的通信部分混杂在一起的,而在新的设计中,我们将TBSExport组件划分成三个层次,每个层次有且仅有一个职责:
- 转换器负责将SPOT对象转换成TBS对象
- 数据记录产生器负责通过TBS对象产生TBS数据记录
- 文件写入器负责将TBS数据记录输出到外部dbf文件中
下面的代码展示了SPOT中的Order对象是如何经历这三个层次,并最终被导入到TBS系统中的:
public void OnOrderBooked(Order order)
{
//1) Translate Spot Order to TBS Order:
TbsOrder tbsOrder = new SpotToTbsOrderTranslator(order).TranslateSpotOrder();
//2) Create TBS specific data structure from TBSOrder:
TbsTable tqrTable = new OrderTqrGenerator(tbsOrder, database)
.GenerateOrderTqrTable();
//3) Write TBS Files
GetTbsFileWriter(tqrTable).Write();
} |
TBSExport组件的整体结构如下图所示:
图五 新的TBSExport设计
这种分离式的设计所带来的众多好处之一,就是我们能够很容易地对处理过程的每个阶段进行单元测试。因此,一旦出现Bug,我们也就能够很快地找到问题所在。
反转SPOT与TBSExport之间的依赖关系
在老的设计中,是SPOT负责将数据传递给TBSExport的。这就要求SPOT能够知道调用TBSExport的时机和方式,于是,SPOT中的很多对象都需要依赖TBSExport,它们甚至还需要了解TBSExport的实现细节,以便能够正确地将数据传递给TBSExport。这种数据“推送”方式存在很多问题,它将原本就具有复杂业务逻辑的SPOT变得更为复杂:因为SPOT不仅需要专注于处理其本身的业务逻辑,而且还要关注数据传递的技术细节。不仅如此,今后可能还会有其它的外部系统需要与SPOT进行集成,如果仍然沿用旧的设计,那么SPOT将会乱成一团。
一种比较可行的方案是采用观察者模式:即当SPOT中发生某个事件时通知TBSExport。我们可以使用.NET中的事件来实现观察者模式。为了让实现起来更为简单,我们使用了定义在类级别的“静态”事件,这就使我们能够在服务启动的时候,直接将TBSExport的事件处理函数注册到SPOT的事件上,同时也使我们能够以一种更为灵活的方式来配置测试项目。
图六 SPOT和TBS之间的依赖关系
比如,OrderManager中定义了一个静态事件,创建新的Order对象时,都会触发这个静态事件:
public class OrderManager
{
public static event OrderEventHandler OrderBooked;
public void BookOrder(Order order)
{
//…book order and update order entry in database
if (OrderBooked != null)
OrderBooked(order); //fire event
}
} |
TBSExport将会订阅这个OrderBooked事件:
public class TbsExportManager
{
public void SubscribeToSpotEvents()
{
OrderManager.OrderBooked += new OrderEventHandler(OnOrderBooked);
//subscribe to other SPOT events
}
} |
在每次创建Order时,OrderManager所要做的仅仅是触发OrderBooked事件,而对接下来能够发生的事情一无所知(事实上它也不需要知道)。当订阅了该事件的TBSExport发现事件已被触发时,它的
OnOrderBooked()方法将被调用,数据导出工作正式开始。这种设计反转了SPOT与外部系统的依赖关系,而且更重要的是,今后如果有其它的外部系统需要与SPOT集成的话,这些系统都能够通过事件来获得SPOT中的信息,而无需对SPOT进行任何修改。
总而言之,以上描述的所有设计上的更改都遵循一个简单的原则:尽可能地减少SPOT领域层对TBS的引用。
结论
通常情况下,我们都会很自然地将TBSExport设计成类似本文最开始所描述的“集成枢纽(integration
gateway)”组件,这样的设计一开始是能够正常工作的。然而,随着越来越多的外部系统的引入,这样的设计不仅会给TBSExport带来不可控制的复杂度,而且会将TBS的相关逻辑带入SPOT的领域层中,使得SPOT的领域层不仅需要处理本身的业务逻辑,而且还需要完成与TBS相关的数据导出操作。最关键的问题是,这种设计没有能够完全地将SPOT和TBS的数据转换逻辑封装起来,从而导致两者的概念模型都越过了各自的边界而交织在一起。
解决这些问题的方案是,将TBSExport设计为防腐层,以便隔离SPOT与TBS,使两者各自的业务逻辑都不会泄漏到对方的领域中。我们所设计的防腐层大致包含了以下几个方面:
- TBSExport所提供的服务都是用SPOT的领域语言来描述的,它包含的接口都是以SPOT中的领域对象作为参数的,比如Order和Customer
- TBSExport完全封装了从SPOT领域对象到TBS数据记录的转换逻辑。我们采用了一种更为创新的方式来应对转换逻辑的复杂度:先从TBS中提炼出隐含的领域模型,一开始并不需要将整个TBS的领域模型完全提炼出来,只需要关注我们需要进行数据转换的部分。我们的经验是,对一个外部系统模型的充分提炼,并不要求这个系统是以面向对象的方式实现的,而这也正是在两套系统的概念模型与功能行为之间实现精准、可扩展的“转换”的关键所在
- 转换逻辑与底层的通信机制分离
- SPOT领域对象并不依赖于TBSExport,TBSExport通过观察者模式与SPOT松耦合
在完成了新的设计后,TBSExport就成为了整个软件系统中最复杂的部分,但新设计所带来的松耦合与延展性,保证了系统的可维护性。整个模型重构工程花费了4至6个团队近6个星期的时间,之后项目就进展得非常顺利,花费在开发和测试TBSExport组件上的工作量仅占了整个项目工作量不到10%的比例,比原来减少近66%。开发人员对TBSExport产生恐惧心理的日子从此一去不复返。
参考文献
1.Eric Evans, Domain-Driven Design,
Tackling Complexity in the Heart of Software(《领域驱动设计:软件核心复杂性应对之道》),
Addison-Wesley, 2003, ISBN 0-321-12521-5
2.Ying Hu and Sam Peng, So We Thought
We Knew Money(《我们原以为自己知道“货币”》注:一篇有关于值对象的论文), available
from http://www.domaindrivendesign.org/practitioner_reports/hu_ying_2007_01.html |