本文翻譯自領域驅動設計官方網站的一篇實踐性論文,原文題為《IAnticorruption – A Domain-Driven Design Approach To More Robust Integration》,我覺得這篇論文寫得很不錯,實踐性非常強,通過對一個真實項目的研究,並結合整個團隊在項目實踐上的經驗,總結了領域驅動設計在系統集成方面的指導作用:通過防腐層的引入,改善現有的系統集成架構,並引導整個項目和團隊實現可持續化發展。本文還隱喻了架構設計的重要性:合理的架構不僅能夠很好地支持項目管理(反之亦然),而且還能夠讓開發和測試朝良性化方向發展,最終獲得項目的巨大成功。為了方便大家的閱讀,我特將本文翻譯成中文發布於此,歡迎大家閱讀討論,對於英語閱讀不感到困難的朋友或者對我的翻譯質量沒有信心的朋友,可以直接點擊上面的鏈接閱讀原文(PDF格式)。
摘要
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發送信息時,它做了兩件事情:
- 創建了一個包含特定數據的DataSet
- 調用了一個定義在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產生恐懼心理的日子從此一去不復返。
感謝
感謝Heather Regehr在項目管理方面給予的支持;感謝Eric Evans對架構設計的獨特見解以及對項目開發的專業指導;感謝Daniel Gackle對項目和本文所作出的貢獻;感謝Brenda Lowe以及QA團隊的積極配合;感謝Taj Khattra、Alex Aizikovsky、George Zhu、Todd Ariss以及整個Custom House開發團隊為項目的成功所作出的貢獻。
參考文獻
- Eric Evans, Domain-Driven Design, Tackling Complexity in the Heart of Software(《領域驅動設計:軟件核心復雜性應對之道》), Addison-Wesley, 2003, ISBN 0-321-12521-5
- Ying Hu and Sam Peng, So We Thought We Knew Money(《我們原以為自己知道“貨幣”》注:一篇有關於值對象的論文), available from http://www.domaindrivendesign.org/practitioner_reports/hu_ying_2007_01.html