不想只做Cruder?實體、聚合根,還不快去了解下

韓楠發表於2022-07-25




責編 | 韓楠

約 4895 字 | 10 分鐘閱讀






 以下,Enjoy~ 




《DDD在Go中的落地》這一專題,每篇文章開頭,我打算都先簡單地跟你聊聊為什麼要這麼做,比如為何需要使用該領域物件,它解決了什麼問題,其次才是對應到程式碼上該如何實現。

有的時候,知道 Why 比知道 How 要重要的多,也希望你能夠做到知其然知其所以然。

過往的 一篇分享中,我曾闡述了這樣一個觀點,對於業務系統,要儘量保持程式碼的清晰,這樣才能帶來較低的維護成本。值物件基於業務域中的統一語言,將相關聯的屬性組合在一起,構成了一個完整的概念整體,從而讓業務邏輯更內聚,業務概念也得到了更好的展現。

   值物件的優勢,在於對細粒度的領域概念的表達,實體的優勢又是什麼呢?要弄清楚這一點,就要從為什麼我們需要從實體說起。


傳統的設計方法由於只從資料出發,也就導致了 在這些CRUD系統中建立出的業務模型,不具有表達業務的能力。而實體的引入,將資料跟業務緊密結合在一起,彼此呼應和更迭,在業務系統中具有更強的生命力,這也是為什麼我們需要引入實體的原因。



01   什麼是實體

先來看看怎麼理解實體。這裡直接引用 IDDD 一書中對實體的定義:

一個實體是一個唯一的東西,並且可以在相當長的一段時間內持續地變化。我們可以對實體做多次修改,故一個實體物件可能和它先前的狀態大不相同。但是,由於它們擁有相同的身份標識(identity),它們依然是同一個實體。


—— from IDDD


02   區別於資料物件


實體有兩個突出的特徵:唯一的身份標識和可變性,而這兩個特徵同樣存在於資料物件身上,因此,為了避免先入為主地將資料物件等同於實體,這裡先說下兩者的區別和聯絡。


▶︎   資料物件

資料物件一般指的是我們在 model 層定義的一些 struct,這些 struct 的屬性跟資料庫中某個表的列資訊是保持一致的,透過 ORM 軟體(我們常用的就是Gorm),可以方便地將資料庫表裡的一行對映成一個資料物件的例項。

反之亦然。

這些物件之所以叫資料物件,主要原因在於它們只是承載了資料功能。

比如,在一個名為 User 的資料模型中:

我們可以看到它包含了使用者名稱、手機號、性別、年齡,等等。但是,單從這個資料模型上是看不出它可以做什麼的。

而具體能做什麼、怎麼做,則被放到了某個服務(通常會有個 service 層)裡面,在服務中透過一些賦值操作 來更新資料物件的某些屬性,最後再透過 ORM 儲存回資料庫中。

這種模式下, 資料物件因為缺少了行為,又被稱為貧血模型。

從本質上來說,這種方式依然是程式導向的程式設計正規化,本應屬於領域模型的業務邏輯 被洩露到了各個 service 中,久而久之,會使得程式碼越來越難以理解。

▶︎   實體物件

實體是 DDD 中的領域物件,它是一個富有行為的領域概念。

領域物件裡的成員和資料物件裡的成員可能是一致的,也可能不一致,這完全取決於你使用什麼樣的儲存技術。

比如,在 MongoDB 這類文件型資料庫中,實體模型和資料模型很可能是高度一致的,但是在傳統的 MySQL 資料庫中,很多時候會將一個實體模型對映成多個資料模型。

考慮在訂單中要有配送地址這個場景。

如果是使用 MongoDB ,可能直接存成一個doc:

而在 MySQL 中,就需要將地址資訊拆到另外一張表裡。

除此以外, 實體物件跟資料物件的本質區別,還在於模型的豐富程度,實體物件是包含了豐富的領域概念的。

還是訂單這個例子, Order 實體上可能還會定義一些領域方法:

而資料模型就只是光禿禿的一個 Order 結構體。

▶︎   唯一標識不等同於資料庫主鍵

說到唯一標識,我們很容易聯想到資料庫表裡的唯一主鍵,認業務的唯一標識就是表記錄裡的id列,其實這種理解是不太全面的。

資料表裡的主鍵 id 在某些情況下,可以作為實體的唯一標識,但兩者本身屬於不同的概念。

還是以 Order 實體為例,它在資料庫中可能存在類似下面這樣的一張表:

那麼,這裡的 id 只是資料庫表裡的一個主鍵,而 order_id 才是 Order 這個領域實體的唯一標識。

再來看一個 Product 的例子,它的定義比較簡單:

products 表有一個 id 列作為主鍵,同時,我們通常也會將這個 id 作為 Product 實體的唯一標識。也就是說,資料庫表的主鍵,有的時候可以作為唯一標識使用,有的時候卻不可以。

總之,我們只需要記住, 唯一標識和資料庫結構沒有關係,主鍵 id 是儲存層面的唯一標識,而業務層面的唯一標識才是實體關心的。


03   如何表示唯一標識


比較教條的做法是無論什麼情況,都用一個值物件來存放實體的唯一標識。

值物件具有不變性,這也就保證了實體身份的穩定。

但在一些比較簡單的情況下,可以直接使用原始型別(比如string、int)來作為唯一標識。

▶︎  使用值物件表示唯一標識

這裡考慮一個訂單號生成的例子,假如我們生成訂單號的規則如下:

時間戳+業務型別+下單平臺+隨機碼(或自增碼,自增碼每天可清零)+支付渠道

那麼,透過這樣一個訂單號,我們可以解析出該訂單下單的時間、支付的渠道等資訊。

這些資訊的解析,跟訂單號是密不可分的,這些行為和訂單號本身形成了一個完整的業務概念整體。因此,這個時候將訂單號編碼為一個值物件是合理的。

使用值物件來實現唯一標識, 不僅能夠更好地表達業務,同時,可以一定程度上規避一些錯誤。 

看下面的程式碼,我們要提供一個根據訂單ID和商品ID,來獲取訂單項資訊的函式:

第一種實現的入參都是 int64 型別,第二種是值物件型別。

對於第一種來說,呼叫方即使在傳參時將 orderId 和 productId 搞反了,編譯器也是不會報錯的,而這種錯誤在第二種實現方式中,是完全不可能發生的。

這種表示方式的唯一缺點,在於程式碼量的增加。

在很多地方,因為必須要對原始型別與值物件型別進行轉換(比如資料庫裡儲存的訂單號還是 int 型別,但是讀取出來要轉成領域實體,就需要轉成 OrderId 型別,在實體持久化的時候還需要將 OrderId 轉成 int),複雜度會有一定的增加。

▶︎   直接使用 int64 作為唯一標識

上面提到的產品ID,是一個不需要使用值物件做唯一標識的例子。

Product 實體用自增 ID 來代表唯一標識,這個 ID 除了能唯一標識一個產品外,沒有其他任何與之相關的行為。 

所以這裡可以將其簡化成一個 int64 型別。int64 也是不可變的,因此其本質上也是符合值物件的特點的。

這種實現方式的缺點是表達能力不強,但好在足夠簡單。

綜合來看: 

•  如果是使用通用的ID生成器這類的工具,來生成唯一標識,其本身除了唯一標識一個實體,也沒有其他的行為,這種情況下,推薦直接使用原始型別就可以了。

•  如果唯一標識,是按照一定的規則來生成的,並且圍繞這個唯一標識還會有一些方法(行為),這個時候最好使用值物件來承載。


04   生成唯一標識


根據不同的場景,大體上分為兩種生成方式:使用者傳入和系統生成。

▶︎   使用者直接傳入唯一標識

這種情況依賴使用者的輸入,使用者需要保證輸入的唯一標識真的是唯一的,這通常很難。但是,在某些特殊場景下還是可以做到的。

比如在學校圖書館,管理員錄入書本的場景。

我們知道每本書都有一個 ISBN 碼,這個 ISBN 碼就可以作為書本的唯一標識。管理員在錄入書本時,用手持裝置直接掃描 ISBN 碼,這個掃描到的 ISBN 碼,就可以認為是使用者直接傳入的唯一標識。

▶︎   系統生成唯一標識

大部分情況下,我們遵循的都是這種方法。無論是上面提到的 OrderId 還是 ProductId,雖然生成的方式不同,但都可以歸屬到系統生成的範疇。

這裡面有一種比較特殊的情況,是使用資料庫的自增來生成。

這種情況特殊的點在於,實體建立好了之後,可能還沒有來得及分配一個唯一標識,因為此時實體還沒有進行持久化。

沒有唯一標識的實體,可能需要面對下面兩個問題:

  如果需要釋出領域事件,這個時候因為還沒生成唯一標識,事件接收者無法知道是哪個實體發出的事件;

說到這,你可能會問了,那怎麼解決這個問題呢?辦法是有,就是將唯一標識的生成提前, 在實體建立好的時候,保證一定是有唯一標識的。

▶︎  程式碼如何實現

通常,我們會在 Repository 介面中,定義一個 NextIdentity 方法,如下:

注:Repository 對應的是 DDD 裡的倉儲概念,我們在後面的章節會介紹。

而需要用到這個 NextIdentity 方法的地方,一般是在工廠或應用服務裡。

注:工廠和應用服務也是 DDD 裡的概念,同樣放在後面的章節進行介紹。

對於應用服務,基本都需要持有一個對應的 Repository 屬性,比如這樣:

而如果是在工廠裡,就要展開討論了。

如果工廠是無狀態的,也可以讓工廠直接持有對應 Repository 屬性,實現方式跟在應用服務裡類似。

如果工廠是有狀態的,那麼只能每次前都建立一個工廠的例項,在建立的時候將 Repository 作為引數傳入:

無論是哪種形式,都需要先生成唯一標識,再生成實體。


05   聚合根


聚合,是 DDD 中較為難以理解的一個概念。

很多剛剛接觸 DDD 的同學,常犯的錯誤是設計出一個囊括天地萬物的大聚合。這在戰略設計階段往往看不出什麼問題,但是一旦要落實到程式碼層面,就會發現根本行不通。

當然,我們這裡也不會過多去講應該如何設計聚合,這不是這篇文章的重點。

但是,我們至少要知道,設計小聚合是很重要的一條原則。

小聚合的前提,是要保證聚合內的一致性條件不被破壞。這裡有篇文章可以幫助大家更好的理解,得空了可以看下。()

更多的原則,還可參考這裡:(https://weread.qq.com/web/reader/f5032ce071fd5a64f50b0f6kad63251024aad61ab143c7e)

當我們迴歸到小聚合的設計後,就會發現,聚合根的實現方式跟實體是非常類似的。

還是考慮 Product 這個例子:

假如說我們的產品模型非常簡單,只有前面的四個屬性,我們是否還有必要再單獨定義一個 ProductAggregate 結構體做聚合根呢?

上面這個寫法顯然是多餘的,Product 是實體,但是,如果在聚合根裡只包含實體一個屬性時,Product 本身也可以當作聚合根來用。

同理,可以推廣到稍複雜的情況。

比如在一個訂單中,通常會包含總價、支付方式、訂單項、地址等內容,如果我們強制區分實體和聚合根的話,可能會寫出如下的程式碼:

但在實際開發中,可以在 Order 中直接引用 OrderItem,這樣就省去了對 OrderAggregate 的維護,Order 也變成了實體加聚合根的雙重身份:

同時,我們建議所有的聚合根在命名上都加一個 Aggregate 或 Agg 的字尾,用以明確地表示這是一個聚合根。

使用聚合根時,還有一個經常容易犯的錯誤,就是在一個聚合根中引用了另外一個聚合根。

正確的做法是 透過全域性唯一標識來引用外部的聚合。

原因就在於,在一個事務中,原則上只能修改一個聚合,如果不持有對其他物件的引用,也就避免了對這個物件的修改。

在你的程式碼裡,如果一個事務必須要修改多個聚合,這個時候就要考慮聚合設計的是否合理,這種情況通常意味著聚合的一致性邊界是錯誤的。


06   結語


今天,我主要為你介紹了圖中包含的這些內容。實體、聚合根,是DDD領域層最核心的概念,其上可能包含了對多個值物件的引用,同時也是業務邏輯主要的載體。

整體來說,實體的定義跟普通的 Struct 並無太大的區別,唯一需要注意的就是唯一標識的表示。

現在,實體有了,接下來的工作就是如何將其持久化到資料庫中,但實體畢竟不同於資料模型,沒法直接呼叫 Gorm 的相關方法。

具體如何做呢?留待我們在下一章節-倉儲,再細說。

▶︎  延伸思考

最後再進一步思考個問題吧。實體和值物件在DDD中是非常重要的領域載體,一般在建模的時候,推薦儘可能地使用值物件來代替實體。有的同學這個時候可能就糊塗了,一件東西要麼是實體要麼是值物件,還能同時兼顧兩者嗎?這就要分場景來看了。

我們知道實體和值物件最大的不同,在於是否具有唯一標識。而在某些情況下,我們關心的只是一件東西的屬性。

比如我們在網上購買了一臺電視,剛送過來的時候你覺得有瑕疵,於是更換了一臺,你要關心的是不是就是更換的這臺跟你購買的是否為同一型號、同一尺寸,但是對於經銷商來說,他關心的就是你退回的是不是之前發給你的那臺。

所以說,同樣是電視這個物品,在你這裡就可以看作是一個值物件,而在經銷商那裡,就必須看作是一個實體了。

好,今天就先交流到這,後續再見~


THE END 

轉載請聯絡ITPUB官方公眾號獲得授權

—————————————————————————————————

歡迎各領域技術人員投稿

投稿郵箱 | hannan@it168.com


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/70016482/viewspace-2907514/,如需轉載,請註明出處,否則將追究法律責任。

相關文章