領域驅動設計(DDD)實踐之路(三):如何設計聚合

本文首發於 vivo互聯網技術 微信公衆號 
連接: https://mp.weixin.qq.com/s/oAD25H0UKH4zujxFDRXu9Q
做者:wenbo zhang

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

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

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

(圖片來源於網絡)緩存

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

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

一、實體Entity

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

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

跟蹤實體的標識是很是重要的,但爲其餘全部對象也加上標識會影響系統性能並增長分析工做,並且會使模型變得混亂,由於全部對象看起來都是相同的。軟件設計要時刻與複雜性作鬥爭,咱們必須區別對待問題,僅在真正須要的地方進行特殊處理。好比在上面的例子中,咱們把收貨地址「XX市YY街道ZZ園區」建模成具備惟一標識的實體,那麼三個用戶就會建立三個地址,這對於系統來講徹底沒有必要甚至還會致使性能或者數據一致性問題。數據結構

二、值對象Value Obj

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

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

在電力運營公司的軟件中,一個地址對應於公司線路和服務的一個目的地。若是幾個室友各自打電話申請電力服務,公司須要知道他們實際上是住在同一個地方,由於咱們真實服務的是用戶所在地方的電力資源,在這種狀況下,咱們會認爲地址是一個實體。可是隨着思考的深刻,咱們發現能夠換種方式,抽象出一個電力服務模型並與地址關聯起來。經過這樣的設計之後,咱們發現真正的實體是電力服務,地址不過是一個具備描述性的值對象而已。

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

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

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

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

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

  • 它能夠做爲不變量;

    值對象可能會被共享,因此具備不變性,即調用方不能對其執行set操做。

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

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

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

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

2、聚合(Aggregate)

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

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

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

3、一些關於聚合的實踐

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

案例:汽車模型設計

約束:首先一輛汽車在車輛登記機構歸屬於惟一一我的或者企業主體(實際上企業也具備法人,因此即便是企業主體也能夠找到對應的歸屬人);其次,正如你們所常見的,咱們探討是目前技術所能實現的、且廣泛流行的車輛結構,一輛車具備4個輪子、一個引擎;

一、業務邊界

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;
}

二、警戒性能問題

在具備複雜關聯的模型中,要保證對象更改的一致性是很困難的。不只互不關聯的對象須要遵照一些固定規則,並且緊密關聯的各組對象也要遵照一些固定規則。然而,過於謹慎的鎖定機制又會致使多個用戶之間毫無心義地互相干擾,從而使系統不可用。引用自《領域驅動設計》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去處理後續的業務規則。

三、值對象-無反作用

值對象的方法應該被設計成一個無反作用函數,即只用於生成輸出而不會修改對象的狀態。對於不變的值對象而言,全部的方法都必須是無做用的函數,由於它們不能破壞值對象的屬性值才能安全的被共享。咱們要意識到值對象毫不僅僅是一個屬性容器,其真正的強大特性「無反作用函數」。好比上面的窗戶對象,當其被實例化出來之後各個屬性就不能被肆意修改了,咱們通用的作法是在構造方法裏面進行賦值或者基於工廠方法得到,總之千萬拒絕提供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);
    }
}

四、聚合的構造與保存

當建立一個對象或建立整個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來完成。

五、展現聚合

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

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

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

六、不要拋棄領域服務

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

//AuthenticationService註冊到了DomainRegistry
UserDescriptor userDescriptor = DomainRegistry
                .authenticationService()
                .authenticate(userId, password);

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

要獲取到一個無狀態的AuthenticationService,而後調用它的authenticate()方法便可。這種方式將全部的認證細節放在領域服務中,而不是應用服務。在須要的狀況下,領域服務 能夠使用在何領域對象來完成操做,包括對密碼的加密過程。客戶端不須要知道任何認證細節。此時,通用語言也獲得了知足,由於咱們將全部的領域術語都放在了身份管理這個領域中,而不是一部分放在領域模型中,另外一部分 放在客戶端中。

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

那麼咱們來總結一下,如下幾種狀況咱們能夠使用領域服務來實現:

  • 執行一個顯著的業務操做過程;
  • 對領域對象進行轉換;
  • 以多個領域對象做爲輸入進行計算,結果產生一個值對象;

七、再談命名

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

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

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

八、領域核心能力

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

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

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

4、聚合與六邊形架構

在以前的系列文章中,我屢次提到了六邊形架構。但更多的是理念上的解釋,如今講解了聚合之後咱們就來看看六邊形架構的代碼風格是什麼樣的,其端口到底爲什麼物。仍是參照以前的作法,在一個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,由於查找/裝載並不隱含須要返回結果的意思。不管如何咱們都要明白,任何一種架構都同時存在正面的和負面的影響。

5、演進的聚合

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

  • 設計沒有表達出團隊對領域的最新理解;
  • 重要的概念被隱藏在設計中了(並且你已經發現了把它們呈現出來的方法);
  • 發現了一個能令某個重要的設計部分變得更靈活的機會;

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

(圖片來源於網絡)

參考文獻:

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

更多內容敬請關注 vivo 互聯網技術 微信公衆號

注:轉載文章請先與微信號:Labs2020 聯繫

相關文章
相關標籤/搜索