戰略設計之上下文映射和系統分層架構
在完成了限界上下文的識別(也就是系統“最粗粒度”的模塊劃分)后,我們需要對這些上下文之間的協作關系進行分析——即“限界上下文關系映射”。也只有在完成上下文關系映射后,我們才能真正的判定自己所做出的“限界上下文識別”是否真的達到了自己想要的“低耦合、高內聚”的目標。因為,通過“限界上下文映射”我們就能夠看到:
- 這些上下文之間有哪些協作關系?
- 這些關系是強關聯還是弱關聯?
關于“限界上下文識別”和“限界上下文關系映射”,我認為這是 DDD 戰略設計中最重要的部分,甚至可以說:這兩個工作將決定了微服務切分是否有效的關鍵因素!
但是,肯定會有人說:限界上下文不用 DDD,我憑直覺就能識別出來。我的回答是:是的,你貌似可以!但更重要的是限界上下文的關系映射,這將決定做微服務拆分后、這些微服務之間是怎樣做到“高內聚、低耦合”的。如果你不用 DDD 戰略設計,我可以很負責任的告訴你:你將來的“微服務”之間的調用關系一定會變得無法控制!
在我實際工作中接觸的某大型國企 IT 系統中,所謂業務中臺上千萬行代碼,部署在十多個微服務中心,而 80%以上的外圍接口調用、或前端界面服務請求,都要從十個以上的微服務中心全部走一遍!這是不是很可怕的災難?系統的高可用性、業務需求的快速響應還有什么保障可言?
所以說:掌握好 DDD 戰略設計,幾乎是做好微服務設計必不可少的前提!不懂得 DDD,你做的“微服務拆分”可能還不如不拆分,單體應用可能更適合你!
在本節內容中,除了搞定限界上下文的映射之外,我還將對系統分層架構進行設計,并在最終給出項目組可直接用于開發的代碼框架結構。
需要特別說明的是:在寫到這篇的過程中,我反復和多位業界大拿請教,大家普遍認為我前面第三篇中業務子域的分類上“核心子域”太多了。我經過再三考慮,將“核心子域”進行了調整,這將影響到本篇關于系統分層結構的設計,您可以跳回到第三篇重新看下“業務子域識別與分類”這一小段。這里再次重申下:您在閱讀中發現我的任何不妥之處 ,可隨時與我交流,我發現不妥之處一定認真思考改進,感謝您的支持!
限界上下文映射
1.跨上下文用例識別
為了識別限界上下文之間的映射關系,我們需要對跨上下文的業務用例(也叫業務服務),從架構設計的技術視角繪制服務序列圖,進而識別它們之間的映射關系。
首先,第一步,我們識別出所有的跨限界上下文的業務用例。在識別某個業務用例是否跨限界上下文時,一定要注意兩個基本事實:1)其實大部分業務用例都是從“群買菜”小程序前端界面發起的,前端與服務端的交互,不算跨限界上下文;2)限界上下文提供的是服務端的業務服務,跨限界上下文一定要是服務端邏輯的相互關系。
關于如何識別“服務端的跨限界上下文”業務邏輯,我認為需要逐個分析前面羅列的所有業務用例,從如下兩個角度篩選:
初步分析業務用例內部的邏輯,看是否需要多個上下文來承擔職責。如果是,則需將該用例納入分析范圍;
分析業務用例圖中的被包含的“子用例”,看是否存在上下文包含了被歸類到別的上下文的情況。如果是,則需將該用例納入分析范圍;為此,識別結果如下圖羅列:
需要說明的是:有幾個用例看起來好像是跨上下文的,其實不是。分別說明如下:
- “確認購買并付款”、“創建訂單預支付”、“完成訂單支付”、“補收客戶貨款”、“退客戶貨款”,這幾個用例其實是訂單上下文和支付系統之間的關系。考慮到我們的系統上下文設計,支付系統其實不是我們目標系統的工作范圍,故這里不納入范圍。
- “后臺查詢店鋪商品”、“后臺瀏覽店鋪訂單列表”、“管理店鋪客戶信息”、“管理店鋪員工”、“開通店鋪加盟并設置分成政策”、“添加品牌店鋪到加盟列表”,看起來每個用例的名稱上都涉及到 2 個限界上下文、并都跟“店鋪”上下文發生關系,其實它們只是需要店鋪 ID,并沒有與店鋪上下文發現任何業務交互,故實際上不作為“跨上下文”的用例來對待。
其次,這些業務用例中,從跨上下文協作關系角度來看,其中有些用例畫服務序列圖是會出現重復的,我們需要識別出來:
- “創建新店鋪”、“編輯店鋪信息”,會用到短信驗證碼驗證手機號,故都屬于跨店鋪、平臺集成兩個限界上下文的,且交互邏輯一致,故重復。
- “加商品到購物車”、“選購接龍商品到購物車”,其實都是涉及到訂單和商品兩個限界上下文的相同交互關系,故重復。
- “創建接龍活動”、“編輯接龍活動”,這兩個業務用例的操作序列,從跨上下文協作關系角度來看也是完全一樣的,只是后者需加載原有“接龍”上下文內部已持久化的信息、前者不用。
- “創建付款訂單”是“確認接龍付款”的子用例,故只需要保留“確認接龍付款”即可。
- “添加品牌店鋪到加盟列表”、“從加盟列表刪除品牌店鋪”,這兩個業務用例都是涉及到店鋪和加盟兩個上下文的關系,并且都是獲取店鋪相關信息、并將店鋪信息傳輸給加盟上下文處理,故重復。
最終,我們確定下來,被用來繪制服務序列圖,進而確定各上下文協作關系的業務用例羅列如下圖:
2.基于跨上下文用例映射上下文關系
我們接下來的工作,就是要對這些業務用例,逐個進行技術分析,設計出服務序列圖,然后根據服務序列圖確定限界上下文的關系映射。
創建新店鋪
創建新店鋪涉及到手機號短信驗證,故需要跨店鋪和平臺集成兩個上下文。服務序列圖如下:
基于該服務序列圖識別出“店鋪”和“平臺集成”上下文關系如圖(C 表示服務調用客戶端、S 表示被調用服務端,以下同。DDD 標準方法中一般用“上下游關系”、及諸如“開發主機服務 OHS”等方式表達,但我認為那是廢話,并沒有清晰的表達強弱關聯,故不在這里采用):
初始化店鋪默認選項
根據產品 UI 原型的設計方案,新店鋪創建后,系統機器人需要基于異步事件,對店鋪的相關選項立即進行初始化:
- 店鋪默認門頭圖片(UI 允許創建人未設置門頭圖片);
- 店鋪是否開通訂單短信提醒(隨著業務發展可能會調整,故不放在創建店鋪時設定);
- 店鋪的第一個員工(就是創建人自己);
- 店鋪默認的加盟分銷政策(隨著業務發展可能會調整,故不放在創建店鋪時設定);
- 店鋪的首個接龍地點(詳見產品 UI 原型,店鋪接龍支持多個地點,但首個地點就是店鋪地址);
- 創建商家及商家賬戶(如果是創建人的第一家店鋪);
- 店鋪默認商品分類(系統規則會按季節調整);
- 店鋪默認的商品搜索熱詞(系統后臺會不定期根據某種策略更新);
這些默認選項,涉及到“店鋪”、“員工”、“商品”、“接龍”、“商家賬戶”、“鑒權”共 6 個上下文的協作。服務序列圖涉及如下:
根據該服務序列圖,我們得出這 6 個限界上下文的協作關系如下圖:
從上圖的上下文依賴關系中,看到店鋪對員工、商品、接龍、商家賬戶 4 個上下文產生了調用關系依賴,這是不合理的。因為從業務角度來說,其實是后 4 者依賴于店鋪的存在而存在,而不是反過來。為此,我們做這樣的調整:
- 采用消息發布者/訂閱者模式,讓后 4 者依賴店鋪上下文發布的“店鋪已創建”消息;
- 去掉“接龍”上下文對店鋪首個接龍地點初始化的邏輯。本質上,仔細分析產品 UI 界面設計的要去,我們發現這其實是“群買菜小程序”在展示創建接龍活動的前端界面時,需調用“店鋪”上下文服務來獲取的信息:如果所選擇店鋪已經有接龍地址,則返回已有的接龍地址列表供選擇;如果該店鋪尚無首個接龍地址,則返回店鋪地址作為默認接龍地址。
經過修改后的服務序列圖如下:
根據該服務序列圖,我們修改上下文的協作關系如下圖(P 表示消息發布者,S 表示消息訂閱者,下同):
加商品到購物車
客戶瀏覽店鋪商品,選中感興趣的商品到購物車,以便后續的購買結算。考慮到添加商品到購物車中去的“時間”特殊性:我們需要在客戶將商品加入到購物車時,為商品創建“快照”,以避免商品信息在后面被編輯修改(比如改了圖片或描述、尤其是價格)時,影響到對客戶的購買承諾。
為此,該業務用例(服務)就涉及到“訂單”和“商品”兩個上下文,服務序列圖設計如下:
該序列圖展示出訂單和商品的上下文關系如圖:
發送訂單提醒
“發送訂單提醒”需要在訂單上下文中發起、“通知”上下文中發送通知,故涉及“訂單”和“通知”兩個上下文。該用例服務序列圖如下:
考慮到訂單消息提醒是沒有副作用的、而且也不需要保證必須成功,故采用消息發布者/訂閱者模式比較合適。也就是得到如下所示的“訂單”和“通知”上下文的映射關系:
創建接龍活動
與“加商品到購物車”用例類似,商家在創建接龍活動、添加商品到接龍中去時也存在“時間”特殊性,需要為商品創建“快照”,以避免商品在后面被編輯修改(比如改了圖片或描述、尤其是價格)時,影響到接龍活動的開展。為此,該業務用例(服務)就涉及到“接龍”和“商品”兩個上下文,服務序列圖設計如下:
該序列圖展示出接龍和商品的上下文關系如圖:
瀏覽我的接龍
按照產品 UI 設計文檔,接龍只能在店鋪下存在。而“瀏覽我的接龍”實際上是“瀏覽我被授權操作的所有店鋪發布的、或其被授權店鋪所加盟品牌店鋪發布的、或我參與的”的所有接龍。故該用例涉及到“接龍”、“店鋪”兩個上下文,服務序列圖如下:
該服務序列圖展示出,實際上“接龍”、“店鋪”這 2 個上下文沒有發生關聯關系。但這個服務序列圖設計,有個“壞味道”的感覺:讓群買菜小程序客戶端承擔了過多業務邏輯,這是不合理的。于是,我們將服務序列圖調整為如下:
該服務序列圖會導致如下的 2 個上下文之間的關系:
確認接龍付款
確認接龍付款從產品界面原型可以看出,確認接龍付款是從“查看接龍詳情”界面發起的。客戶在該界面上點擊相應的商品加入購物車、或從購物車移除,然后點擊“我要接龍”按鈕進入該用例。
該用例允許用戶設置提貨方式、提貨時間、聯系人等信息后,點擊“確認付款”按鈕完成支付。該按鈕點擊后,按照產品原型設計,需要完成如下任務:
- 創建訂單;
- 更新關聯商品的銷量,以便于后續商品列表和詳情頁面顯示商品銷量;
- 如果下單客戶首次購買對應店鋪商品,則為該店鋪初始化對應客戶資料,以便于商家后續維護客戶資料;
- 記錄客戶參與了該接龍,以便于客戶“瀏覽我的接龍”時,可包含該接龍;
根據上面的邏輯,我們畫出服務序列圖設計如下:
該服務序列圖展示的相關限界上下文關系如下圖:
這里可以看到上下文之間的調用關系比較多,并且“訂單”與“商品”、“訂單”與“客戶”之間,還存在數據一致性的要求,不利于系統的“松耦合”。為了降低上下文之間的耦合性,我們分析業務需求發現:其實“訂單創建”后“增加商品銷量”、以及“為指定店鋪初始化客戶”是可以有一定的數據延遲的,并不需要通過“強”服務調用關系保障嚴格的數據一致性。為此,我們將服務序列圖修改如下:
根據改進后的服務序列圖,可以調整上下文映射關系如下圖:
結算訂單收入
結算訂單收入分為兩步:第一步,在客戶確認訂單完成、或機器人超時自動確認訂單完成時,訂單上下文通知商家賬戶上下文記錄待結算的訂單 ID(由于訂單上下文缺乏“判斷什么訂單屬于待結算狀態”這樣的領域知識,故只能由商家賬戶上下文來記錄);第二步,系統機器人定時觸發商家賬戶上下文對待結算訂單進行收入結算。服務序列圖設計如下:
其中第一步的操作,可以和“發送訂單提醒”中的“訂單已完成”領域事件合并處理。不過,因為這里是不可丟失的領域事件,所以這就要求采用“可靠事件模式”。基于該服務序列圖,識別出“訂單”和“商家賬戶”上下文的關系如圖:
結算傭金收入
結算傭金收入可以和結算訂單收入同時進行,也可以分開在兩個用例中異步執行。考慮到為了將來支持允許品牌商在加盟政策中設置結算周期,故將其分開在兩個業務用例異步執行。為此,設計其服務序列圖如下:
該序列圖展示出商家賬戶和訂單的上下文關系如圖:
3.限界上下文映射圖
我們將上面針對各跨上下文業務用例分析后,得到的上下文映射關系進行匯總后最終得出下圖:
圖中實線是服務調用關系,屬于“強關系”;虛線是消息通知關系,屬于“弱關系”。
通過該限界上線文映射關系可以看出:我們總共有 9 個限界上下文,其中有強依賴關系的涉及到 9 個、強依賴關系鏈有 9 條。分別是:接龍依賴于店鋪、商品和訂單,店鋪依賴于平臺集成,訂單依賴于商品,商家賬戶、員工、客戶依賴于鑒權,商家賬戶依賴于訂單。9 個上下文,理論上最多有 72 條依賴鏈條,我們分析匯總后只有 11 條,已經是很好的設計。
基于這樣的綜合評估,我們認為目前的設計已經到了“可接受程度”,暫時不再做更多的優化和調整了,以避免“過度設計”。
系統架構和代碼框架
1.業務子域與上下文的映射
完成了限界上下文的關系映射,其實就有了對整體系統架構進行分層設計的基礎。系統整體架構設計從分層角度來看,包括:邊緣層、業務價值層、基礎層。邊緣層一般都是各種針對前端 UI 的控制器,業務價值層和基礎層包含所有的限界上下文,其中基礎層放的是對應到支撐子域、通用子域的限界上下文,而核心子域對應的限界上下文作為業務價值層。
為了區分業務價值層和基礎層,我們先將前面得出的業務子域(見第三篇中全局分析內容)、與限界上下文進行如下表所示的關系映射:
需要說明的是:其中“商家管理”和“店鋪管理”雖然是支撐子域,但由于分別被合并到“商家賬戶”、“店鋪”上下文來開發實現,故其中邏輯的代碼實現也被劃分到了“業務價值層”。
2.菱形對稱架構簡介
在對整個系統進行“分層架構設計”之前,我們還需要先熟悉一個軟件架構模式——菱形對稱架構(以下簡稱菱形架構),如下圖所示:
對“菱形架構”的說明如下:
菱形架構的基礎,是依賴于“領域層”的界定。這涉及到后面才會進行的 DDD 戰術設計內容,這里不做展開,您只需要知道:“領域層”放的是業務領域的關鍵業務知識,包括“聚合(內含實體對象、值對象)”和“領域服務”兩類內容。事實上,我們所追求的“高內聚”主要體現的“領域層”,因為業務需求變化而引起的變化,我也希望主要通過“領域層”的“聚合”和“領域服務”的變化來實現。所以說,“領域層”是 DDD 的“核心代碼所在地”。
所有非“領域邏輯”層的代碼,我們都希望封裝到“北向網關”或“南向網關”中去。具體說明如下:
“北向網關”其實就是上下文向外提供服務、或接受消息通知的入口處。如果上下文只需要向外提供同一個進程內部的應用服務調用接口、或接受消息通知(通過消息總線),則需要“本地”服務;如果上下文需要作為獨立進程(這時候一般是云原生的獨立“微服務”)向外輸出服務、或接受消息通知(通過消息中間件),則需要將“本地服務”包裝為“遠程”服務。
也就是說:北向網關中的“本地服務”和“遠程服務”是一一對應的,“遠程服務”只負責將遠程調用的出入參做格式轉換并傳給“本地服務”,而“本地服務”負責調用領域層的“聚合”或“領域服務”來完成具體的業務邏輯(關于“聚合”和“領域服務”的內容,我會在本專題后面的章節中演示)。
“南向網關”其實就是上下文用來將這 3 類技術細節從業務邏輯中“剝離”出來的主要手段(圖中只畫了第一種):訪問底層數據庫、調用其它上下文服務、向其它上下文發布消息通知。這 3 個技術細節的封裝,在 DDD 戰術設計中分別叫“資源庫”、“客戶端(也可以叫防腐層——ACL)”、“消息發布者”。
從圖中可以看出,“南向網關”有“端口”和“適配器”兩種角色。簡單點來說,“端口”是抽象接口(以 java 為例就是 interface),而“適配器”則是“接口實現”(以 java 為例就是實現了對應 interface 的類)。并且,一般來說“適配器”都是通過依賴注入(DI,控制反轉 IoC 的一種)的方式集成到限界上下文的代碼中(java spring 中一般是 bean 注入)。
“南向網關”區分“端口”和“適配器”兩個角色的好處:一方面是可以讓限界上下文內的任何代碼都不直接依賴于具體的底層技術細節,如:采用哪種數據庫(oracle 還是 mysql、甚至 nosql 數據庫)、怎么調用其它上下文服務(本地調用還是遠程 RPC)、怎么發布消息通知(本地消息總線、還是消息中間件);另一方面的好處是允許我們隨時將構建在一個“微服務”甚至“單體應用”中的多個限界上下文進行拆分到多個進程(即多個“微服務”中),而并不會引起“領域層”、以及“北向網關”的任何代碼修改(只要替換并重新打包被依賴注入的“適配器”類即可)。
很明顯可以看出,“菱形架構”其實是限界上下文內部所采用的一個“軟件架構模式”。而在整個系統范圍內,因為包含多個限界上下文,DDD 設計理念并沒有要求所有的上下文都嚴格遵循“菱形架構”——而完全可以根據實際需要(尤其是“基礎層”的上下文),視情況而采用其它架構模式(如 MVC 三層架構、大數據計算架構等)。
3.系統分層架構圖
有了前面的將上下文分為“基礎層”和“業務價值層”、以及菱形架構概念的基礎,再考慮到“群買菜”系統前端界面針對 3 類用戶:商家、客戶、平臺運營,前兩者使用微信小程序,最后一個使用 PC 端,以及相應“伴生系統”的協作邊界。我們最后畫出系統架構分層設計圖如下:
對于上圖,需要特別說明的是:
其中騰訊地圖、短信系統、微信公眾號因為不涉及到業務流程,只是一個功能點,故前面的業務子域、上下文、業務用例都沒有展現。
有的上下文有“事件訂閱”。這是根據我們前面“限界上下文映射圖”中的描述,需要進行消息訂閱的上下文才有。
其中比較特殊的一點:“平臺集成上下文”沒有“領域層”,這是因為它主要完成對微信公眾號接口、短信接口、微信開放平臺接口的封裝,并不存在 DDD 戰術設計所需要的“實體”對象這一“領域層”必須的基本要素。
4.代碼框架結構
基于前面的系統分層架構設計,結合菱形架構模式,我們給出目標系統的如下代碼框架結構(采用 java spring 框架開發):
對上圖中各目錄的劃分和命名說明如下:
foundation 目錄存放的是“基礎層”限界上下文,valueadded 目錄存放的是“業務價值層”限界上下文,edge 目錄存放的是“邊緣層”限界上下文;
本質上,每個限界上下文都可以作為獨立的 java 工程存在。但因為本項目由我一個人開發,所以就沒有劃分工程了;
每個限界上下文,采用“上下文名稱+context”命名風格。如:“訂單上下文”命名為 ordercontext;
每個限界上下文中,其內部目錄結構說明如下:
north 目錄存放的是“北向網關”的內容,包括 local 和 remote 子目錄,分別對應北向網關的“本地服務”和“遠程服務”。有的上下文的 north 目錄下有 subsrciber 子目錄,是為了存放消息訂閱者代碼的。
south 目錄存放的是“南向網關”的內容,包括 port 和 adapter 子目錄,分別對應南向網關的“端口”和“適配器”。
domain 目錄存放的是“領域層”內容,也就是核心的業務邏輯所在。
pl 目錄存放的是“發布語言”,其實就是北向網關的“本地服務”向外提供服務時,允許外部調用服務所使用的出入參對象類型。之所以將出入參對象類專門設定一個目錄來管理,是因為這些出入參其實是和 domain 目錄下的“實體類”是不同的,我們不希望將“領域層”內部的業務邏輯暴露出去(也就是不將“實體類”的業務邏輯暴露出去)。
業務價值層有個 sharedcontext,這是因為考慮到代碼復用、可能會出現某些“值對象”、“發布語言(即服務接口出入參)”類被多個上下文共用,具體細節我在后面的戰術設計中會講到。
對于“群買菜”系統來說,由于小程序前端界面已經存在,本次是服務端做 DDD 改造,故不打算對前后端交互接口進行調整。為此,我為其設計了“邊緣層”,也就是 BFF 層。這就是 edge 目錄存放的代碼內容。