一直想系統的整理一下自己有關Domain Model實踐的嘗試。但總覺得自己的想法還不夠系統而作罷。
然而從另一方面看“系統的東西”也許永遠做不到,失去了目標的生活該會多乏味。
因此我決定將自己有關Domain Model設計的有關實踐和思考和盤托出,也算是拋磚引玉。歡迎大家
參與討論,遇到同你的觀點相左的地方,希望能以包容的態度來面對,我們是朝同一方向走的伙伴而不是
相互對視的敵人。:)
在深入討論之前我先拋出一些原則和概念,最后你會看到這些概念和原則的威力。
1.按照概念依賴的原則來組織業務層。
2.將業務活動(業務流程)建模成類。
3.用業務活動(業務流程)作為關聯整個業務層各種對象的骨架。
4.在業務活動中鑿出擴展點,使用不同接口分離不同性質業務對象。
5.將對象的存儲理解為業務層概念。
......
概念依賴
這是我認為能否得到良好業務層最重要的概念。
在我系統框架設計將要完成,開始涉及業務層設計時,我腦袋一片空白,書上,大家討論的大多是整個系統的結構從UI層
到服務層到數據訪問層到數據庫。到底業務層該如何組織?Martin Fowler的POEAA的書中沒有回答。找到的相關
書籍也都過于空泛。Martin Fowler的分析模式有些用處,但不夠系統。透過Martin fowler網站,我拿到了
Domain Driven Design的發行前版本。該書給了我很大的啟示。其中的要點有:
關于關聯:
1.Imposing a traversal direction (強制一個關聯的導航方向)
......
關于Responsibility Layers(業務職責層)的劃分:
作者給出了三個指導原則:Conceptual dependency.(概念依賴)為其中一項。
書中給出的描述的是業務職責層上層的對象需要通過下層對象才能在概念上完整,
相反下層對象則可獨立于上層對象存在含義。這樣天然的下層對象相對于上層對象
會更穩定。并且在今后演變的過程中,使同擴展的方式來完善系統,而不是改變對象
的方式。
通過實踐,我覺得這條原則可以應用在任何兩個有關聯的業務對象上。通??梢酝ㄟ^
概念依賴先建立一個導航方向。這能夠滿足大多數的需求。當確實需要反向導航時,
只要理由充分可以隨時加上,并且如果先前將這兩個對象放入不同包中,這時需要
將他們合并到同一個包中。
我見過一個不好的設計。Customer具有很多Flag分別標記該客戶是否掛失,凍結,注銷等等。
通常叫做客戶狀態,然而這是不對的,這違背了單一職責原則。事實上除了注銷外
掛失和凍結都不應該算作Customer的本質屬性。相反我把他們看作某種約束,進而把掛失看作
一種協議.....因為Customer的概念可以不依賴于掛失和凍結的概念,相反掛失和凍結卻要依賴
Customer的概念,應為這是他們動作的主體。
同樣的一開始就讓Customer有GetAccount的方法同樣不好。因為Customer的概念確實不依賴Account
XXXAccount卻可以有Customer的屬性,Account在概念上依賴Customer。
按照概念依賴原則我們能更好的理解業務職責層的劃分。DDD中建議了如下的職責層。
按從高到低分別為:
依賴方向
| Decision
| Policy
| Commitment
| Operation
V Potential
Potential中包括類似Customer,Employee等Party方面的類。對應支持業務。
Operation中包括了核心業務如存取款,買賣以及同這些業務關聯的Account,Product等等。
Commmitment對于客戶包括同客戶簽訂的協議等。對于員工來說包括授權等。
Policy包括計算某種收費的策略,比如電信收費的算法。對應支持業務。
Decision包括主要對應于管理業務。監控系統多在這層。
從上到下觀察,很好的遵循了概念依賴的原則。
從另一方面來看,可以根據概念隨著時間發展的順序來建立對象之間的關系。這樣會天然的滿足概念依賴原則。
后面發展起來的概念可以依賴前面的已經存在的概念,而反過來這不可。這是系統穩定的關鍵。
同客戶簽訂的各種協議可以不斷的發展,但是客戶對象是穩定的。
同理收費的策略可以變化但是最終反映到帳戶上的都只是對Balance的變更。所以收費策略比
帳戶更不穩定。
客戶對象也要比帳戶對象穩定。
從按照穩定的方向依賴的原則出發,我們可以得到對象間的單向依賴。當然也會存在雙向關聯
的對象,然而這種情況在我的實踐中不是很多。而且一旦你懂得了單向關聯的好處后,你就會
謹慎的使用雙向關聯。濫用關聯會使得整個業務層象DDD中說的,變成一大塊“果凍”,你隨便觸動
果凍某一塊,整個果凍都會顫動。
同樣為了簡化設計,對象的關系中多對多的關系盡量避免。如果可以
則通過限定角色轉化為一對多或一對一的關系。
以上是關于概念依賴的觀念,下面讓我們看看如何建模業務中的活動。
有一種做法是使用分析模型中的控制類直接映射到設計中類中。我看來這不是好的做法。
這里談談分析與設計的區別。
從分析的角度來看,業務實體總是被動的。業務是通過控制對象操作業務實體來完成的。
分析時我們是關注是什么問題。這要求我們客觀的來描述現實。
進入設計階段我們關注的是如何解決的問題??刂茖ο笫┘优c業務實體的操作加入不涉及
第三者,則這個操作可以并入被操作的實體類中。然而分析中控制對象的概念是如此的
深刻,以至于只涉及Customer的ChangePassword方法該放到哪里都成了問題。類不是
“某概念 + 所關心該概念的屬性 + 最終施加與這些屬性上的操作” 的封裝,又是什么呢?
下面的問題是如何建??缭蕉鄠€業務實體的操作?
舉個例子:銀行開戶。
現在假設開戶時涉及到下面的一些操作對象。
創建一個Customer對象。
創建一個CapitalAccount對象。
存入一定數額的現金。
記錄一筆開戶流水。
整個業務活動,我可以建模為OpenCustomerAct對象。偽碼如下:
public class OpenCustomerAct extends CustomerAct
{
...
public void override doRun()
{
Customer customer = Customer.create(...);
CapitalAccount capitalAccount = CapitalAccount.create(customer,...);
capitalAccount.deposit(...);
OpenCustomerLog.create(this);
}
...
}
所需的參數通過構造函數得到。
將所有的業務活動都建模成一個Act,這非常重要。甚至你可以在Session中放入一個Act來
表示當前正在進行的業務。所有的擴展都是從Act開始的。
假如你要對Act施加某種檢查,那么對doRun方法進行攔截可以達到該目的。
用例能夠簡化到只剩下流程,同樣道理Act也可以做到這點。
對于象RichClient的交互模式,通常只在最后才提交業務,中間的交互都是在準備提交的數據。
那么在中間調用的方法中可以只new XXXAct而不執行doRun操作。這樣做是因為中間的調用
可能會用到XXXAct來作為上下文。現在我還沒有想好在這樣的中間過程中,如何能夠觸發
植入到donRun前的檢查?或許可以創建一個空doRun的子類覆蓋掉父類實際的操作?
Act
public interface Act
{
Operator getOperator();//誰
Date getOccurDate();//在什么時間
String getOccurPlace();//什么地點
BusinessType getBusinessType();//做什么業務
ActState getActState();//業務運行的當前狀態
}
“誰在什么時間什么地點做了什么業務?!?
這描述了任何業務的基本方面。從哲學的角度來看,“我們得到了Act,我們就得到了事物的基礎”。
當我們具體的描述某項業務時,假如需要向調用方暴露特定的屬性。
我們可以隨時添加到Act的子接口中。
例如同Customer相關的Act可定義為:
public interface CustomerAct extends Act
{
Cutomer getCustomer();//針對哪個客戶
}
在復雜一點的情況下,如業務需要多人協作完成,可以通過組合模式達到目的。
public interface CompositeAct extends Act
{
Act[] getActs();
}
涉及到一段時間有中間狀態的工作流也應該可以作為Act的子接口進行擴展。
不過我沒有做過這方面的嘗試。
將Act放入Session
將Act放入Session使得可以方便得到業務運行的上下文。而且通過擴展Act。
可以從Act或其子接口中得到想得到的任何東西,這使得任何擴展都成為可能。
這里說明一下Act類的位置應當放入Potential層中,并且與Operator在一起。
因為Potential層的業務對象也需要業務活動來維護。
如果你的框架中Sesion在更基礎的包中,則可以給Act提供一個空內容的父接口,放入Session所在的包中。
public interface AbstractAct
{
}
public interface Act extends AbstractAct
{
...
}
Session提供得到AbstractAct的入口。
public class Session
{
...
static public AbstractAct getAbstractAct()
{
return Instance().abstractAct;
}
...
}
Act上的擴展點
按照分層的觀點,下層不允許依賴上層,然而業務對象卻是協作完成某個目的的。
而且只要業務對象需要維護,就需要相關的Act。
例如:銀行中的存錢業務,參考上面的分層,我們把它放入Operation層。
在存錢的業務中,我們需要檢查該客戶是否做了掛失。而掛失協議我們是放在Commitment層。
顯然,Operation層不能直接調用Commitment層的協議。
DIP模式發話了“用我”。
在Operation層中定義Commitment層接口,和一個工廠,使用反射實現這種調用。在Act中調用。
abstract public class ActImpl
extends abstractActImpl
implements Act
{
public virtual void run()
{
doPreprocess();
doRun();
doPostprocess();
}
abstract public doPreprocess();
abstract public doRun();
abstract public doPostprocess();
}
public interface CustomerCommitment
{
void affirmCanDo();
}
abstract public class CustomerActImpl
extends ActImpl
implements CustomerAct
{
...
public override void doPreprocess()
{
...
//擴展點
CustomerCommitment customerCommitment = CustomerCommitmentFactory.create(this);
customerCommitment.affirmCanDo();
...
}
...
}
public interface InnerCustomerCommitment
{
void affirmCanDo(CustomerAct customerAct);
}
public class CustomerCommitmentImpl implements CustomerCommitment
{
private CustomerAct customerAct;
public CustomerCommitmentImpl(CustomerAct customerAct)
{
this.customerAct = customerAct;
}
public void affirmCanDo()
{
...
//通過配置得到該customerAct對應需要檢查的客戶約束,包括協議,逐一檢查。
DomainObjectCollection commitmentTypes = CustomerCommimentRepository.findByBusinessType(customerAct.getBusinessType());
...
foreach( CommitmentType typeItem in commitmentTypes )
{
InnerCustomerCommitment commitment = getCommitment(typeItem);
commitmentItem.affirmCanDo(customerAct);
}
...
}
}
public class CustomerLostReportAgreementChecker implements InnerCustomerCommitment
{
public void affirmCanDo(CustomerAct customerAct)
{
Check.require(customerAct.getCustomer() != null,"客戶不存在");
CustomerLostReportAgreement customerLostReportAgreement =
CustomerLostReportAgreementRepository.find(customerAct.getCustomer());
if(customerLostReportAgreement != null)
{
agreement.affirmCanDo(customerAct);
}
}
}
public class CustomerLostReportAgreement
{
...
public void AffirmCanDo(CustomerAct customerAct)
{
if(customerAct.getOccurDate <= expiringDate)
throw new CustomerLossReportedException(customer);
}
...
}
同樣道理,可以對其他上層的對象使用DIP使依賴倒置。
比如:電信計算費用。就可以通過在CustomerAct的doRun中插入擴展點來實現。
這樣復雜的計費算法就被封裝在接口之后了??梢苑峙淞硗獾娜藛T來開發。
業務活動的流程仍然清晰可見。
是啊,這正是接口的威力,大多數的設計模式不也是基于這種原理嗎?
還有在Act上的擴展點可以分為兩類,顯式的和隱式的。
電信費用的計算就是顯式的,因為CustomerAct需要知道計算的結果,用來從帳戶中扣除金額。
而檢查掛失協議是隱式的,CustomerAct可以對此一無所知。
通過在Act上的擴展點,我們可以向上擴展。
這仿佛是在樹枝上種木耳,呵呵。
DIP VS Facade
對于上面的情況,另外一種方法是使用Facade。
讓我們比較一下兩者。
簡要說明一下Facade的做法:
abstract public class CustomerActImpl
extends ActImpl
implements CustomerAct
{
...
public override void doPreprocess()
{
...
//注意:這里傳遞的參數,會使得用Facade方式的人大傷腦筋。
//按照掛失的要求目前傳遞getBusinessType(),getCustomer(),getOccurDate()就夠了
//但是對于所有的CustomerCommitment這些參數就不一定夠了。
//比如:客戶可能簽訂指定員工協議。(指只允許協議中指明的員工能操作的業務)
//那么該接口需要添加getOperator()參數。
//接口變得不穩定。
CustomerCommitmentManager.affirmCanDo(getBusinessType(),getCustomer(),getOccurDate(),?,...);
...
}
...
}
Facade可以使得在Act中也是只提供一個調用點,但是因為不是依賴倒置的關系,不得不顯示的說明需要用到的參數。
相反使用DIP模式,接口中定義的是Act的接口,而Act是可以擴展的。(是否擴展全部看上層的對象是否需要)。
而正是因為相應的CustomerCommitment總是處于需要檢查的XXXAct的上層。這樣具體的CustomerCommitment
總是可以依賴XXXAct。因此可以獲得任何想要得到的信息。
同樣對于電信計算費用的例子,因為傳遞的參數是CustomerAct接口。所以對于今后任何可能的擴展該接口都是不會變化的。
能夠做到這一點,完全要歸功于將計算費用放入Operation的上層Policy中,你能體會到其中的要領嗎?
形象一點來說,使用DIP模式,采取的是一種專家模式。
DIP的Act說的是:“CustomerCommitment你看看我現在的情況,還能運行嗎?”
相反Facade模式,則是令人厭煩的嘮叨模式。
Facade的Act說的是:“CustomerCommitment,現在執行的客戶是XXX,業務是XXX,時間是XXX,...你能告訴我還能運行下去嗎?”
顯然DIP要瀟灑得多。
實現接口 VS 繼承父類
這里稍稍偏離一下主題,討論一下接口同繼承的問題。
什么時候使用接口?什么時候使用繼承?
這似乎是個感覺和經驗問題。或者我們會傾向于多使用接口,少使用繼承。
可不可以再進一步呢?
以下是我的觀點:
“接口是調用方要求的結果,而繼承則是實現方思考的產物?!?
畢竟如果我們定義的接口沒有被用到,那它就沒有任何用處。
接口的目的在于制定虛的標準,從而使調用方不依賴于實現方。
而繼承某個父類則多半是基于“偷懶“的考慮,已經存在的東西,我為什么不利用一下?
當然這樣說是忽略了繼承的真正用意--單點維護。
所以在定義XXXAct的接口時,需要多考慮一下,上層對象需要Act中的提供什么特性,會如何使用它。
接口屬于調用方。
業務對象的持久化
一個會引起爭議的問題,是業務層是否會涉及業務對象持久化的概念。
答案是肯定的。
DDD中在描述The life cycle of a domain object時,給出了兩種形式的持久化。
Store和Archive。我們使用的較多是Store。
但是這不代表業務層要依賴數據訪問層。相反依賴關系應該倒過來。數據訪問層依賴
業務層。通常我們使用Mapper實現,在hibernate中通過配置達到該目的。
要做到業務層不依賴于數據訪問層,同樣借助接口來完成。
在業務層定義數據訪問的接口,為了方便,可以使用一個類來封裝這些操作。
public interface CustomerFinder
{
Customer findByID(ID id);
Customer findByCode(String code);
DomainObjectCollection findByName(String name);
...
}
public class CustomerRepository
{
private static CustomerFinder finder = null;
private static CustomerFinder getFinderInstance()
{
if (finder == null)
{
finder = (CustomerFinder)FinderRegistry.getFinder("CustomerFinder");
}
return finder;
}
public static Customer findByID(ID id)
{
Customer obj = getFinderInstance().findByID(id);
Check.require(obj != null,
"未找到ID為:" + id.toString() +
"對應的 Customer。");
return obj;
}
...
}
在數據訪問層實現這些接口。因為是數據訪問層依賴業務層,所以你可以采用多種技術來實現,
使用hibernate這樣的開源項目,或者手工編寫Mapper。
ID id
另外一個有爭議的問題是Domain層是否要引入與業務無關的ID來標識不同的對象呢?
我的經驗是在業務層引入ID的概念會使很多事情變得方便些。
如:Lazyload。
這是否不屬于業務的范疇?是在概念上不屬于業務。但在業務上
不是沒有對應的概念。
例如:保存客戶定購信息的訂單,作為標識的就是訂單號,這是給人使用的。
在使用電腦后,我們也給對象一個它能理解的統一標識,這就是ID。
另外不要使用業務上的概念作為主鍵和外鍵,因為它們本來就不是數據庫的概念。
否則,會使得業務概念同數據庫的概念混淆起來。
ID的使用通常會選擇效率較高的long類型。
不過我們的實現走得更遠,我們將其封裝為ID對象。
Service層
現在我們向上看看將業務層包裹的服務層。
服務層是架設在應用層和業務層的橋梁,用來封裝對業務層的訪問,因此
可以把服務層看作中介,充當兩個角色:
1.實現應用層接口要求的接口;
2.作為業務層的外觀。
服務層的典型調用如下:
public interface CustomerServices
{
void openCustomer(CustomerInfo cutomerInfo);
void customerLostReport(String customerCode,Date expiringDate,String remark);
CutomerBasicInfo getCutomerBasicInfo(String customerCode);
...
}
public class CustomerServicesImpl
extends ServiceFacade
implements CustomerServices
{
...
public void openCustomer(CustomerInfo cutomerInfo)
{
try
{
init();
OpenCustomerAct openCustomerAct =
new OpenCustomerAct(customerInfo.name,
customerInfo.code,
customerInfo.address,
customerInfo.plainpassword
...
);
openCustomerAct.run();
commit();
}
catch(Exception e)
{
throw ExceptionPostprocess(e);
}
}
public void customerLostReport(String customerCode,Date expiringDate,String remark)
{
try
{
Check.require(customerCode != null && customerCode != "",
"無效的客戶代碼:" + customerCode);
init();
CustomerLostReportAct customerLostReportAct =
new CustomerLostReportAct(customerCode,
expiringDate,
remark);
customerLostReportAct.run();
commit();
}
catch(Exception e)
{
throw ExceptionPostprocess(e);
}
}
public CutomerBasicInfo getCutomerBasicInfo(String customerCode)
{
try
{
Check.require(customerCode != null && customerCode != "",
"無效的客戶代碼:" + customerCode);
init();
Customer customer = CustomerRepository.findByCode(customerCode);
//這里選擇的是在CustomerRepository外拋出CustomerNotFoundException異常,
//另一種方法是在CustomerRepository中拋出CustomerNotFoundException異常。
//因為CustomerRepository在于通過客戶代碼查找對應的客戶。至于是否應該拋出
//異常則交給業務層或服務層來處理。
//這里有很微妙的區別,拋出CustomerNotFoundException應該是誰的職責呢?
//你的想法是什么?
if(customer == null)
throw new CustomerNotFoundException(customerCode);
CutomerBasicInfo cutomerBasicInfo = CutomerBasicInfoAssembler.create(customer);
return cutomerBasicInfo;
}
catch(Exception e)
{
throw ExceptionPostprocess(e);
}
}
...
}
服務層的代碼很簡單,不是嗎?
上面的代碼可以通過AOP進一步的簡化。使用AOP實現我希望代碼象下面這樣簡單。
public class CustomerServicesImpl
implements CustomerServices
{
...
public void openCustomer(CustomerInfo cutomerInfo)
{
OpenCustomerAct openCustomerAct =
new OpenCustomerAct(customerInfo.name,
customerInfo.code,
customerInfo.address,
customerInfo.plainpassword
...
);
openCustomerAct.run();
}
public void customerLostReport(String customerCode,Date expiringDate,String remark)
{
Check.require(customerCode != null && customerCode != "",
"無效的客戶代碼:" + customerCode);
CustomerLostReportAct customerLostReportAct =
new CustomerLostReportAct(customerCode,
expiringDate,
remark);
customerLostReportAct.run();
}
public CutomerBasicInfo getCutomerBasicInfo(String customerCode)
{
Customer customer = CustomerRepository.findByCode(customerCode);
if(customer == null)
throw new CustomerNotFoundException(customerCode);
CutomerBasicInfo cutomerBasicInfo = CutomerBasicInfoAssembler.create(customer);
return cutomerBasicInfo;
}
DTO or Not
我認為是否使用DTO取決于項目的大小,開發團隊的結構,以及對項目演變預期的評估結果。
不使用DTO而直接使用PO傳遞到應用層適用于一個人同時負責應用層和業務層的短期簡單項目;
一旦采用該模式作為構架,我不知道業務層是否還能叫做面向對象。
原因如下:
1.使用PO承擔DTO的職責傳遞到應用層,迫使PO不能包含業務邏輯,這樣業務邏輯會暴露給應用層。
業務邏輯將由類似于XXXManager的類承擔,這樣看來似乎PO有了更多的復用機會,因為PO只包含getXXX同setXXX類似的屬性。
然而這正類似面向過程模式的范例,使用方法操作結構,程序多少又回到了面向過程的方式。
2.將PO直接傳遞到應用層,迫使應用層依賴于業務層,如果一個人同時負責應用層和業務層那么問題不大;
如果是分別由不同的人開發,將使得應用層開發人員必須了解業務層對象結構的細節,增加了應用層開發人員的知識范圍。
同時因為這種耦合,開發的并行受到影響,相互交流增多。
3.此外這也會使得業務層在構建PO時要特別小心,因為需要考慮傳遞到應用層效率問題,在構建業務層時需要
考慮應用層的需要解決的問題是不是有些奇怪?
有人會抱怨寫XXXAssember太麻煩,我的經驗是XXXAssembler都很簡單。
我們使用手機,會發現大多數手機提供給的接口都是相同的,這包括0-9的數字鍵,綠色的接聽鍵,紅色的掛機鍵,還有一塊顯示屏。
無論我是拿到NOkIA,還是MOTO的手機,我都能使用,作為手機使用者我沒有必要知道手機界面下的結構,不用關心
使用的是SmartPhone還是Symbian。
確實,應用層將服務層和業務層看作黑箱要比看作白箱好得多。