領域驅動模型DDD(一)——服務拆分策略

  • 2022 年 3 月 23 日
  • 筆記

前言

領域驅動模型設計在業界也喊了幾年口號了,但是對於很多「務實」的程式設計師來說,紙上談「術」遠比敲程式碼難得太多太多。本人能力有限,在拜讀相關作品時既要隱忍書中晦澀難懂的專業名詞,又要去迎合西方大牛在撰寫的過程中融入的西式故事。我想總會有一部分水平和我類似的碼農們,需要一份對系統闡述DDD小白文化的文本。因此,本人便自不量力地結合一些簡單的項目經驗,將領域驅動模型設計思想從理解到落地的實施和總結分享給諸位。當然,如果是某些行業先鋒不幸看到本人稚嫩的文字時,就當作是馬戲團中的小丑,一笑了之翻頁便可。

思維的入門

在學習架構思想的初期,特別時面對架構模型時(例如六邊形架構、領域服務拆分),我總會不自然地在對號入座,思考在模型中的這一塊放到程式碼實現上是Controller層還是Dao層,是採用消息中間件還是NoSQL快取。這種自動聯想的「被動技能」在學習微服務設計架構思想的過程中是致命的,過於專註業務的技術實現而脫離架構思想本身的大局觀,就容易陷入用「具體」無法概括「抽象」的處境。

對此,讓我們先忘了平日在開發中使用的各種被分類到細緻入微的技術,帶著一種閱讀「無用」文學而非可以模仿實操的工具書的心態一起來對領域驅動設計做一個基礎性的理解。

1 常用的服務拆分方法

1.1 根據業務能力進行服務拆分

創建微服務架構的策略之一就是採用業務能力進行服務拆分,這也是目前市面上大部分產品設計初期採用的方式。主要原因是這種方法對於架構師來說是比較容易實施的,就好像在做一個分類遊戲,關於用戶的註冊登錄以及資訊管理可以劃分為一個業務,關於文章的發布下架以及內容更迭可以劃分為一個業務。

這樣的分類手段核心是以業務活動進行劃分,也保證了大部分面向對象的程式設計師在程式碼實現時可以更快地、更明確地創建實體類,從而開闢出一個個針對不同業務功能的微服務。

倘若我們以程式設計師盤踞的技術論壇/部落格為例,那麼它們必須要實現最基礎的三大業務:1、用戶的註冊登錄以及用戶對個人資訊的操作;2、用戶可以發布/修改/刪除自己的文章;3、網站的後台管理員可以對前面兩者進行更高許可權的操作。至於更多的用戶評論、用戶私聊、vip充值等一些功能則是拓展,畢竟沒有它們也不會影響到整個網站的正常運作,因此暫不加入討論。

所以根據上述最基礎的三大業務,如果要進行微服務架構設計(當然現實中不會有為了如此簡陋的網站採用微服務,不然會被從業的程式設計師背地挖苦痛罵),我們可以畫出相應的映射圖【圖1-1】:

採用業務能力進行拆分固然方便了架構師和程式設計師,在應對一般的企業項目時,這種方式是非常穩妥的方案,架構中每個劃分出的服務內部後期可以根隨功能的需求增加而逐漸迭代(例如,我現在需要文章發布時可以附帶圖片,那麼可以在實現文章業務的服務中添加相關的介面與具體實現程式碼),但是整體的項目架構是保持不變的。

話雖如此,但讀者們要知道上面僅僅是為了實現技術部落格網站最基本業務而定義的「初代」架構。一種情況是隨著整個技術部落格網站的日益龐大,為了迎合用戶的需要,我們不得不擴展網站的功能性,例如增添用戶對文章點贊/收藏/留言,後台對文章內容是否健康的審核等等,這時候初代被劃分好的服務內部就會日益「龐大」,需要我們重新操刀對其進行分解切割。

另外一種情況是由於用戶量的提升,致使服務之間的遠程調用、進程間的通訊次數驟增而導致請求響應效率逐步低下,例如擁有龐大數量的讀者用戶在訪問文章時不僅要通過文章服務獲取文章內容,還要通過文章的作者每次調用用戶服務來獲取作者個人資訊簡介(在沒有快取機制,每個服務根據業務主體分割明確的假設情況),我們又得考慮把一些服務組合在一起:

例如當我們現在常用的註冊/登錄手段——利用第三方服務進行簡訊驗證,如果只是為了實現用戶註冊/登錄功能,完全可以併入到用戶的服務中【圖1-2】:

一旦不單用戶登錄/註冊情況用到簡訊驗證,例如要充值時候也要用到簡訊驗證支付,那麼就得把它獨立出來【圖1-3】:

上面的問題正是以業務進行服務拆分時難以避免的,因為業務必然是不斷發展的(參考市面上眾多臃腫的軟體),被繁雜的業務牽著鼻子走的架構模式必然會陷入到不停拆分或重組的境地,那麼服務內部以及服務之間的關係也將逐步模糊紊亂。

1.2 根據子域進行服務拆分

即使這是一段很枯燥的歷史,但為了感謝曾在這個領域躬耕的技術先驅們我還是要帶上這段文字:Eric Evans在他的經典著作中(Addison-Wesley Professional)提出的領域驅動設計是構建複雜軟體的方法論,這些軟體通常都是以面向對象和領域模型為核心。

既然是方法論,它提供的是「怎麼辦」的理論體系。類比蛋炒韭菜,我所能提供的是做這道菜需要什麼工具和佐料,放油放蛋放鹽放韭菜的先後順序和比例,至於真正做起來火候的大小,炒菜的姿勢,蛋菜的克量等都是視情況而定,並沒有固定的要求。這又回到我在「思維入門」中提到的,不要用機械的「具體」去反推「抽象」理論,因為炒菜的姿勢最多能影響的是菜的口味,並非是完成這道菜的必要條件。

下面讓我們來理解DDD中重要的兩個概念:子域和界限上下文。我先以好理解的方式在各位腦海中勾勒出這兩者的基本認識,然後各位再去看專業解釋會好接受的多。

如果把一個項目業務比作一個國家整體,而子域相當於國家內部的各個省份,這些省份細緻地劃分了國家每個地域面積大小和居住人群,而所有省份的聚合又構成了整個國家。作為限界上下文,其英文為bounded context,可以直譯成「有界的環境」,那麼套入前文各位可能舉一反三理解成「省界」。但事實並非如此,以「省份」類比子域的話,省界並非是限制人口流動的主要原因,限制人流的本質在於一個省份有一個省份的風土人情和文化語言,例如廣東省用粵語交流,福建省用閩南語交流,這些語言在省份內部是大家達成共識都能理解的,但跑到廣東說福建話只會讓本地人摸不著頭腦。

因此,每個省份(子域)之間產生的交流障礙歸根於它們各自內部通用語言環境不同(限界上下文)。這個舉例或許不符合社會學對於人口流動的分析,但拿來解釋子域和限界上下文還是很貼切的。

那麼讓我們引入教科書對二者的解釋來鞏固諸位對它們的記憶:子域是領域的一部分,領域是DDD中用來描述應用程式問題域的一個術語。識別子域的方法和識別業務能力一樣:分析業務並識別業務的不同專業領域,分析產生的子域定義結果也會和識別業務能力得到的結果非常相近。DDD把領域設計模型的邊界成為界限上下文,當使用微服務架構時,每一個界限上下文對應一個或則一組服務,我們可以通過DDD方式定義子域,並把子域對應為每一個服務【圖1-4】。

1.3 拆分單體應用的難點

1.網路延時

網路延時是分散式系統中一直存在的問題。不論是以業務進行拆分還是子域進行拆分,對服務不斷細化分解會導致各個服務之間的大量往返調用。即使可以通過批量處理API在一次往返中獲取多個對象,從而減少延時。但是在其他情況下,解決方案是把多個服務整合到一起,用變成語言中的函數調用替換昂貴的進程通訊。

2.同步進程間通訊導致可用性降低

舉一個最簡單的例子,當我們在某東下單一件商品時創建了訂單,創建訂單的過程中需要獲取商品的詳細資訊和購買者的詳細資訊,而這其中有一個服務出現了不可用的狀態就會導致整個業務創建失敗,這種同步進程通訊帶來的可用性降低讓我們不得不折中採用非同步消息進行處理。

3.服務之間維持數據的一致性

當我們對服務進行拆分後,服務之間如何保持數據一致性成為重點和難點。還是以某東為例,在活動期間大量用戶可能在同一刻參與了秒殺活動,而對於倉存服務與商品服務之間如何保證它們在數量的一致性(比如前台有100個用戶下單,那麼對於商品來說只需要在原有的數量上減去100然後返回給前端頁面作為商品資訊的一部分及時展示給用戶還有多少存貨便可,但是實際上真正需要扣減的應該發生在倉庫服務中,因為倉庫服務記憶體儲的才是實際庫存),怎麼讓一波狂歡後實際庫存與前端保持一致是業務中必須攻克的難題。

4.上帝類阻礙了服務的拆分

分解的一部分障礙就是所謂的上帝類,即全局類或則是「公用」類。上帝類通常為應用程式不同方面實現業務邏輯。

以美團外賣業務舉例,一個不經思考設計的訂單類中會將以下所有資訊屬性直接構建成一個類:商家資訊屬性(商家名稱、商家地址等)、用戶資訊屬性(賬戶、昵稱、地址等)、外賣商品屬性(外賣商品名稱、價格、配料等)、配送方屬性(配送員身份、配送員電話號碼、配送起始時間、配送截止時間等)。不過這些屬性涉及了不同服務中的應用程式,導致了商品系統中必然存在的訂單所包含的資訊變得特別龐大,並且與其他服務之間因為共同的屬性保持著「曖昧」的聯繫。

一種解決方法是在資料庫中創立一個公用的訂單資料庫,處理訂單的所有服務使用此資料庫,但這就出現了「緊耦合」的情況。

對此應用DDD將每個服務視為「孤島」般的子域(盡量不與其他服務發生在屬性的上糾葛,形成一座具有特色便於識別的「孤島」)。所以讓我們看看自己手機上美團APP中客戶訂單,然後再看看拿到外賣時釘在外賣上的商家收到的訂單,幸運的話再看看外賣小哥送外賣時接收的訂單,它們所包含的資訊內容和資訊數量絕對是不同。

這代表著在商家子域、用戶子域、配送子域中我們根據子域的不同定義了不同側重點的「訂單」,而不是一股腦地都塞進全局訂單類里讓不同服務進行共享。也正是不同側重點的「訂單」只有在自己子域中才是「有效的」,「能被讀懂的」(總不可能讓商家去看外賣小哥的訂單資訊,用戶去看商家的訂單資訊),子域便有了與之對應的界限上下文。

這一章的基礎內容便到此結束,下一章可能會講一下服務的進程通訊或則是Saga管理事務,盡情期待。