限界上下文—邏輯邊界or物理邊界?
邊界通過限界上下文來確定,這在領域驅動設計中具有非凡的意義。對應于通用語言,限界上下文是語言的邊界,對于領域模型,限界上下文是模型的邊界,二者對應于問題空間(Problem Space)的界定。對于系統的架構,限界上下文還確定了應用邊界和技術邊界,進而幫助我們確定整個系統及各個限界上下文的解決方案。可以說,限界上下文是連接問題空間與解決方案空間的重要橋梁。
那么,限界上下文所界定的邊界,究竟是邏輯邊界,還是物理邊界?這并沒有定論,需得依據不同場景而做出不同的決策。
邏輯邊界
根據業務對領域進行邏輯分解時,分與合是兩個矛盾而又統一的概念。合是目標,分是降低復雜度的一種手段。分實則是為了更好的合。通過業務分解,每個分解出來的限界上下文規模就變得更小,因而更容易理解和把控。由于這種分解是從業務相關性來考慮的,使得領域可以更加細分,業務分析師或者領域專家就可以只要求掌握更加細分的專精領域。
從系統的代碼模型(Code Model)看,所謂邏輯邊界有兩種表現形式。以Java為例,歸納如下:
- 命名空間級別:邏輯邊界僅僅通過命名空間進行界定,但是所有的限界上下文其實都處于同一個模塊中,編譯后都屬于同一個Jar包。
- 模塊級別:在命名空間上是邏輯分離的,而不同限界上下文則屬于同一個項目的不同模塊,編譯后會生成各自的Jar包。若限界上下文之間存在依賴,則在運行時,這些Jar會被同時加載到同一個Java虛擬機中。這里所謂的“模塊”,在Java代碼中也可以創建為Jigsaw的module。
將限定上下文的邊界視為邏輯邊界是最常見也是最簡單的一種形式。一方面邏輯的分離可以保證系統代碼的清晰結構,另一方面它也使得限界上下文之間的協作變得更加容易,更加高效。在物理上,限界上下文彼此之間的通信其實是無縫集成的,要重用的領域模型都可以直接訪問,并對模型類進行實例化。如下是國際報稅系統的邏輯邊界(Java):
然而,正所謂越容易重用,就越容易產生耦合。編寫代碼時,我們需要謹守這條無形的邏輯邊界,時刻注意不要逾界,并確定限界上下文各自對外公開的接口,避免對具體的實現產生依賴。
采用邏輯邊界劃分限界上下文的系統架構是單塊(Monolithic)架構,所有的限界上下文都部署在同一個進程中,因此不能針對某一個限界上下文進行水平伸縮。需要對限界上下文的實現進行替換或升級時,會影響到整個系統。即使我們守住了邏輯邊界,這種耦合仍然存在,導致各個限界上下文的開發互相影響,團隊之間的協調成本也隨之而增加。
物理邊界
邏輯邊界的壞,正是物理邊界的好;反過來,物理邊界的壞,同樣是邏輯邊界的好。當我們將限界上下文的邊界定義為物理邊界時,每個限界上下文就變成了一個個細粒度的微服務。
這里,我們需要針對Eric Evans提出的“限界上下文”概念做進一步澄清:限界上下文究竟是僅僅針對領域模型的邊界劃分,還是對整個架構(包括基礎設施層以及需要使用的外部資源)垂直方向的劃分?正如前面對Eric Evans觀點的引用,他在《領域驅動設計》一書中明確地指出:“根據團隊的組織、軟件系統的各個部分的用法以及物理表現(代碼和數據庫模式等)來設置模型的邊界。”顯然,限界上下文不僅僅作用于領域層和應用層。它是架構設計而非僅僅是領域設計的關鍵因素。
倘若我們將限界上下文的邊界視為物理邊界,則可以保證邊界內的服務、基礎設施乃至于存儲資源、中間件等其他外部資源的完整性,最終形成自治的服務。限界上下文之間僅僅通過限定的方式以限定的通信協議和數據格式進行通信,除此之外,彼此沒有任何共享,這種架構被稱之為零共享架構。這種架構的表現形式為:每個限界上下文都有自己的代碼庫、數據存儲以及開發團隊,每個限界上下文選擇的技術棧和語言平臺也可以不同。當每個限界上下文都被物理隔離時,一個限界上下文的開發人員就不能調用另一個限界上下文的方法,或者將數據存儲在共享結構中了,這可以避免因為共享帶來的耦合。下圖為危機分析系統的架構:
物理分隔開的限界上下文變得小而專,使得我們可以很好地安排遵循2PTs規則的小團隊去治理它。然而,這種架構的復雜度也不可低估。限界上下文之間的通信是跨進程的,我們需要考慮通信的健壯性。數據庫是完全分離的,當需要關聯之間的數據時,需得跨限界上下文去訪問,無法享受數據庫自身提供的關聯福利。由于每個限界上下文都是分布式的,如何保證數據的一致性也是一件棘手的問題。當整個系統都被分解成一個個可以獨立部署的限界上下文時,運維與監控的復雜度也隨之而劇增。
數據庫共享
在邏輯邊界和物理邊界中間,還存在一種折中的手段。在考慮限界上下文劃分時,分開考慮代碼模型與數據庫模型,就可能出現在代碼上分離,而在數據庫層面卻存在數據共享的形式,即多個限界上下文共享同一個數據庫。
因為沒有分庫,在數據庫層面就可以更好地保證事務的ACID。這或許是該方案最有說服力的證據,但也可以視為是對“一致性”約束的妥協。
數據庫共享的問題在于數據庫的變化方向與業務的變化方向會不一致。這種不一致性體現在兩個方面:
- 耦合:雖然業務上限界上下文之間是解耦的,但是在數據庫層面依然存在強耦合關系
- 水平伸縮:部署在應用服務器的應用服務可以根據限界上下文的邊界單獨進行水平伸縮,但是在數據庫層面卻無法做到
根據Netflix團隊提出的微服務架構***實踐,其中一個最重要特征就是“每個微服務的數據單獨存儲”。但是服務的分離并不絕對代表數據應該分離。數據庫的樣式(Schema)與領域模型未必存在一對一的映射關系。在對數據進行分庫設計時,如果僅僅站在業務邊界的角度去思考,可能會因為分庫的粒度太小,導致不必要的跨庫關聯。因此,我們可以將“數據庫共享”模式視為一種過渡方案,不要在一開始設計微服務的時候,就直接將數據徹底分開,而是采用演進式的設計。
為了便于在演進設計中將分表重構為分庫,從一開始要注意避免在兩個表之間建立外鍵約束關系。某些關系型數據庫可能通過這種約束關系提供級聯更新與刪除的功能,這種功能反過來會影響代碼的實現。一旦因為分庫而去掉表之間的外鍵約束關系,需要修改的代碼太多,會導致演進的成本太高,甚至可能因為某種疏漏帶來隱藏的Bug。
沒有外鍵約束關系可能在當前增加了開發成本,卻為未來的演進打開了方便之門。例如,在針對某手機品牌開發的輿情分析系統中,危機查詢服務提供對識別出來的危機的查詢,需要通過userId獲得危機處理人、危機匯報人的詳細信息。左圖為演進前直接通過數據庫查詢的方式,右圖則切斷了這種數據庫耦合,改為服務調用的方式:
倘若架構被設計為數據庫共享,且兩個服務需要操作同一張數據表(這張表被稱之為“共享表”),則傳遞了一個信號,即我們的設計可能出現了錯誤:
- 遺漏了一個限界上下文,共享表對應的是一個被重用的服務:買家在查詢商品時,商品服務會查詢價格表中的當前價格,而在提交訂單時,訂單服務也會查詢價格表中的價格,計算當前的訂單總額;共享價格數據的原因是我們遺漏了價格上下文,通過引入價格服務就可以解除這種不必要的數據共享。
- 職責分配出現了問題,操作共享表的職責應該分配給已有的服務:輿情服務與危機服務都需要從郵件模板表中獲取模板數據,然后再調用郵件服務組合模板的內容發送郵件;實際上從郵件模板表獲取模板數據的職責應該分配給已有的郵件服務。
- 共享表對應兩個限界上下文的不同概念:倉儲上下文與訂單上下文都需要訪問共享的產品表,但實際上這兩個上下文需要的產品信息是完全不同的,應該按照限界上下文的邊界分開為產品建表。
為什么會出現這三種錯誤的設計?根本原因還是在于我們沒有通過業務建模,而是在數據庫中隱式地進行建模,因而在代碼中沒有體現正確的領域模型,從而導致了數據庫層面的耦合或共享。
【本文為51CTO專欄作者“張逸”原創稿件,轉載請聯系原作者】