構建領域驅動的微服務

構建領域驅動的微服務

加速架構學習!

譯自:Building Domain Driven Microservices

微服務的定義

微服務中的術語”微”傳達了一個服務的大小,但這不是將一個應用變為微服務的唯一準則。當團隊轉變到基於微服務的架構時,需要提高敏捷性(自動部署和頻繁發布)。很難對微服務架構的風格做一個準確的定義。我傾向於Adrian Cockcroft 的定義:”由松耦合且具有邊界上下文的元素構成的面向服務的架構”。

雖然它給出了一個啟發式的高層設計,但微服務架構具有一些獨特的特徵,使其有別於以往的面向服務的架構。下面展示了部分特性,更多可以參見Martin Fowler的文章Sam Newman的構建微服務

  1. 服務具有圍繞業務上下文(而不是圍繞對任意技術的抽象)定義的邊界。
  2. 隱藏實現細節,並通過帶目的性的介面暴露介面。
  3. 服務不會在邊界外暴露內部結構,例如,不會共享資料庫。
  4. 服務具有容錯能力。
  5. 各個團隊的功能是獨立的,且可以獨立發布。
  6. 團隊擁抱自動化文化。例如,自動化測試,持續集成和持續發布。

我們對這種架構風格概括如下:

松耦合的,面向服務的架構,每個服務都有明確的邊界上下文,可以快速、頻繁、可靠地發布應用。

領域驅動設計和邊界上下文

微服務的能力在於可以明確地定義各個服務的職責,並劃清服務之間的界限。目的是實現邊界內的高內聚以及邊界外的低耦合。即,把傾向於共同變動的服務歸為一類。但就像生活中的很多問題一樣,說起來容易,做起來難(涉及到業務以及各種可能的變化)。因此在設計系統時還應該考慮到重構能力。

領域驅動設計(DDD)是設計微服務的關鍵,它可以幫助拆分一體式架構,或構建一個全新的項目。領域驅動設計是Eric Evans 在他的中提出的一些列理念、原則和模式,用於幫助設計基於底層業務領域的軟體系統。開發者和領域專家使用一種通用的語言來共同構建業務模型,然後將其綁定到到有意義的系統上,並在這些系統與從事這些服務的團隊之間建立協作協議。更重要的是,他們設計了系統之間的輪廓或邊界。

微服務從這些概念中汲取了靈感,所有這些概念都可以幫助構建支援獨立變更和演化的模組化的系統。

在繼續後面的內容之前,先快速回顧一下基本的DDD術語。對領域驅動設計的完整概述超出了本文的範疇。強烈推薦參考Eric Evans的來構建微服務 。

:表示一個組織,在下面的例子中為零售(Retail)或電子商務(eCommerce)。

子域:一個組織中的組織或業務單元。一個域由多個子域構成。

通用語言:用於表示模型的語言。下例中,每個子域中的Item就是通用語言模型。且開發者,產品經理,領域專家以及業務利益相關者都認同該語言,並在交付件(程式碼,產品文檔等)中使用該語言。

Fig 1. Sub-domains and Bounded Contexts in eCommerce domain

邊界上下文

領域驅動設計中將邊界上下文定義為”可以使用一個單詞或一句話確定其內部含義的配置”。換句話說邊界也是一個模型。在上面例子中,”Item”在不同的上下文中的意義不同。在Catalog 上下文中,一個Item意味著暢銷產品;在Cart上下文中,意味著客戶向購物車添加了商品;在Fulfillment 上下文中,意味著會把一個Warehouse商品送到客戶手中。每個模型都是不同的,且每個模型都有不同的意義,可能包含不同的屬性。通過對這些模型進行區分,並將它們隔離在各自的邊界內,我們可以清晰地表達這些模型。

注意:理解子域和邊界上下文非常重要。子域屬於問題空間,即業務是如何看待問題的。而邊界上下文用於解決空間問題,即如何實現方案來解決問題。理論上,每個子域可能會存在多個邊界上下文,但我們盡量將子域中的邊界上下文限制為一個。

微服務如何關聯邊界上下文

應該把微服務放到哪裡?可以認為一個邊界上下文對應一個微服務嗎?可以,但也不可以。下面看看為什麼會這樣。在很多情況下,邊界上下文的邊或輪廓會非常大。

Fig 2. Bounded context and microservices

考慮上例。Pricing 邊界上下文由三個不同的模型–Price,Priced items,和Discounts。分別負責一個商品的價格,一系列商品的總價以及應用折扣等。我們創建了一個單獨的系統,並包含了上述所有模型,但也可能會成為一個不合理的大型應用程式 。如前面所述,每個數據模型都有其不變數和業務規則。隨著時間的推移,如果我們不夠謹慎,該系統可能會變成一個邊界模糊的”大泥球”,且職責重疊,有可能導致會退回到一開始的地方–一體式模型。

另一種對該系統建立模型的方式是對相關的模型進行分離或分組,成為獨立的微服務。在DDD中,這些模型(Price, Priced Items, 和Discounts)稱為聚合。聚合是一個由相關模型組成的自包含的模型。可以通過一個公開的介面修改某個聚合的狀態,聚合會保證一致性以及不變數的執行。

通常,一個聚合是指:一個集群中包含關聯關係的對象,將這些對象視為數據變更的單元。外部對聚合的引用被限制到聚合中的某個成員,該成員稱為根。聚合邊界會使用一致性規則。

Fig 3. Microservices in the Pricing Context

沒有必要為每個聚合建立一個獨立的微服務模型。圖3為我們最終的服務(聚合)模型(但不是唯一的模型)。某些情況下,一個單獨的服務可能會託管多個聚合,特別是在我們沒有完全理解業務領域之前。需要注意的是,只能在單個聚合中保證一致性,且只能通過已發布的介面修改聚合。任何違背該準則的行為都有可能導致架構變為一個”大泥球”。

上下文映射-精確確定微服務邊界的一種方法

另一個重要的概念是上下文映射(仍然來自領域驅動設計)。一體式架構通常由不同的模型構成,且大多數是強耦合的(一個模型能夠清楚了解到另一個模型的細節,且對任一個模型的修改都可能會影響到另一個,等等)。在分解一體式架構之後,確定這些模型(在這種情況下為確認聚合)及其關係至關重要。上下文映射可以幫助我們確認和定義各種邊界上下文和聚合之間的關係。邊界上下文定義了一個模型的邊界–Price,Discounts等。在上面例子中,上下文映射定義了這些模型之間的以及不同上下文之間的關係。在確認這些依賴之後,我們可以確定實現這些服務的團隊之間的協作關係。

對上下文映射的完整描述超出了本文範圍。下圖是一個上下文映射的例子,展示了各種用於處理支付電子商務訂單的應用。

  1. 購物車上下文(cart context)處理訂單的在線授權;訂單上下文(Order context)處理付款完成後的付款流程,如清算;客服中心(Context center)用於處理類型訂單的重複付款和修改付款方式等異常。
  2. 為了簡化,我們假設這些上下文都是用獨立的服務實現。
  3. 所有上下文都封裝了相同的模型。
  4. 注意這些模型邏輯上都是相同的,即它們都遵循通用領域語言(Ubiquitous domain language)—支付方法,授權和清算。它們只是不同上下文的一部分。

相同模型分布在不同上下文中的另一個跡象是,所有這些模型都直接集成到了相同的支付網關,且彼此之間執行相同的操作。

Fig 4. An incorrectly defined context map

重定義服務邊界–將聚合映射到正確的上下文

圖4中有一些非常明顯的問題。Payments聚合作為多個上下文的一部分。跨多個服務保證不變數和一致性非常重要,更不用說這些服務之間的並行問題。例如,如果在訂單服務(Orders service)在嘗試通過以提交的付款方式進行清算時,客服中心(contact center)更改了與訂這些單關聯的付款方式,會發生什麼情況。同時應該注意,付款網關(payment gateway)的任意變動都會影響到多個服務,有可能會影響到多個團隊或擁有這些上下文的組。

經過一些調整並使聚合與正確的上下文保持一致,我們獲得了對這些子域更加準確的表達方式,如圖5,進行了很多變更,現在看下涉及的改動:

  1. Payments聚合成為了一個單獨的服務–Payment服務。該服務從其他需要付款功能的服務中抽象了Payment網關。現在一個邊界上下文僅包含一個聚合,很容易就可以對這些不變數進行管理,同時也避免了相同服務邊界內的事務的一致性問題。
  2. Payments聚合使用一個防護層(ACL)來隔離核心領域模型與付款網關的數據模型,該數據模型通常是第三方提供的,且可能會發生變化。ACL層通常包含將付款網關的數據模型轉換為Payments聚合的數據模型的適配器。
  3. Cart 服務會通過直接API調用方式來調用Payments服務,購物車服務可能需要完成付款授權。
  4. 注意Orders和Payments服務之間的交互,Orders服務會發送領域事件,Payments服務會監聽該事件,並完成訂單清算。
  5. Contact center服務可能會包含多個聚合,但此處我們只關心Orders聚合。該服務會發送付款方式變更的事件,且Payments服務會對此做出反應,撤消之前使用的信用卡,並處理新的信用卡。

image-20210303103510345

Fig 5. Redefined context map

通常一個一體式或傳統應用程式會包含很多聚合,以及重疊的邊界。為這些聚合和依賴創建一個上下文映射可以幫助我們理解到如何藉助微服務的輪廓來使我們脫離一體式的泥沼。記住,微服務架構的成功和失敗取決於這些聚合之間的低耦合,以及聚合內的高內聚。

同時應該注意到,邊界上下文本身也是合理的高內聚單元。即使一個包含多個聚合的上下文,整個上下文和聚合都可以構成一個單獨的微服務。我們發現這種啟發式非常適用於比較模糊的領域–例如組織正在進入的新業務領域。這種情況下,你可能沒有足夠的洞察力來為業務劃清界限,過早分解聚合可能會造成高昂的重構成本。假設我們偶然發現兩個聚合可以放到一起,然後將兩個資料庫合二為一(涉及數據遷移)。在合併前需要保證使用介面對這些聚合進行了充分的隔離,這樣我們就不需要各個聚合內的複雜的細節。

事件風暴-識別服務邊界的另一種技術

事件風暴是另一種識別系統中聚合(以及微服務)的基本技術。它是一種很有用的工具,可以在設計複雜的微服務生態時對一體式架構進行分解。

簡而言之,事件風暴是在應用程式上工作的團隊之間的頭腦風暴演習(在我們的場景中,用來確定一體式系統中的各種領域事件,以及如何對這些事件進行處理)。團隊也需要確認這些事件影響的聚合或模型,以及後續影響。在團隊進行該演習時,可能會提出重疊的概念,模糊的領域語言以及衝突的業務流程。然後按照模型分組,重新定義聚合,確認重複的流程。通過不斷的演習,聚合所在的邊界上下文會變得越來越清晰。事件風暴研討非常有用,當所有團隊在一起的時候(物理或虛擬的)時,可以在敏捷風格的白板上映射事件,命令和過程。在演習結束之後,通常會得到如下結果:

  1. 重定義聚合列表,這些列表可能會變成新的微服務。
  2. 領域事件需要經過這些微服務。
  3. 其他應用或用戶可以直接調用的命令。

最後展示了一個事件風暴研討的簡單樣板。這是一個很棒的協作演習,可以使團隊對聚合和邊界上下文達成共識。除做了一個很好的團建之外,團隊也可以通過此次活動對領域、通用語言以及準確的服務邊界有共同的理解。

Fig 6. Event Storming board

微服務間的通訊

快速回顧一下,一體式主機會在一個處理邊界內承載多個聚合。因此能夠在該邊界內保證聚合的一致性。例如,如果一個客戶發起一個訂單,我們可以減少庫存的商品數,然後發送郵件給客戶,所有操作都在一個事務中完成。所有的操作最終會成功或失敗。但在我們分解一體式,並將聚合打散到多個不同的上下文後,我們將擁有幾十甚至上百個微服務。現在存在於一體式的單個邊界內的流程被延申到了多個分散式系統上。要跨這些分散式系統達到事務的完整性和一致性是非常困難的,而且要付出一定的代價–系統的可用性。

微服務同時也是分散式系統,因此也可以運用CAP理論—“一個分散式系統只能兌現三個特性中的兩個:一致性,可用性和分區容錯性“。在真實系統中,分區容錯性是必須要保證的,如網路不可達,虛擬機宕機,地區間的延遲變大等等。

因此,我們只能從可用性和一致性中二選一。現在我們知道,在現代應用中,犧牲可用性並不是一個好主意。

image-20210303101603990

Fig 7. CAP Theorem

圍繞事件一致性設計應用

如果嘗試構建跨多個分散式系統的事務,則最終會有可能會導致分布一體式。如果任意一個系統不可用,則整個處理將不可用,通常會導致糟糕的客戶體驗,承諾失信等等。此外對一個服務的變更可能會涉及另一個服務,導致複雜而昂貴的部署。因此我們最好根據特定的場景來設計應用,為了可用性而犧牲一部分一致性。如上例中,我們非同步處理所有的過程,從而最終保持一致。如果後續倉庫中的某個商品不可用,則可能需要重新訂購,或在達到一定閾值後停止接收訂單。

有時候,我們可能會需要在事務跨兩個不同處理邊界中的聚合時,保證強ACID。這是重新審視這些聚合數據並將其合併為一體的絕佳標誌。事件風暴和上下文映射可以幫助我們在不同的處理邊界中分解聚合前識別到依賴性。合併兩個微服務是有代價的,但有時候又難以避免。

支援事件驅動架構

當微服務的聚合發生變化時可以發送基本的變更資訊,稱為領域事件。任何對這些事件感興趣的服務都可以監聽這些事件,並在其所在的領域中做出相應的動作。這種方式可以避免耦合行為(一個域沒有規定其他域應該做什麼)以及臨時耦合(對一個流程的成功處理並不會在同一時間依賴所有的系統)。當然,這也意味著系統最終會保持一致。

Fig 8. Event driven architecture

在上例中,訂單服務提交了一個事件–訂單取消。訂閱該事件的其他服務會執行相應的事件功能:付款服務會退還款項,庫存服務會調整商品的庫存等等。為了保證整體的可靠性和彈性,需要注意:

  1. 生產者應該保證至少產生一次事件。如果不能(因為某種失敗),則應該保證提供回退機制來重新出發事件。
  2. 消費者應該保證以冪等的方式消費事件。如果再次出現了相同的事件,則不能對消費者產生任何影響。事件有可能需要保證到達的順序,消費者可以使用時間戳或版本欄位來保證事件的唯一性。

在一些使用場景下,有可能不能使用基於事件的集成方式。看下購物車服務和付款服務,它們是同步集成的,因此需要注意到這一點。這是一個行為(購物車服務可能會調用到付款服務的REST API,然後以此完成對一個訂單的付款授權)和時間耦合(在購物車服務接收訂單時,付款服務必須是可用的)的例子。這種耦合降低了這些上下文的自治能力,可能會產生不良依賴。有一些方式可以避免這種耦合,但這些方式也會使我們無法向客戶提供即時回饋。

  1. 將REST API轉換為基於事件的集成方式。但如果付款服務僅暴露了一個REST API,那麼這種方式是不可行的。
  2. 購物車服務立即接收訂單,並使用一個批處理任務獲取訂單,並調用付款服務的API。
  3. 購物車服務產生一個本地事件,然後調用服務服務的API。

為了防止依賴的上游(付款服務)的失敗和不可用,上述方式可以加入重試機制。例如,在購物車和付款同步集成出現失敗的場景下,可以使用事件或批量重試作為備選方式。這種方式會對客戶體驗產生一些額外的影響,如客戶可能輸入錯誤的付款資訊,而當我們處理這些離線付款時,這些資訊可能並不在線;或業務可能會增加一些代價來回收失敗的付款。 但有可能,購物車服務因此增加了應對支付服務不可用性或故障情況下的彈性,其利大於弊。例如,當無法採集離線的付款時,我們可以通知到客戶。簡而言之,在設計系統時,需要在用戶體驗,彈性和運維成本權衡。

避免針對特定消費者的數據需求而在服務之間進行編排

很多面向服務的架構中的一個反例是:服務是為了迎合特定訪問模式的用戶。通常這種模式發生在客戶團隊和服務團隊緊密合作的情況下。如果團隊正在開發一體式應用,則經常會創建一個跨多個聚合邊界的API,因此這些聚合是強耦合的。看一個例子,假設需要同時在Web和移動應用上的一個訂單詳情頁面中展示一個訂單的詳情,以及訂單的退款流程詳情。在一體式應用中,會使用GET Order API(假設是REST API)同時請求訂單和退款服務,合併兩個聚合,並向調用者發送複合響應。由於聚合屬於相同的處理邊界,因此可能不會造成很大的開銷。因此消費者可以在一個調用中獲取所有必需的數據。

如果訂單或退款服務位於不同的上下文,則無法從單一的微服務或聚合邊界內獲取所需的數據。為消費者保留相同功能的一種方式是讓訂單服務負責調用退款服務,並創建複合響應:

  1. 訂單服務集成了其他服務,用於支援需要退款數據以及訂單數據的消費者。此時當退款聚合發生變化的同時也會影響到訂單聚合,訂單服務的自治性變弱。
  2. 由於訂單服務集成了其他服務,因此需要考慮故障點–如果退款服務宕機,那麼訂單服務是否能夠發生部分數據,消費者是否能夠正常失敗?
  3. 如果需要修改消費者來從退款聚合獲取更多的數據,則需要兩個團隊配合修改。
  4. 如果使用這種模式進行跨平台協作,則有可能在各種領域服務之間造成複雜的web依賴。造成這種局面的原因就是所有的服務都迎合了調用者使用的特定訪問模式。

前端的後端(BFFs)

一種緩解這種風險的方式是讓消費者團隊管理各種領域服務的編排。最終,調用者能夠更好地了解訪問模式,並完全掌握對這些模式的修改。這種方式從表示層對領域服務進行解耦,使它們能夠專註於核心業務的處理。但如果web和移動apps直接調用不同的服務(而不是調用一體式的複合API),則可能會對這些apps造成一定的性能開銷(在低頻寬網路上執行多個調用,處理以及從不同的API合併數據等)。

可以使用另一種模式:BFF(Backend for Front-ends)。這種設計模式下,消費者會創建並管理一個後端服務(在上例中為web和移動團隊),處理跨多領域服務的集成(完全是為了給客戶提供前後端體驗)。現在web和移動端都可以根據需求來設計數據合約(contract)。甚至可以使用GraphQL ,而非REST API來提供靈活的訪問,並返回所需要的內容。需要注意的是,該服務是消費者團隊(而不是領域服務的團隊)所有並維護的。前後端團隊可以根據需要進行優化,例如一個移動app可以通過請求一個小的載荷來降低移動app的調用數等等。下圖展示了修改後的編排邏輯。BFF服務可以同時調用訂單和退款領域服務。

儘早構建BFF服務也很有用(在將一體式拆分為大量服務前)。否則,要麼領域服務必須支援域間業務流程,要麼Web和移動應用程式必須直接從前端調用多個服務。 這兩個選項都將導致性能開銷,且團隊之間缺少自治。

DDD中的概念比較模糊,但有一些概念(如領域和子域,邊界上下文等)和實施(聚合中的值對象和根成員等)是比較清晰的。

參考

Tags: