【領域驅動設計實踐之路】往期精彩文章:

這是“領域驅動設計實踐之路”系列的第三篇文章,分析瞭如何設計聚合。聚合這個概念看似很簡單,實際上有很多因素導致我們建立不正確的聚合模型。本文對這些問題逐一進行剖析。

聚合這個概念看似很簡單,實際上有很多因素導致我們建立不正確的聚合模型。一方面,我們可能爲了使用上的一時便利將聚合設計得很大。另一方面,因爲邊界、職責的模糊性將一些重要的方法放在了其他地方進而導致業務規則的泄露,沒有達到聚合對業務邊界的保護目的。在開始聚合之前,我們要區分清楚“實體Entity”“值對象Value Obj”的區別,並且要重視“值對象Value Obj”的真正價值。

(圖片來源於網絡

一、實體(Entity) OR 值對象(Value Obj)

領域驅動設計裏面有兩個重要的概念,“實體Entity”“值對象Value Obj”。很多人講解時候會舉類似這樣的例子:用戶在某電商平臺下單,其收貨地址爲“XX市YY街道ZZ園區”。現實場景中多個用戶的收貨地址有可能是同一個,所以會把地址建模成Value Obj,藉此把Value Obj簡單解釋成“描述性的、不變的東西,比如地址”。這樣的解釋似乎也能說明問題,但是我覺得還沒有深入到本質去探究、容易忽略Value Obj的真正要義。

1、實體Entity

一些對象不僅僅是由它們的屬性定義組成的,我們更關心其延續生命週期內經歷的不同狀態階段,這是我們業務域的核心。我們出於追蹤的目的,需要給每一個實體設置唯一標識。通常的,我們也會將其持久化到數據庫中,實體即表裏的一行記錄。因此,當我們需要考慮一個對象的個性特徵,或者需要區分不同的對象時,我們引入實體這個領域概念。一個實體是一個唯一的東西,並且可以在相當長的一段時間內持續地變化。我們可以對實體做多次修改,故一個實體對象可能和它先前的狀態大不相同。但是,由於它們擁有相同的身份標識(identity),它們依然是同一個實體。對於某電商平臺而言,一個個的用戶就是實體,我們要對他們加以區別並且持續的關注他們的行爲。

實體有特殊的建模和設計思路。它們具有生命週期,這期間它們的形式和內容可能發生根本改變,但必須保持一種內在的連續性,即全局唯一的id。它們的類定義、職責、屬性和關聯必須由其標識來決定,而不依賴於其所具有的屬性。即使對於那些不發生根本變化或者生命週期不太複雜的實體,也可以在語義上把它們作爲實體來對待,這樣可以得到更清晰的模型和更健壯的實現。當然,軟件系統中的大多數實體可以是任何事物,只要滿足兩個條件即可,一是它在整個生命週期中具有連續性,二是它的區別並不是由那些對用戶非常重要的屬性決定的。根據業務場景的不同,實體可以是一個人、一座城市、一輛汽車、一張彩票或一次銀行交易。

跟蹤實體的標識是非常重要的,但爲其他所有對象也加上標識會影響系統性能並增加分析工作,而且會使模型變得混亂,因爲所有對象看起來都是相同的。軟件設計要時刻與複雜性做鬥爭,我們必須區別對待問題,僅在真正需要的地方進行特殊處理。比如在上面的例子中,我們把收貨地址“XX市YY街道ZZ園區”建模成具有唯一標識的實體,那麼三個用戶就會創建三個地址,這對於系統來說完全沒有必要甚至還會導致性能或者數據一致性問題。

2、值對象Value Obj

當我們只關心一個模型元素的屬性時,應把它歸類爲值對象。我們應該使這個模型元素能夠表示出其屬性的意義,併爲它提供相關功能。值對象應該是不可變的;不要爲它分配任何標識,而且不要把它設計成像實體那麼複雜。即描述了領域中的一些屬性,比如用戶的名字、聯繫方式。當然也會存在一些複雜的描述信息,其本身可能就是一個對象,甚至是另一個實體概念。

在前述的電商例子中地址是一個值對象。但在國家的郵政系統中,國家可能組織爲一個由省、城市、郵政區、街區以及最終的個人地址組成的層次結構。這些地址對象可以從它們在層次結構中的父對象獲取郵政編碼,而且如果郵政服務決定重新劃分郵政區,那麼所有地址都將隨之改變。在這裏地址是一個實體。

在電力運營公司的軟件中,一個地址對應於公司線路和服務的一個目的地。如果幾個室友各自打電話申請電力服務,公司需要知道他們其實是住在同一個地方,因爲我們真實服務的是用戶所在地方的電力資源,在這種情況下,我們會認爲地址是一個實體。但是隨着思考的深入,我們發現可以換種方式,抽象出一個電力服務模型並與地址關聯起來。通過這樣的設計以後,我們發現真正的實體是電力服務,地址不過是一個具有描述性的值對象而已。

在房屋設計軟件中,可以把每種窗戶樣式視爲一個對象。我們可以將“窗戶樣式”連同它的高度、寬度以及修改和組合這些屬性的規則一起放到“窗戶”對象中。這些窗戶就是由其他值對象組成的複雜值對象,比如圓形天窗、1m規格平開窗、狹長的哥特式客廳窗戶等等。對於“牆”對象而言,所關聯的“窗戶”就是一個值對象,因爲僅僅起到描述的作用,“牆”不會去關心這個窗子昨天是什麼樣,以至於當我們覺得這個窗戶不合適的時候直接用另外一個窗戶替換即可。

歸根結底,我們使用這個窗戶對象來描述牆的窗戶屬性。但是在該房屋設計軟件的素材系統中,它的主要職責就是管理窗戶這一類的附屬組件,那麼對它而言窗戶就是一個鮮活的實體。從這個例子中我們可以看出,所屬業務域很重要,這也就是我們之前所講述的上下文,即同一對象在不同上下文中是不一樣的。

當你決定一個領域概念是否是一個值對象時,你需要考慮它是否擁有以下特徵:

  • 它度量或者描述了領域中的某個概念屬性;

    當你的模型中的確存在一個值對象時,不管你是否意識到,它都不應該成爲你領域中的一件東西,而只是用於度量或描述領域中某件東西的一個概念。一個人擁有年齡,這裏的年齡並不是一個實在的東西,而只是作爲你出生了多少年的一種度量。一個人擁有名字,同樣這裏的名字也不是一個實在的東西,而是描述瞭如何稱呼這個人。

  • 它可以作爲不變量;

    值對象可能會被共享,所以具有不變性,即調用方不能對其執行set操作。

  • 它將不同的相關的屬性組合成一個概念整體;

    一個值對象可以只處理單個屬性,也可以處理一組相關聯的屬性。在這組相關聯的屬性中,每一個屬性都是整體屬性所不可或缺的組成部分,這和簡單地將一組屬性組裝在對象中是不同的。如果一組屬性聯合起來並不能表達一個整體上的概念,那麼這種聯合並無多大用處。比如貨幣與單位、幣種應該是一個整體概念,否則很難明白12到底代表什麼意思?12美分還是12元RMB。

  • 當度量和描述改變時,可以用另一個值對象予以替換;

    比如隨着時間推移,用戶年齡從21歲變成22歲,即22替換21。

二、聚合(Aggregate)

每個對象都有生命週期,對象自創建後可能會經歷各種不同的狀態,要麼被暫存、要麼刪除直至最終消亡。當然,很多對象是簡單的臨時對象,僅通過調用構造函數來創建,用來做一些計算,而後由垃圾收集器回收。這類對象沒必要搞得那麼複雜。但有些對象具有更長的生命週期,其中一部分時間不是在活動內存中度過的。它們與其他對象具有複雜的相互依賴性。它們會經歷一些狀態變化,在變化時要遵守一些固定規則。管理這些對象時面臨諸多挑戰,稍有不慎就會把自己帶入一個大泥坑。

減少設計中的關聯有助於簡化對象之間的遍歷,並在某種程度上限制關係的急劇增多。但大多數業務領域中的對象都具有十分複雜的聯繫,以至於最終會形成很長、很深的對象引用路徑,我們不得不在這個路徑上追蹤對象。在某種程度上,這種混亂狀態反映了現實世界,因爲現實世界中就很少有清晰的邊界。但這卻是軟件設計中的一個重要問題,幸而我們可以藉助“聚合”來應對。

首先,我們需要用一個抽象來封裝模型中的引用。聚合就是一組相關對象的集合,我們把它作爲數據修改的單元。每個都有一個根(root)和一個邊界(boundary)。邊界定義了聚合內部都有什麼。根則是聚合所包含的一個特定實體。對聚合而言,外部對象只可以引用根,而邊界內部的對象之間則可以互相引用。除根以外的其他實體都有本地標識,但這些標識只在聚合內部才需要加以區別,因爲外部對象除了根之外看不到其他對象。

三、一些關於聚合的實踐

關於聚合、實體的概念已經描述清楚了,下面我打算藉助一個例子來繼續深入探討聚合的相關知識。

案例:汽車模型設計

約束:首先一輛汽車在車輛登記機構歸屬於唯一一個人或者企業主體(實際上企業也具有法人,所以即使是企業主體也可以找到對應的歸屬人);其次,正如大家所常見的,我們探討是目前技術所能實現的、且普遍流行的車輛結構,一輛車具有4個輪子、一個引擎;

1、業務邊界

Car、Customer很自然的按照實體進行對待;發動機作爲一個產品交付時候有唯一序列號,考慮到其可能的特性我們姑且也視其爲實體;因爲有4個輪子,可能需要進行區分所以也被視爲實體。綜上可知,我們先把4個對象都當做實體。因爲是建模汽車相關業務,所以我們把Car視爲根。至此,我們得到了一個強大的聚合,包含車輪、引擎以及所屬人信息。

public class Car {

private Customer customer;

/**

* WheelPositionEnum枚舉標識輪子狀態

* FR FL BR BL依次標識前右、前左、後右、後左輪

* 在聚合內部保持獨立

*/

private Map<String, Wheel> wheels;

private Engine engine;

//其他屬性暫略

}

(滑動可查看

當我們分析出聚合以後,事情還沒有結束。聚合表達的是業務,那麼業務的規則、約束如何來保證呢?

  • 根ENTITY即Car具有全局標識,它最終負責檢查固定規則。

  • 根ENTITY具有全局標識。邊界內的ENTITY具有本地標識,這些標識只在從聚合內部纔是唯一的,比如上面的車輪集合。

  • 刪除操作必須一次刪除AGGREGATE邊界之內的所有對象。(利用垃圾收集機制,這很容易做到。由於除根以外的其他對象都沒有外部引用,因此刪除了根以後,其他對象均會被回收。)我們可以想象,當汽車不存在的時候,我們更不會去關心其車輪情況,“皮之不存毛將焉附”。

  • AGGREGATE外部的對象不能引用除根ENTITY之外的任何內部對象。即我們不可能先獲取到車輪對象,然後去反向獲取Car對象,這樣就等於建立了Car、Wheel的雙向關聯並且對調用方而言會很困惑。我什麼情況下可以直接使用Wheel、何時可以直接使用Car,這是系統走向腐敗的第一步。

現在我們看下代碼實現,Car具有全局唯一id用以區分不同對象;且負責約束的檢查,比如是否具有4個輪子、是否有一個引擎,否則不能正常使用。也許我們日常開發中的做法是調用方獲取到一個Car實例以後,去校驗這些規則是否滿足,這樣做的問題就是業務規則的泄露。

public Car getCar(Long id) {

Car car = carRepostory.ofId(id);

if (car.getEngine() == null ||

car.getWheels().keySet().size() != SPECIFIC_WHEEL_SIZE) {

throw new CarStatusException(id);

}

return car;

}

/**

*上述代碼存在的問題,畢竟現實中有報廢、廢棄的Car

*1.命名getCar實際上進行了狀態檢查,命名與實際語義不符;

*2.Car的狀態約束泄露到調用方;

*3.雖然面向流程寫出的是可以工作的代碼,但我們更推薦

* 面向領域的封裝代碼;

**/

public Car getWorkableCar(Long id) {

Car car = carRepostory.ofId(id);

//業務約束由Car自己承擔

if (!car.workable()) {

throw new CarStatusException(id);

}

return car;

}

(滑動可查看

2、警惕性能問題

在具有複雜關聯的模型中,要保證對象更改的一致性是很困難的。不僅互不關聯的對象需要遵守一些固定規則,而且緊密關聯的各組對象也要遵守一些固定規則。然而,過於謹慎的鎖定機制又會導致多個用戶之間毫無意義地互相干擾,從而使系統不可用。引用自《領域驅動設計》P82。

在上面的模型中,Engine被視爲Car聚合內的一個實體,這就意味着要對Engine做修改必須先擁有Car所有權。現在我們遇到一個需求:發動機製造商突然發現其交付的產品存有安全隱患,需要跟蹤運行效果以及通過網絡進行補丁安裝。

(1)如何解決爭用問題?

Car對象自身對Engine存有一些寫的邏輯,比如更新發動機的使用情況;發動機製造商也要對Engine做一些升級。這裏面可能有一些業務限制,比如發動機升級期間不提供對外服務,這裏面爲了規避併發可能要進行一些加鎖操作,這就會導致性能問題。

(2)如何解決效率問題?

製造商不能直接獲取到Engine對象,因爲對外部而言擁有Car實例纔能有渠道去獲得Engine實例。這就導致了效率問題,因爲製造商不得已只能去遍歷所有Car實體。

因此我們考慮把發動機作爲一個單獨的業務域,Car聚合裏面只需要記錄EngineId。無論是發動機的運行數據或者發動機的監控、升級等操作,都由發動機自己負責。同時因爲Car聚合記錄了EngineId,必要的情況下我們可以方便的從EngineRepository中獲得Engine對象,這也算是做到了懶加載。可以想象,系統中假如存在千萬級別的Car實例,按照最初的方案就會有千萬級別的Engine對象,但是我相信並不是每一次對Car實例的調用都需要獲取其Engine信息,這就造成了大量的內存消耗。相對於最初的方案,我們的聚合或更小,也更靈活。

public class Car {

private Customer customer;

private Map<String, Wheel> wheels;

//我們構造單獨的Engine聚合。

//此處只記錄EngineId,需要時候再去獲取實例。懶加載。

//從實體轉爲值對象

private String engineId;

//......

}

(滑動可查看

在聚合中,如果你認爲有些被包含的部分應該建模成一個實體,此時你該怎麼辦呢?首先,思考一下,這個部分是否會隨着時間而改變,或者該部分是否能被全部替換。如果可以全部替換,那麼請將其建模成值對象,而非實體。有時,建模成實體也是有必要的。但是很多情況下,許多建模成實體的概念都可以重構成值對象。聚合的內部建模成值對象有很多好處的。根據你所選用的持久化機制,值對象可以隨着根實體而序列化,比如我們可以把EngineId和Car一起存放;而實體則需要單獨的存儲區域予以跟蹤,此外實體還會帶來某些不必要的操作,比如我們需要對多張表進行聯合查詢。但是對單張表進行讀取要快得多,而使用值對象也更加方便與安全。再者由於值對象是不變的,測試起來也相對簡單。

在實際項目中,即使沒有併發鎖、沒有大事務,我們依然還會遇到寫操作性能問題。Car被廢棄處理以後,我們可能不僅僅是更新對應數據庫記錄信息。我們還需要在車輛登記機構進行銷戶操作;對應的車輪、發動機相關的數據記錄如何處理等等。如果你指望一個方法體裏面處理完這些邏輯,我敢保證你的代碼響應時間會非常之久,甚至導致“汽車報廢”業務不可用。因此我們要去思考這個過程,哪些是核心邏輯,哪些允許一定的時延,對複雜的邏輯進行異步處理。比如:我們發佈CarAbandonedEvent進而由相應的handler去處理後續的業務規則。

3、值對象-無副作用

值對象的方法應該被設計成一個無副作用函數,即只用於生成輸出而不會修改對象的狀態。對於不變的值對象而言,所有的方法都必須是無作用的函數,因爲它們不能破壞值對象的屬性值才能安全的被共享。我們要意識到值對象絕不僅僅是一個屬性容器,其真正的強大特性“無副作用函數”。比如上面的窗戶對象,當其被實例化出來以後各個屬性就不能被肆意修改了,我們通用的做法是在構造方法裏面進行賦值或者基於工廠方法獲得,總之千萬拒絕提供public的set方法,因爲你不知道哪個小夥伴在你不知情的情況setBomb。當管理窗戶的附屬資源系統進行升級,可能導致某低版本的窗戶對象不可用時候只需要對系統發送一個WindowsUpgradedEvent,進而由各個業務方去檢查是否替換使用新的窗戶對象。

一個值對象允許對傳入的實體對象進行修改嗎?如果值對象中的確有方法會修改實體對象,那麼該方法還是無副作用的嗎?該方法容易測試嗎?因此,如果一個值對象方法將一個實體對象作爲參數時,最好的方式是,讓實體對象使用該方法的返回結果來修改其自身的狀態。

比如某車輛養護機構提供噴繪功能,用戶基於三原色自由組合自己喜愛的顏料。我們定義了Paint對象,其顏色由red、yellow、blue構成。在這裏“顏色”是一個非常重要的概念。你可以想象某種網紅流行顏色必然會被大家追捧,在這段期間頻繁地被系統創建出來。通過前面的論述,我們試着顯示定義PigmentColor專門用於三原色的管理。其本身也會作爲一個值對象被Paint使用。

public class Paint {

private PigmentColor pigmentColor;

private Double volume;

//一定量的顏料A可以與其他顏料混合配比使用,那麼我們可能定義一個mixedWith方法

//還有一個疑問就是混合後的Paint對象到底是不是原來的?

public void mixedWith(Paint anotherPaint){

//1.add volume

//2.顏料混合

//3.then, but...who am I

}

}

(滑動可查看

把PigmentColor分離出來之後,確實比先前表達了更多信息,但混合計算的邏輯該怎麼實現也是一個頭疼的事情。當把顏色數據移出來後,與這些數據有關的行爲也應該一起移出來。但是在做這件事之前,要注意PigmentColor是一個值對象,因此應該是不可變的。當我們混合調配時,Paint對象本身被改變了,它是一個具有生命週期的實體。相反,表示基個色調(棕色、黑色、白色)的PigmentColor則一直表示那種顏色。Paint的結果是產生一個新的PigmentColor對象,用於表示新的顏色。

public class PigmentColor {

//mixedwith作爲值對象的無副作用方法,返回一個新的對象由調用方決定是否使用。

public PigmentColor mixedwith(PigmentColor otherPigment, Double ratio) {

//混合的邏輯

return 新的PigmentColor對象;

}

}

/**

*

* 如果一個操作把邏輯或計算與狀態改變混合在一起,那麼我們

* 就應該把這個操作重構爲兩個獨立的操作。

* 邏輯計算可以視爲命令,我們對於結果的獲取視爲查詢。這也

* 符合命令查詢分離的原則。

*/

public class Paint {

public void mixedwith(Paint other) {

this.volume += other.getVolume();

Double ratio = other.getVolume() / this.volume;

//用新返回的顏料對象替換當前的顏料對象,

//通過可以替換的值對象維護Paint實體的完整性。

this.pigmentColor =

this.pigmentColor.mixedwith(other.getPigmentColor(), ratio);

}

}

(滑動可查看

4、聚合的構造與保存

當創建一個對象或創建整個AGGREGATE時,如果創建工作很複雜,或者暴露了過多的內部結構,則可以使用FACTORY進行封裝。就好比我們不可能讓調用方來構造我們的Car聚合,因爲調用方並不知道我們WheelPositionEnum與Wheel的映射關係,不知道如何去構造Wheel信息。複雜的對象創建是領域層的職責,無論是實體、值對象,其創建過程本身就是一個主要操作,有時候被創建的對象自身並不適合承擔複雜的裝配操作。將這些職責混在一起可能產生難以理解的拙劣設計,好比我們的Car必然不是自己生產出來的,而是產自於某個“工廠”。

我們應該將創建複雜對象的實例和AGGREGATE的職責轉移給單獨的對象,提供一個封裝所有複雜裝配操作的接口。在創建AGGREGATE時要把它作爲一個整體,並確保它滿足固定規則。我們可以視其爲“工廠FACTORY”。FACTORY有很多種設計方式,包括FACTORY METHOD(工廠方法)、ABSTRACT FACTORY(抽象工廠)和BUILDER(構建器)。

這裏要強調的是,BUILDER(構建器)也是我們常用的一種工廠方法。我們可以對Car聚合設計一個工廠方法buildWheels,其接受必須要的參數進而轉換爲滿足業務規則的映射關係。這裏面更重要的是業務約束的檢查,每個創建方法都是原子的,而且要保證被創建對象或AGGREGATE的所有固定規則。在生成ENTITY時,這意味着創建滿足所有固定規則的整個AGGREGATE,但在創建完成後可以向聚合添加可選元素。在創建不變的VALUE OBJECT時,這意味着所有屬性必須被初始化爲正確的最終狀態。如果FACTORY通過其接口收到了一個創建對象的請求,而它又無法正確地創建出這個對象,那麼它應該拋出一個異常,或者採用其他機制,以確保不會返回錯誤的值。

很多場景中,聚合被創建出來以後其生命週期會持續一段時間。我們在稍後的代碼裏面仍舊需要使用,考慮到複雜聚合的生成過程比較繁瑣,所以我們有必要找到一個地方將這些還需要使用的聚合“暫存”起來。否則我們就需要時刻把這些聚合當做參數進行傳遞。爲每種需要全局訪問的對象類型創建一個“容器”即REPOSITORY,並通過一個衆所周知的全局接口來提供訪問。提供添加和刪除對象的方法,用這些方法來封裝在數據存儲中實際插入或刪除數據的操作。提供根據具體條件來挑選對象的方法,並返回屬性值滿足查詢條件的對象或對象集合,從而將實際的存儲和查詢技術封裝起來。只爲那些確實需要直接訪問的AGGREGATE根提供REPOSITORY。讓客戶始終聚焦於模型,而將所有對象的存儲和訪問操作交給REPOSITORY來完成。

5、展示聚合

首先我們應該明確DDD裏面有清晰嚴格的“層”概念,通常情況下展示層需要的信息會分散在多個聚合裏面,但是每個聚合裏面也有一些本次展現所不需要的信息;而每一個聚合可能又是有幾個數據庫實體記錄構成的。這就導致了一個展示對象涉及了多次數據庫查詢且存在多次數據對象的轉換。這也許會成爲你的吐槽點。

但可能有些讀者會選擇直接在數據結構中使用業務實體對象(即在展示層、數據庫設計時候也使用領域層聚合)。畢竟,業務實體與請求/響應模型之間有很多相同的數據。但請一定不要這樣做!這兩個對象存在的意義是非常不一樣的。隨着時間的推移,這兩個對象會以不同的原因、不同的速率發生變更。所以將它們以任何方式整合在一起都是對共同閉包原則(CCP)和單一職責原則(SRP)的違反。總有一天,當你想要重新設計底層存儲時候會導致展示層的問題;或者迫於展示層的需求去修改底層的表結構。

針對一開始的吐槽,我們可以藉助懶加載去避免不必要的查詢以及轉換;還可以把一些常用的數據緩存起來。但如果使用redis一類的內存數據庫時候,要考慮對象的序列化消耗。因爲如果把一個層級較深、比較複雜的大聚合緩存在redis中,在高頻讀取的情況下序列化也會令你抓狂。在這樣的情況下,我們可能需要重新設計緩存結構,儘可能接近於viewObj.setAttribute(redis.getXXX())。很大程度上,對象之間的轉換可能不能完全避免,所以我們要綜合考慮以上幾種因素去權衡實踐。

6、不要拋棄領域服務

很多人認爲DDD中的聚合就是在與貧血模型做抗爭,所以在領域層是不能出現“service”的,這等於是破壞了聚合的操作性。但有些重要的領域操作無法放到實體或值對象中,這當中有些操作從本質上講是一些活動或動作,而不是對象。比如我們的身份認證、支付轉賬業務,我們很難去抽象一個金融對象去協調轉賬、收付款等業務邏輯;有時候我們也不太可能讓對象自己執行auth邏輯。因爲這些操作從概念上來講不屬於任何業務對象,所以我們考慮將其實現成一個service,然後注入到業務領域或者說是業務域委託這些service去實現某些功能。

//AuthenticationService註冊到了DomainRegistry

UserDescriptor userDescriptor = DomainRegistry

.authenticationService()

.authenticate(userId, password);

(滑動可查看

以上方式是簡單的,也是優雅的。客戶端只需

要獲取到一個無狀態的AuthenticationService,然後調用它的authenticate()方法即可。這種方式將所有的認證細節放在領域服務中,而不是應用服務。在需要的情況下,領域服務 可以使用在何領域對象來完成操作,包括對密碼的加密過程。客戶端不需要知道任何認證細節。此時,通用語言也得到了滿足,因爲我們將所有的領域術語都放在了身份管理這個領域中,而不是一部分放在領域模型中,另一部分 放在客戶端中。

AuthenticationService和那些與用戶身份相關的業務定義在相同的package中,但對於該接口的實現類,我們可以選擇性地將其存放在不同的地方。如果你正使用依賴倒置原則或六邊形架構,那麼你可能會將這個多少有些技術性的實現類放置在領域模型之外的某個設施層。

那麼我們來總結一下,以下幾種情況我們可以使用領域服務來實現:

  • 執行一個顯著的業務操作過程;

  • 對領域對象進行轉換;

  • 以多個領域對象作爲輸入進行計算,結果產生一個值對象;

7、再談命名

類以及函數的命名一直以來都是令人困惑的話題,根因在於它說起來很簡單,但要做好確實太難了。試想一下如果開發人員爲了使用一個組件而必須要去研究它的實現,那麼就失去了封裝的價值。當某個人開發的對象或操作被別人使用時,如果使用這個組件的新的開發者不得不根據其實現來推測其用途,那麼他推測出來的可能並不是那個操作或類的主要用途。如果這不是那個組件的用途,雖然代碼暫時可以工作,但設計的概念基礎已經被誤用了,兩位開發人員的意圖也是背道而馳。當我們把概念顯式地建模爲類或方法時,爲了真正從中獲取價值,必須爲這些程序元素賦予一個能夠反映出其概念的名字。類和方法的名稱爲開發人員之間的溝通創造了很好的機會,也能夠改善系統的抽象。

因此在命名類和操作時要描述它們的效果和目的,而不要表露它們是通過何種方式達到目的的。這樣可以使客戶開發人員不必去理解內部細節。在創建一個行爲之前先爲它編寫一個測試,這樣可以促使你站在客戶開發人員的角度上來思考它。測試驅動的另一個價值就是要求我們寫出易於(測試)使用的代碼。試想一下,我們自己編寫測試都很困難的時候,別人又如何明白呢?

通常的所有複雜的機制都應該封裝到抽象接口的後面, 接口只表明意圖,而不表明方式。在領域的公共接口中,可以把關係和規則表述出來,但不要說明規則是如何實施的;可以把事件和動作描述出來,但不要描述它們是如何執行的。

8、領域核心能力

當我們對現實領域進行思考時候,很容易被“表象”所迷惑。比如我們的Car聚合內部會有一個導航服務,一般情況我們可能需要按照最短路徑導航、躲避擁堵、高速優先等情況。通過前面的學習,我們抽象一個“導航”服務並將其注入或者註冊到Car聚合。

隨着導航要求的多樣化,不可避免的該類會變得臃腫繼而難以維護。因此我們藉助策略模式,抽象一個導航策略,一切問題都變得更加清晰。

如上圖所示設計,我們得到了清晰明確的導航模型以及一個被明確提煉出來的導航策略。無論我們導航需求如何變化,我們只需要去增加實現類即可,這就是我們架構原則所提倡的對擴展開放。這雖然是一個很小的例子,但是其背後的意義重大,讓我們學會區分什麼是行爲、什麼是策略。因爲行爲是固定的,策略是變化的。當我們將二者區分以後,就能更加聚焦於領域的核心行爲能力。

四、聚合與六邊形架構

在之前的系列文章中,我多次提到了六邊形架構。但更多的是理念上的解釋,現在講解了聚合以後我們就來看看六邊形架構的代碼風格是什麼樣的,其端口到底爲何物。還是參照之前的做法,在一個DDD沒有完全普及的項目中,我們依然提供一個CarFacade供外部調用,以免花費很長時間去和他們爭論到底該不該建模一個充血的Car對象。

//通過RPC調用得到Car聚合信息,進而轉換成前端展示所需要的ViewObject

CarData carData = carFacade.OfId(carId);

CarVO carVO = CarVOFactory.build(carData.getValue());

(滑動可查看

通常應用服務被設計成了具有輸入和輸出的API,而傳入數據轉換器的目的即在於爲客戶端生成特定的輸出類型。在六邊形架構中我們可能會使得服務返回void類型,數據隱式的在端口流轉。通過這一點,我們可以看出六邊形架構更強調數據流轉而不像傳統開發方式那樣注重數據的返回或加工。

public class CarFacadeImpl {

public void OfId(Long carId){

//領域層邏輯

Car car = this.carRepository.OfId(carId);

//應用層邏輯

//這裏的輸出端口是一個位於位於應用程序的邊緣特殊的端口;

//在使用Spring時,該端口類可以被注入到應用服務中;

//在本例中其職責是把Car聚合轉換成前端展示所需要的ViewObject;

//如果我們使用SpringMVC一類的框架,該端口還負責把數據返回給HttpResponse;

this.carHttpOutputPort().write(car);

}

}

(滑動可查看)

當然我們可能會有多個輸出端口,而各個端口的隔離實現又避免了邏輯的污染,爲將來任意擴展端口場景提供了可能性。在write()方法執行後,每一個註冊的讀取器都會將端口的輸出作爲自己的輸入。這裏最大的問題就是,不瞭解六邊形架構的人會抱怨“你的getXXX方法竟然沒有返回值”。所以我們在方法命名時候儘可能避免使用get字樣,通常我會取而代之find/load,因爲查找/裝載並不隱含需要返回結果的意思。無論如何我們都要明白,任何一種架構都同時存在正面的和負面的影響。

五、演進的聚合

提到“重構”,我們頭腦中就會出現這樣一幅場景:幾位開發人員坐在鍵盤前面,發現一些代碼可以改進,然後立即動手修改代碼(當然還要用單元測試來驗證結果)。當然這個過程應該一直進行下去,但它並不是重構過程的全部。與傳統重構觀點不同的是,即使在代碼看上去很整潔的時候也可能需要重構,原因是模型是否與真實的業務一致,或者現有模型導致新需求不能被自然的實現完成。重構的原因也可能來自學習:當開發人員通過學習獲得了更深刻的理解,從而發現了一個得到更清晰或更有用的模型。綜合起來以下幾點的出現就說明你應該重新審視你的聚合了,當然我們重構也好、演進也罷,也還是要基於實際項目的情況。

  • 設計沒有表達出團隊對領域的最新理解;

  • 重要的概念被隱藏在設計中了(而且你已經發現了把它們呈現出來的方法);

  • 發現了一個能令某個重要的設計部分變得更靈活的機會;

最後還是延續前面文章的一貫風格,本文講述了很多有關聚合的細節,即使在非DDD的項目中,這些有效實踐依然大有裨益。我們希望設計的聚合具有柔性特徵,但這往往很難。能夠清楚地表明它的意圖;使人們很容易看出代碼的運行效果,因此也很容易預計修改代碼的結果。柔性設計主要通過減少依賴性和副作用來減輕人們的思考負擔。這樣的設計是以深層次的領域模型爲基礎的,在模型中,只有那些對用戶最重要的部分才具有較細的粒度。在這樣的模型中,那些經常需要修改的地方能夠保持很高的靈活性,而其他地方則相對比較簡單。這也就是我一再強調的“行爲”“策略”的區別。當我們這樣去思考問題以後,編碼以及設計思路會有很大變化,從原來那樣的流程代碼中脫離出來,進而站在一個更高的抽象層次上去實現系統。

(圖片來源於網絡

參考文獻:

  1. 《領域驅動設計:軟件核心複雜性應對之道》

  2. 《實現領域驅動設計》

vivo互聯網技術

vivo移動互聯網是基於vivo 智能手機所建立的完整移動互聯網生態圈,圍繞vivo大數據運營,打造包括應用、遊戲、資訊、品牌、電商、內容、金融、搜索的全方位服務生態,滿足海量用戶的多樣化需求。

點一下,代碼無 Bug

相關文章