阿里巴巴領域建模實踐

前言

設計是把雙刃劍,沒有最好的,也沒有更好的,而是條條大路到杭州。同時不設計和過度設計都是有問題的,恰到好處的設計纔是我們追求的極致。

DDD(Domain-Driven Design,領域驅動設計)只是一個流派,談不上壓倒性優勢,更不是完美無缺。 我更想跟大家分享的是我們是否關注設計本身,不管什麼流派的設計,有設計就是好的。

從我看到的代碼上來講,阿里集團內部大部分代碼都不屬於 DDD 類型,有設計的也不多,更多的像“麪條代碼”,從端上一條線殺到數據庫完成一個操作,僅有的一些設計集中在數據庫上。我們依靠強大的測試保證了軟件的外部質量(向苦逼的測試們致敬),而內部質量在緊張的項目週期中屢屢得不到重視,陷入日復一日的技術負債中。

一直想寫點什麼喚起大家的設計意識,但不知道寫點什麼合適。去年轉到盒馬,有了更多的機會寫代碼,可以從無到有去構建一個系統。盒馬跟集團大多數業務不同,盒馬的業務更面向 B 端,從供應到配送鏈條,整體性很強,關係複雜,不整理清楚,誰也搞不明白髮生什麼了。所以這裏設計很重要,不設計的代碼今天不死也是拖到明天去死,不管我們在盒馬待多久,不能給未來的兄弟挖坑啊。在我負責的模塊裏,我們完整地應用了 DDD 的方式去完成整個系統,其中有我們自己的思考和改變,在這裏我想給大家分享一下,他山之石可以攻玉,大家可以借鑑。

領域模型探討

1. 領域模型設計:基於數據庫 vs 基於對象

設計上我們通常從兩種維度入手:

  • Data Modeling: 通過數據抽象系統關係,也就是數據庫設計

  • Object Modeling: 通過面向對象方式抽象系統關係,也就是面向對象設計大部分架構師都是從 Data Modeling 開始設計軟件系統,少部分人通過 Object Modeling 方式開始設計軟件系統。這兩種建模方式並不互相沖突,都很重要,但從哪個方向開始設計,對系統最終形態有很大的區別。

Data Model

領域模型(在這裏叫數據模型)對所有軟件從業者來講都不是一個陌生的名詞,一個軟件產品的內在質量好壞可能被領域模型清晰與否所決定,好的領域模型可以讓產品結構清楚、修改更方便、演進成本更低。

在一個開發團隊裏,架構師很重要,他決定了軟件結構,這個結構決定了軟件未來的可讀性、可擴展性和可演進性。通常來說架構師設計領域模型,開發人員基於這個領域模型進行開發。“領域模型”是個潮流名詞,如果拉回到 10 幾年前,這個模型我們叫“數據字典”,說白了,領域模型就是數據庫設計。

架構師們在需求討論的過程中不停地演進更新這個數據字典,有些設計師會把這些字典寫成 SQL 語句,這些語句形成了產品 / 項目數據庫的發育史,就像人類胚胎髮育:一個細胞(一個表),多個細胞(多個表),長出尾巴(設計有問題),又把尾巴縮掉(更新設計),最後哇哇落地(上線)。

傳統項目中,架構師交給開發的一般是一本厚厚的概要設計文檔,裏面除了密密麻麻的文字就是分好了域的數據庫表設計。言下之意:數據庫設計是根本,一切開發圍繞着這本數據字典展開,形成類似於下邊的架構圖:

在 service 層通過我們非常喜歡的 manager 去 manage 大部分的邏輯,POJO(後文失血模型會講到)作爲數據在 manager 手(上帝之手)裏不停地變換和組合,service 層在這裏是一個巨大的加工工廠(很重的一層),圍繞着數據庫這份 DNA,完成業務邏輯。

舉個不恰當的例子:假如有父親和兒子這兩個表,生成的 POJO 應該是:

public class Father{…}public class Son{ private String fatherId;//son 表裏有 fatherId 作爲 Father 表 id 外鍵 public String getFatherId(){ return fatherId; } ……}

這時候兒子犯了點什麼錯,老爸非常不爽地扇了兒子一個耳光,老爸手疼,兒子臉疼。Manager 通常這麼做:

public class SomeManager{ public void fatherSlapSon(Father father, Son son){ // 如果邏輯上說不通,大家忍忍 father.setPainOnHand(); son.setPainOnFace();// 假設 painOnHand, painOnFace 都是數據庫字段 }}

這裏,manager 充當了上帝的角色,扇個耳光都得他老人家幫忙。

Object Model

2004 年,Eric Evans 發表了《Domain-Driven Design –Tackling Complexity in the Heart of Software》(領域驅動設計),簡稱 Evans DDD,先在這裏給大家推薦這本書,書裏對領域驅動做了開創性的理論闡述。

在聊到 DDD 的時候,我經常會做一個假設:假設你的機器內存無限大,永遠不宕機,在這個前提下,我們是不需要持久化數據的,也就是我們可以不需要數據庫,那麼你將會怎麼設計你的軟件?這就是我們說的 Persistence Ignorance:持久化無關設計。

沒了數據庫,領域模型就要基於程序本身來設計了,熱愛設計模式的同學們可以在這裏大顯身手。在面向過程、面向函數、面向對象的編程語言中,面向對象無疑是領域建模最佳方式。

類與表有點像,但不少人認爲表和類就是對應的,行 row 和對象 object 就是對應的,我個人強烈不認同這種等同關係,這種認知直接導致了軟件設計變得沒有意義。

類和表有以下幾個顯著區別,這些區別對領域建模的表達豐富度有顯著的差別,有了封裝、繼承和多態,我們對領域模型的表達要生動得多,對 SOLID 原則的遵守也會嚴謹很多:

  • 引用:關係數據庫表表示多對多的關係是用第三張表來實現,這個領域模型表示不具象化, 業務同學看不懂。

  • 封裝:類可以設計方法,數據並不能完整地表達領域模型,數據表可以知道一個人的三維,但並不知道“一個人是可以跑的”。

  • 繼承、多態:類可以多態,數據上無法識別人與豬除了三維數據還有行爲的區別,數據表不知道“一個人跑起來和一頭豬跑起來是不一樣的”。

再看看老子生氣扇兒子的例子:

public class Father{ // 教訓兒子是自己的事情,並不需要別人幫忙,上帝也不行 public void slapSon(Son son){ this.setPainOnHand(); son.setPainOnFace(); }}

根據這個思路,慢慢地,我們在面向對象的世界裏設計了栩栩如生的領域模型,service 層就是基於這些模型做的業務操作(它變薄了,很多動作交給了 domain objects 去處理):領域模型並不完成業務,每個 domain object 都是完成屬於自己應有的行爲(single responsibility),就如同人跑這個動作,person.run 是一個與業務無關的行爲,但這個時候 manager 或者 service 在調用 some person.run 的時候可以完成 100 米比賽這個業務,也可以完成跑去送外賣這個業務。這樣的話形成了類似於下邊的架構圖:

我們回到剛纔的假設,現在把假設去掉,沒有誰的機器是內存無限大,永遠不宕機的,那麼我們需要數據庫,但數據庫的職責不再承載領域模型這個沉重的包袱了,數據庫迴歸 persistence 的本質,完成以下兩個事情:

  • 存:將對象數據持久化到存儲介質中。

  • 取:高效地把數據查詢返回到內存中。

由於不再承載領域建模這個特性,數據庫的設計可以變得天馬行空,任何可以加速存儲和搜索的手段都可以用上,我們可以用 column 數據庫,可以用 document 數據庫,可以設計非常精巧的中間表去完成大數據的查詢。總之數據庫設計要做的事情就是儘可能高效存取,而不是完美表達領域模型(此言論有點反動,大家看看就好),這樣我們再看看架構圖:

這裏我想跟大家強調的是:

  • 領域模型是用於領域操作的,當然也可以用於查詢(read),不過這個查詢是有代價的。在這個前提下,一個 aggregate 可能內含了若干數據,這些數據除了類似於 getById 這種方式,不適用多樣化查詢(query),領域驅動設計也不是爲多樣化查詢設計的。

  • 查詢是基於數據庫的,所有的複雜變態查詢其實都應該繞過 Domain 層,直接與數據庫打交道。

  • 再精簡一下:領域操作 ->objects,數據查詢 ->table rows

2. 領域模型:失血、貧血、充血

失血、貧血、充血和脹血模型應該是老馬提出的(此老馬非馬老師,是 Martin Fowler),講述的是基於領域模型的豐滿程度下如何定義一個模型,有點像:瘦、中等、健壯和胖。脹血(胖)模型太胖,在這裏我們不做討論。

失血模型:基於數據庫的領域設計方式其實就是典型的失血模型,以 Java 爲例,POJO 只有簡單的基於 field 的 setter、getter 方法,POJO 之間的關係隱藏在對象的某些 ID 裏,由外面的 manager 解釋,比如 son.fatherId,Son 並不知道他跟 Father 有關係,但 manager 會通過 son.fatherId 得到一個 Father。

貧血模型:兒子不知道自己的父親是誰是不對的,不能每次都通過中間機構(Manager)驗 DNA(son.fatherId) 來找爸爸,領域模型可以更豐富一點,給 son 這個類修改一下:

public class Son{ private Father father; public Father getFather(){return this.father;}}

Son 這個類變得豐富起來了,但還有一個小小的不方便,就是通過 Father 無法獲得 Son,爸爸怎麼可以不知道兒子是誰?這樣我們再給 Father 添加這個屬性:

public class Father{ private Son son; private Son getSon(){return this.son;}}

現在看着兩個類就豐滿多了,這也就是我們要說的貧血模型,在這個模型下家庭還算完美,父子相認。然而仔細研究這兩個類我們會發現一點問題:通常一個 object 是通過一個 repository(數據庫查詢),或者 factory(內存新建)得到的:

Son someSon = sonRepo.getById(12345);

這個方法可以將一個 son object 從數據庫裏取出來,爲了構建完整的 son 對象,sonRepo 裏需要一個 fatherRepo 來構建一個 father 去賦值 son.father。而 fatherRepo 在構建一個完整 father 的時候又需要 sonRepo 去構建一個 son 來賦值 father.son。這形成了一個無向有環圈,這個循環調用問題是可以解決的,但爲了解決這個問題,領域模型會變得有些噁心和將就。有向無環纔是我們的設計目標,爲了防止這個循環調用,我們是否可以在 Father 和 Son 這兩個類裏省略掉一個引用?修改一下 Father 這個類:

public class Father{ //private Son son; 刪除這個引用 private SonRepository sonRepo;// 添加一個 Son 的 repo private getSon(){return sonRepo.getByFatherId(this.id);}}

這樣在構造 Father 的時候就不會再構造一個 Son 了,但代價是我們在 Father 這個類裏引入了一個 SonRepository,也就是我們在一個 domain 對象裏引用了一個持久化操作,這就是我們說的充血模型。

充血模型:充血模型的存在讓 domain object 失去了血統的純正性,他不再是一個純的內存對象,這個對象裏埋藏了一個對數據庫的操作,這對測試是不友好的,我們不應該在做快速單元測試的時候連接數據庫,這個問題我們稍後來講。爲保證模型的完整性,充血模型在有些情況下是必然存在的,比如在一個盒馬門店裏可以售賣好幾千個商品,每個商品有好幾百個屬性。如果我在構建一個店的時候把所有商品都拿出來,這個效率就太差了:

public class Shop{ //private List<Product> products; 這個商品列表在構建時太大了 private ProductRepository productRepo; public List<Product> getProducts(){ //return this.products; return productRepo.getShopProducts(this.id); }}

3. 領域模型:依賴注入

簡單說一說依賴注入:

  • 依賴注入在 runtime 是一個 singleton 對象,只有在 spring 掃描範圍內的對象(@Component)才能通過 annotation(@Autowired)用上依賴注入,通過 new 出來的對象是無法通過 annotation 得到注入的。

  • 個人推薦構造器依賴注入,這種情況下測試友好,對象構造完整性好,顯式地告訴你必須 mock/stub 哪個對象。

說完依賴注入我們再看剛纔的充血模型:

public class Father{ private SonRepository sonRepo; private Son getSon(){return sonRepo.getByFatherId(this.id);} public Father(SonRepository sonRepo){this.sonRepo = sonRepo;}}

新建一個 Father 的時候需要賦值一個 SonRepository,這顯然在寫代碼的時候是非常讓人惱火的,那麼我們是否可以通過依賴注入的方式把 SonRepository 注入進去呢?Father 在這裏不可能是一個 singleton 對象,它可能在兩個場景下被 new 出來:新建、查詢,從 Father 的構造過程,SonRepository 是無法注入的。這時工廠模式就顯示出其意義了(很多人認爲工廠模式就是一個擺設):

@Componentpublic class FatherFactory{ private SonRepository sonRepo; @Autowired public FatherFactory(SonRepository sonRepo){} public Father createFather(){ return new Father(sonRepo); }}

由於 FatheFactory 是系統生成的 singleton 對象,SonRepository 自然可以注入到 Factory 裏,newFather 方法隱藏了這個注入的 sonRepo,這樣 new 一個 Father 對象就變乾淨了。

4. 領域模型:測試友好

失血模型和貧血模型是天然測試友好的(其實失血模型也沒啥好測試的),因爲他們都是純內存對象。但實際應用中充血模型是存在的,要不就是把 domain 對象拆散,變得稍微不那麼優雅(當然可以,貧血和充血的戰爭從來就沒有斷過)。那麼在充血模型下,對象裏帶上了 persisitence 特性,這就對數據庫有了依賴,mock/stub 掉這些依賴是高效單元化測試的基本要求,我們再看 Father 這個例子:

public class Father{ private SonRepository sonRepo;//=new SonRepository() 這裏不能構造 private getSon(){return sonRepo.getByFatherId(this.id);} // 放到構造函數裏 public Father(SonRepository sonRepo){this.sonRepo = sonRepo;}}

把 SonRepository 放到構造函數的意義就是爲了測試的友好性,通過 mock/stub 這個 Repository,單元測試就可以順利完成。

5. 領域模型:盒馬模式下 repository 的實現方式

按照 object domain 的思路,領域模型存在於內存對象裏,這些對象最終都要落到數據庫,由於擺脫了領域模型的束縛,數據庫設計是靈活多變的。在盒馬,domain object 是怎麼進入到數據庫的呢。

在盒馬,我們設計了 Tunnel 這個獨特的接口,通過這個接口我們可以實現對 domain 對象在不同類型數據庫的存取。Repository 並沒有直接進行持久化工作,而是將 domain 對象轉換成 POJO 交給 Tunnel 去做持久化工作,Tunnel 具體可以在任何包實現,這樣,部署上,domain 領域模型(domain objects+repositories)和持久化 (Tunnels) 完全的分開,domain 包成爲了單純的內存對象集。

6. 領域模型:部署架構

盒馬業務具有很強的整體性:從供應商採購,到商品快遞到用戶手上,對象之間關係是比較明確的,原則上可以採用一個大而全的領域模型,也可以運用 boundedContext 方式拆分子域,並在交接處處理好數據傳送,這裏引用老馬的一幅圖:

我個人傾向於大 domain 的做法,我傾向(所以實際情況不是這樣的)的部署結構是:

結語

盒馬在架構設計上還在做更多的探索,在 2B+ 互聯網的嶄新業務模式下,有很多可以深入探討的細節。DDD 在盒馬已經邁出了堅實的第一步,並且在業務擴展性和系統穩定性上經受了實戰的考驗。基於互聯網分佈式的工作流引擎(Noble),完全互聯網的圖形繪製引擎(Ivy)都在精心打磨中,期待在未來的就幾個月裏,盒馬工程師們給大家奉獻更多的設計作品。

發佈了81 篇原創文章 · 獲贊 9 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章