戲說領域驅動設計(二十)——值物件

SKevin發表於2022-04-02

  值物件這個東西在DDD裡算是比較抽象的,好多人學了半天也學不明白。我這種聰明人也費了好大勁,總算苦心人天不負,現在也能用個有模有樣了。戰術模式中不論是領域服務、物件工廠還是資源庫,基本上您能聽懂是什麼意思,在BO層中所承擔的角色也比較明確,唯獨這個值物件有點坑爹。遙想當年我在使用C#的時候,裡面有一個值型別,與別人討論的時候經常會把這個東西搞混,就我現在寫東西還下意識把“值物件”寫成“值型別”呢。《實現領域驅動設計》書中針對值物件給了大概8類特性概括,如下圖所示。不過要我說,也就那麼有限幾點值得注意的。如果從程式設計的角度來看,所謂的值物件其實也很普通,所以讓我們以白話的形式盤盤它。

一、特性

戲說領域驅動設計(二十)——值物件

  以我來看,最難的地方就是值物件與實體型別在建模時候的決擇。有些物件可以是實體,也可以是值物件,反正你怎麼想都有理。很多初學者包括我自己也時常搞迷惑,之所以出現這種問題以我現在的經歷來看有三個原因:一是在建模的時候偏離了領域驅動的約束而以資料庫為參照,畢竟我們在上學的時候學習程式設計都是從資料庫驅動開始的。資料庫要求每一個表都應該有個主鍵,而有的時候值物件需要單獨存在一個表中,這種情況就容易造成把值物件看成實體。其實開始設計的時候是想著圍繞著領域來搞的,不過做著做著就跑偏了,這是由於潛意識造成的偏差。第二、很多程式設計師您別看一說理論的時候都能夠口吐芬芳,但並沒有真正搞明白到底值物件是幹什麼的,它的本質到底是什麼,為什麼DDD先驅要特意提出這樣一個概念,沒有理論基礎在實踐中走彎路很正常;最後一個原因是程式設計師在設計的時候目光過於片面,只關注於眼前的需求,有些模型從當前的需求點上去看的確是值物件,如果轉而從大業務流程的角度去觀察,從全域性的角度去考慮,事情就會有變化,這涉及到工作方式的變更,我感覺沒有解只能靠自己去悟了。對了,經常有人問上面的圖是怎麼畫的:PPT,還湊合吧?

二、詳解

  廢話不多說,讓我們先從值物件的特徵搞起。首先先說這東西的主要作用是什麼,您不要一看上圖那一堆的圓圈就頭暈,是我在公司內部講解PPT時用的,主要以唬人為主。在這裡就不能這麼玩兒了,我們們得是小衚衕裡趕豬——直來直去。“構成某物——我認為這是值物件最為重要的特性。這麼說吧,90%的情況下是由於這個原因才把一個模型設計為值物件的。既然是構成,他就會依附於所構成的目標物件。比如電商購物網站的訂單,一般會包含價格資訊、付款資訊、收貨資訊,如下程式碼所示。

public class Order extends EntityModel<Long> {
    private string 編號;
    private string 收件人姓名;
    private string 收件人電話;
    private string 收件人所在區;
    private string 收件人所在街道;
    private string 收件人所在街道;
    private BigDecimal 總價;
    private BigDecimal 優惠價;
    private Integer 支付方式;
    private BigDecimal 支付金額;
}

  上面的程式碼把所有的屬性都定義為原始型別,這樣設計實體其實也沒有什麼問題,還挺直觀的。不過有些物件導向專家就感覺非常的不爽:這種設計缺少抽象能力和麵向物件精神,他看著彆扭。而且呢,這樣的設計會造成其所屬物件比如這裡的“Order”所承受的責任有點重,假如我想知道一個訂單的待支付價格(待支付價是在總價與優惠價中選擇一個小的),就需要把這個方法寫到“Order”裡,如果抽象出一個價格資訊物件呢?就可以讓它來負責處理本條業務,那“Order”物件的責任也自然就減輕了。我性子急,既然專家認為上述程式碼不理想,就讓我們跟隨物件導向專家的思路,把一些相對內聚的元素組合起來成為單獨的類,比如價格資訊、支付資訊和客戶資訊等。

public class Order extends EntityModel<Long> {
    private string 編號;
    private Customer 客戶資訊;
    private Price 價格資訊;
    private Payment 支付資訊;
}

public class Customer {
    private Contact 聯絡人資訊;
    private Address 地址資訊;
}

public class Contact {
    private string 收件人姓名;
    private string 收件人電話;
}

public class Address {
    private string 收件人所在區;
    private string 收件人所在街道;
    private string 收件人所在街道;
}

public class Payment {
    private Integer 支付方式;
    private BigDecimal 支付金額;
}

public class Price {
    private BigDecimal 總價;
    private BigDecimal 優惠價;
    
    public BigDecimal getFinalPrice() {
        return min(總價,優惠價);
    }
}

  這段程式碼已經足夠OO了,如果想再追求極致可以把“價格”類中兩個屬性的型別從“BigDecimal”變為一個自定義型別比如“Money”。讓我們再看看“Order”的現狀,裡面的大部分的屬性的型別已經從基本型別變成了自定義的類,例項化後當然就是物件了。DDD給這些物件一個新的名字:值物件。回過頭來再看本文最上面的圖,您會發現好多東西全是廢話。“構成某物”,這還用解釋?沒封裝成值物件前也是“Order”的構成要素,多封裝一層後本質也沒什麼變化啊;“無識別符號”,廢話!原來也沒有啊,都是依附於訂單的,只要訂單有ID就行了,後面你完全可以根據訂單找到那些值物件;“概念整體”,有點腦子都知道啊?第二個版本的程式碼就是通過把一些內聚的元素封裝為一個個值物件的,物件不是整體是什麼?所以說學習值物件的時候你得去做一個像我這樣的推導,才會發現別看說得熱鬧,也不過如此。

  所謂的“修飾某物”,就是說值物件通常用於描述他所在的實體或值物件,一般會作目標物件的屬性而存在,並不會孤立的存於世上。比如上面的“價格”值物件,是用來修飾訂單的,脫離訂單談價格沒有意義。這個特性可讓幫助您在設計時決策一個物件是否應該被作為值物件看待。綜合“構成某物”特性,我們就會發現值物件在其生命週期中需要依附於某物且只屬於某物,不存在被共用的情況。拋開訂單談論“支付資訊”或“客戶資訊”我感覺就是在搞笑;一個客戶可能會下多個訂單,但每個訂單都包含一個獨立的客戶物件,它不會跨訂單共享。其實,通過這兩種特性,已經可以幫助您在99%的情況下決策一個物件到底是實體還是值物件了,說“決策”都誇張了點,應該是可以輕易的判別了。不過文章寫到這份肯定不行,不夠深入啊。我們還是要著重解釋值物件的“概念整體”特徵,這個涉及值物件的本質,只有瞭解這些您才知道為什麼在使用值物件的時候有許多的約束比如“不可變性”,也可以幫助你理解值物件的內涵並推匯出值物件的其它特徵。

  通過前面的程式碼案例可知我們通過把一些概念內聚的屬性組合在一起形成了值物件。這說明了什麼?既然是有意的合併在一起,你就應該視值物件為一個整體!整體的意思就是不可分割(如果還能分割,您當初所做的合併就等於白乾,工作自相矛盾),比如您去菜市場買龍蝦,你跟老闆說只想買肉不買皮,你說他會不會砍你?有了“整體”概念或約束作為前提,你在值物件上的任何操作都必須以整體為導向,必須始終把它當成一個整體來看待。比如你想修改值物件的某個屬性值,就整另外一個值物件把原來的替換掉而不是單獨修改那個屬性,這叫整體替換。

  為什麼要這樣?值物件也是一種領域模型,也要始終保持其內部業務規則的一致性(這個叫物件的不變性)。為了達到這個目的,我們需要對物件的屬性做各種驗證,需要在執行某個方法時判斷此操作是否會打破規則限制,實體物件我們是這樣做的,非常麻煩。而引入值物件的一個初衷之是為了簡化物件的使用,反正我所有的方法都不會修改屬性,根本不需要做任何判斷。如果我放開修改限制,非常容易造成物件的變質。比如聯絡人資訊,姓名“張三”+電話“123321”在我構建這個值物件時已經驗證過是合法的。如果允許修改單個的屬性,您把電話變成了“ABC”,造成了人是張三但電話是李四的,小心人家投訴你打騷擾。假如只有少量的實體和值物件,您並不會從此受多大的益。一個系統中數以百計的物件,我都不多說比如100個,其中90個是值物件,這個比例比較合理。由於值物件先天的不可變性,您寫程式碼時不用那麼多的約束判斷,這得省多大事兒?這麼說吧,值物件越多,你受益越大。

  我們其實也可以把值物件想成一個Java中的Long型別的資料,您總不可能在修改它的時候只修改高32或低32位吧?要不就不修改,要修改就全部修改。好了,有了這樣一個概念作為前提,那麼我們就可以推匯出以下四點。

  • 在設計值物件的時候不應該有“setter”方法來支援部分屬性值的修改,只能通過建構函式進行全屬性賦值;
  • 判等的時候,應該是每個屬性值都相等才能算兩個值物件是相等的,你可以把值物件想象成由幾個原始型別屬性組成的,判等肯定要比較每一個屬性;
  • 值物件可以包含豐富的業務方法包括命令型的,但業務方法不應該修改值物件的某個屬性值;
  • 修改屬性時只能通過整體替換。

  上述四點正好可以對應開篇圖中所說的“不可變性”、“屬性判斷”、“無負作用”和“替換性”。

  寫到此,我們做一下總結:“構成某物”和“修飾某物”兩個性質幫助我們決策一個物件是實體還是值物件;“概念整體”決定了值物件在設計和操作時所要遵循的規範(參考上一段所論述的四點)。至於無識別符號,這個沒什麼可談的。值物件需要依附於某個實體而不能獨立存在,所以你給他識別符號也沒個卵用,要追蹤某個值物件只要通過其所屬的實體。在總結了值物件的特性後,我們來說一下使用值物件到底有哪些好處。

  好處一:簡單;由於您不能單獨修改值物件的某個屬性,也就不用加上那麼多的約束和驗證,反正只有查詢操作怎麼玩也不會壞的。驗證的責任在實體物件構造的時候都處理過了,我們就不用再費二次的力氣處理這些;好處二:更符合物件導向精神。通過把業務方法拆到值物件中能減少實體設計的複雜度並讓程式碼看起來更加符合單一責任原則 。下面我寫了兩段程式碼,您看看哪個更好。

//訂單狀態
public class Status {
    WAITING_PAY, PAIED, COMPLETED;
    
    public boolean canPay() {
        return this == Status.WAITING_PAY;
    } 
}

public class OrderA extends EntityModel<Long> {
    private Status status;
    
    public boolean canPay() {
        return status == Status.WAITING_PAY;
    }
}

public class OrderB extends EntityModel<Long> {
    private Status status;
    
    public boolean canPay() {
        return status.canPay();
    }
}

  我把“訂單狀態”封裝為一個實體物件“Status”,方法“canPay”在實現時:類“OrderA”中實現了具體的邏輯;類“OrderB”中由“Status”來代理。如果邏輯很簡單或狀態列舉很少,這兩種寫法沒有區別。就怕訂單狀態列舉特別多的時候,由值物件自已完成相應的邏輯更優雅,複用度也會更高。這叫“知識專家”原則,即哪個物件擁有完成一個業務邏輯所需的知識就把責任放在哪個物件上面。

  關於值物件的修改,這裡有一個小技巧分享。當需要修改某個值物件的時候,最好別讓客戶直接以“New”的方式來構建值物件再傳入到方法裡;相反,您可以將這個值物件所需要的屬性以引數的形式傳入到方法中,在方法內部包裝成值物件後再將原值物件替換掉,這樣可以隱藏值物件的建立過程,如下程式碼片段所示。

public class Order extends EntityModel<Long> {
    private PaymentDetail paymentDetail;
    
    public void pay(String payer, BigDecimal payment) {
        this.paymentDetail = new PaymentDetail(payer, Money.of(payment));
    }
}

final public class PaymentDetail extends ValueModel {
    private String payer;
    private Money payment;
}

   文章寫至這份兒上您應該明白了為什麼有人說在DDD落地程式碼時,大部分的物件都最好設計成值物件了吧?也明白為什麼在設計物件的時候能使用值物件就不使用實體了吧?最起碼的好處是程式碼更清晰、量更少、維護性更高。看起來顯得專業,人人都誇你心靈手兒巧。

三、儲存

  值物件的儲存其實也沒什麼可講的,主要是太簡單了。無怪乎是兩種:1)把值物件的值和實體放在同一張表中;2)把值物件放在單獨的表中。方式一相對簡單,就是把值物件內的各個屬性對映成和實體表在一起的欄位,如下圖所示。

戲說領域驅動設計(二十)——值物件

 

  方式二,把值物件放到單獨一個表中,如下示例所示。需要注意的是示例中“審批環節”物件在儲存到資料庫後有了一個ID屬性。這其實不影響我們的設計,莫慌。在領域模型中值物件遵守了其設計規範,沒有標識;落到了資料庫中那就是資料層面的事兒了,在關聯式資料庫中每個表都有個ID這是資料庫本身的約束。再說了,有就有了唄,您又不用。不過說到這塊,我突然想起了一個特別典型的案例。下面的案例您應該能看出來審批單和審批環節的關聯關係。我們以審批單ID為“A0001”的資料為例,在經過某些業務後,需要把審批環節表中ID為“N0001”行的狀態從“1”變成“2”。這怎麼搞?您在載入審批單物件的時候肯定要把兩個審批環節資訊一同載入,載入後審批環節是個值物件,它都沒有ID的屬性,更別提“N0001”了。此種情況要怎麼解?把值物件變成實體?

戲說領域驅動設計(二十)——值物件

  兩種思路:一是在更新審批環節前,把資料庫中存在的資料拿出來和待持久化的資訊做對比,有變化的就是要變更的,自然就可以獲取到ID屬性了。這種方法有點扯,麻煩不說有時候甚至是不好靠譜,也就是說你根本比不了,比如上面的案例就不行。再假如在審批環節表中再加一個欄位“meta”,裡面儲存了JSON格式的審批後設資料,現在我把JSON中的某個節點的值改變了,你怎麼比?字串比較,別鬧……JOSN格式啊大哥,兩個節點的順序不同,但節點名和值都相同,您說他是不是相等的?什麼什麼?排序後再比較?我……算了吧,我們還是說方式二吧,有圖有真像。

  直接把舊值物件對應的資料刪除,再插入新的。感覺世界一下子清淨了,還費那個勁比較每個屬性,太不專業了。當然,上面例子僅出於演示作用,其實並不嚴謹,你還是需要提供一些關鍵屬性(比如審批環節+審批人ID)來幫助實體識別出待修改的值物件,這個關鍵屬性可不一定非得是ID,也就是完全不需要使用實體來替換原來的設計。

  儲存的時候,你的系統中如果有NoSQL資料庫也可以考慮去用一用。我在幾年前做過一個業務,當時設計了一組關係挺複雜的物件。持久化的時候使用了MySQL,其實當時也沒得選,按理放到MongoDB中最佳。這沒辦法,你也不能和運維去哭訴啊,加個中介軟體運維就需要投入更多的人力。結果,儲存特別費勁,其實如果只是花費點精力也還好,查詢才坑爹。這個案例不太好講,總之呢如果以列的方式儲存物件屬性,不知道有多少列合適,因為物件屬性的數量是動態的,即使能做也會有好多的列為空值;如果按行存,查詢支撐不了。這個事情後來也沒辦法,只能是把複雜的搜尋需求取消掉了。所以舉這個案例其實是想強調我們在儲存實體的時候要靈活一點,別隻認識MySQL,那東西也不是萬能的。平常多積累一些知識,關鍵時刻只有你應用的漂亮、得體,妥妥職場上最靚的仔。大家都崇拜強者,尤其是姑娘們,努力吧兄弟!

四、案例

  為了能把知識講透了,我這顆不安的心強烈要求我再多舉一些例子,它的建議我接受了,我們們不管幹貨稀貨索性就多整點。以後園子裡評什麼敬業獎的時候,感覺得有一份歸我。其實也不是給我,是給我那顆敬業的心。Let's go……

  1、訂單與訂單項:最常見的案例,必須安排。訂單項肯定是值物件,你想看買了什麼東西就必須通過訂單進行導航。脫離了訂單的訂單項就是一個沒媽的孤兒,找不到存在的理由,可憐吶!

 

   2、賬戶與實名資訊:太簡單了,不解釋。

 

   3、電商系統中的地址管理:引功能用於管理登入賬號的不同送貨地址。地址物件在訂單中屬於值物件;在本場景中是實體,不買東西您還不讓我管理我的送貨地址?又沒吃你家大米!

 

  4、微博:必須是實體啊,要不然別人怎麼引用?

 

 

  5、論壇中的貼子與評論:你猜呢?我覺得評論算是一種特殊的帖子,他關聯了被評論的物件而貼子則不需要,本質上都是一種“被髮布的內容”。另外,修改貼子的時候不需要把評論一同編輯了;修改評論也不會影響貼子的狀態,兩者是一種弱關聯。表面上看刪除了貼子其對應的評論也不復存在(此外應該用被隱藏更為合適),但帖子並沒有被一併刪除。我們在看評論的時候其實都是通過貼子導航過去的,這個案例中僅僅是由於貼子被刪除而失去了導航點,並不代表貼子與評論具有相同的生命週期,所以我猜這兩個都是實體。再說了,我還能針對評論再給出新的評論呢,你不把它當成實體合適嗎?

  6、角色與許可權:兩實體唄。許可權可以屬於多個角色,角色也包含多個許可權,多對多的關係。多對多的時候,兩邊肯定都是實體。下面的圖只畫了一個方向的多重關聯,因為那是設計模型,設計模型中不應該存在多對多的情況。

 

總結

  這章字寫得有點多,本來一個沒什麼可講的東西我都能給它整出花樣來,你能不服?不管怎麼著,有了這些知識,我相信您應該知道何為值物件以及怎麼使用了吧?等你寫物件導向的程式碼多了,就會發現其實值物件真的在所有的物件中佔了大多數,9:1都不誇張。再提醒一句:務必把值物件當成整體來看,從這個角度理解您會通透很多。

  寫完今天的這篇已經二十章了,可累壞了。不過好處也是大大的,能把自已所學與別人分享,這個過程讓人快樂與滿足。讓我們繼承往前行……對了,螢幕前的你也要多多努力啊,去成全自己的夢想。

附:

  在走查本文的時候,發現了一個細節沒有重點說明:值物件除了不能有識別符號(ID)和不能修改屬性外,其實和實體是一樣的,是可以有業務方法的,您可千萬別把值物件當成DTO來用,這也是一個充血模型呢。

 

相關文章