eShopOnContainers 知多少[8]:Ordering microservice
1. 引言
Ordering microservice(訂單微服務)就是處理訂單的了,它與前面講到的幾個微服務相比要複雜的多。主要涉及以下業務邏輯:
- 訂單的建立、取消、支付、發貨
- 庫存的扣減
2. 架構模式

簡化的CQRS和DDD微服務設計
如上圖所示,該服務基於CQRS 和DDD來實現。

專案結構
從專案結構來看,主要包括7個專案:
- Ordering.API:應用層
- Ordering.Domain:領域層
- Ordering.Infrastructure:基礎設施層
- Ordering.BackgroundTasks:後臺任務
- Ordering.SignalrHub:基於Signalr的訊息推送和實時通訊
- Ordering.FunctionalTests:功能測試專案
- Ordering.UnitTests:單元測試專案
從以上的專案定義來看,該微服務的設計並符合DDD經典的四層架構。

Ordering.API對應DDD中分層
核心技術選型:
- ASP.NET Core Web API
- Entity Framework Core
- SQL Server
- Swashbuckle(可選)
- Autofac
- Eventbus
- MediatR
- SignalR
- Dapper
- Polly
- FluentValidator
3. 簡明DDD
領域驅動設計是一種方法論,用於解決軟體複雜度問題。它強調以領域為核心驅動設計。主要包括戰略和戰術設計兩大部分,其中戰略設計指導我們在巨集觀層面對問題域進行識別和劃分,從而將大問題劃分為多個小問題,分而治之。而戰術設計從微觀層面指導我們如何對領域進行建模。

DDD開發過程
其中戰術設計了引入了很多核心要素,指導我們建模:
- 值物件(Value Object)
- 實體(Entity)
- 領域服務(Domain Service)
- 領域事件(Domain Event)
- 資源庫(Repository)
- 工廠(Factory)
- 聚合(Aggregate)
-
應用服務(Application Service)
戰術要素
其中實體、值物件和領域服務用於表示領域模型,來實現領域邏輯。
聚合用於封裝一到多個實體和值物件,確保業務完整性。
領域事件來豐富領域物件之間的互動。
工廠、資源庫用於管理領域物件的生命週期。
應用服務是用來表達用例和使用者故事。
有了以上的戰術設計要素還不夠,如果它們糅合在一起,還是會很混亂,因此DDD再通過分層架構來確保關注點分離,即將領域模型相關(實體、值物件、聚合、領域服務、領域事件)放到領域層,將資源庫、工廠放到基礎設施層,將應用服務放到應用層。以下就是DDD經典的四層架構:

DDD經典四層架構
以上相關圖片來源於: 張逸 · 領域驅動戰略設計實踐
4. Ordering.Domain:領域層

專案結構
如果對訂單微服務應用DDD,那麼要摒棄傳統的面向資料庫建模的思想,轉向領域建模。該專案中主要定義了以下領域物件:
- Order:訂單
- OrderItem:訂單項
- OrderStatus:訂單狀態
- Buyer:買家
- Address:地址
- PaymentMethod:支付方式
- CardType:銀行卡片型別
在該示例專案中,定義了兩個聚合:訂單聚合和買家聚合,其中Order和Buyer分屬兩個聚合根,其中訂單聚合通過持有買家聚合的唯一ID進行關聯。如下圖所示:

訂單聚會和買家聚合
我們依次來看其對實體、值物件、聚合、資源庫、領域事件的實現方式。
4.1. 實體、值物件與聚合

實體相關類圖
實體與值物件最大的區別在於,實體有識別符號可變,值物件不可變。為了保證領域的不變性,也就是更好的封裝,所有的屬性欄位都設定為 private set
,集合都設定為只讀的,通過建構函式進行初始化,通過暴露方法供外部呼叫修改。
從類圖中我們可以看出,其主要定義了一個 Entity
抽象基類,所有的實體通過繼承 Entity
來實現命名約定。這裡面有兩點需要說明:
- 通過
Id
屬性確保唯一識別符號 - 重寫
Equals
和GetHashCode
方法(hash值計算:this.Id.GetHashCode() ^ 31
) - 定義
DomainEvents
來儲存實體關聯的領域事件(領域事件的發生歸根結底是由於領域物件的狀態變化引起的,而領域物件[實體、值物件和聚合])中值物件是不可變的,而聚合往往包含多個實體,所以將領域事件關聯在實體上最合適不過。)

值物件相關類圖
同樣,值物件也是通過繼承抽象基類 ValueObject
來進行約定。其主要也是過載了 Equals
和 GetHashCode
和方法。這裡面有必要學習其 GetHashCode
的實現技巧:
// ValueObject.cs protected abstract IEnumerable<object> GetAtomicValues(); public override int GetHashCode() { return GetAtomicValues() .Select(x => x != null ? x.GetHashCode() : 0) .Aggregate((x, y) => x ^ y); } //Address.cs protected override IEnumerable<object> GetAtomicValues() { // Using a yield return statement to return each ele yield return Street; yield return City; yield return State; yield return Country; yield return ZipCode; }
可以看到,通過在基類定義 GetAtomicValues
方法,用來要求子類指定需要hash的欄位,然後將每個欄位取hash值,然後通過異或運算再行聚合得到唯一hash值。
所有對聚合中領域物件的操作都是通過聚合根來維護的。因此我們可以看到聚合根中定義了許多方法來處理領域邏輯。
4.2. 倉儲

倉儲相關類圖
IRepository
定義了一個
IUnitOfWork
屬性,其代表工作單元,主要定義了兩個方法
SaveChangesAsync
和
SaveEntitiesAsync
,藉助事務一次性提交所有更改,以確保資料的完整性和有效性。
4.3. 領域事件

領域事件相關類圖
從類圖中可以看出一個共同特徵,都實現了 INotification
介面。對MediatR熟悉的肯定一眼就明白了。是的,這個是 MediatR
中定義的介面。藉助MediatR,來實現事件處理管道。通過程序內事件處理管道來驅動命令接收,並將它們(在記憶體中)路由到正確的事件處理器。
關於MeidatR可以參考我的這篇博文:MediatR 知多少
而關於領域事件的處理,是通過繼承 INotificationHanlder
介面來實現,這樣 INotification
與 INotificationHandler
通過Ioc容器的服務註冊,自動完成事件的訂閱。而領域事件的處理其下放到了 Ordering.Api
中處理了。這裡大家可能會有疑惑,既然叫領域事件,那為什麼領域事件的處理不放到領域層呢?我們可以這樣理解,事件是領域內觸發,但對事件的處理,其並非都是業務邏輯的相關處理,比如訂單建立成功後傳送簡訊、郵件等就不屬於業務邏輯。
eShopOnContainers中領域事件的觸發時機並非是即時觸發,選擇的是延遲觸發模式。具體的實現,後面會講到。
5. Ordering.Infrastructure:基礎設施層
基礎設施層主要用於提供基礎服務,主要是用來實體對映和持久化。

Ordering.Infrastructure 程式碼結構
從圖中可以看到,主要包含以下業務處理:
- 實體型別對映
- 冪等性控制器的實現
- 倉儲的具體實現
- 資料庫上下文的實現(UnitOfWork的實現)
- 領域事件的批量派發
這裡著重下第2、4、5點的介紹。
5.1. 冪等性控制器
冪等性是指某個操作多次執行但結果相同,換句話說,多次執行操作而不改變結果。舉例來說:我們在寫預插指令碼時,會新增條件判斷,當表中不存在資料時才將資料插入到表中。無論重複執行多少次 SQL 語句,結果一定是相同的,並且結果資料會包含在表中。
那怎樣確保冪等性呢?一種方式就是確保操作本身的冪等性,比如可以建立一個表示“將產品價格設定為¥25”而不是“將產品價格增加¥5”的事件。此時可以安全地處理第一條訊息,無論處理多少次結果都一樣,而第二個訊息則完全不同。
但是假設價格是一個時刻在變的,而你當前的操作就是要將產品價格增加¥5怎麼辦呢?顯然這個操作是不能重複執行的。那我如何確保當前的操作只執行一次呢?
一種簡便的方法就是記錄每次執行的操作。該專案中的 Idempotency
資料夾就是來做這件事的。

Idempotency 類圖
從類圖來看很簡單,就是每次傳送事件時生成一個唯一的Guid,然後構造一個 ClientRequest
物件例項持久化到資料庫中,每次藉助MediatR傳送訊息時都去檢測訊息是否已經發送。

冪等性處理
5.2. UnitOfWork(工作單元的實現)

Uow實現邏輯
從程式碼來看,主要乾了兩件事:
- 在提交變更之前,觸發所有的領域事件
- 批量提交變更
這裡需要解釋的一點是,為什麼要在持久化之前而不是之後進行領域事件的觸發呢?
這種觸發就是延遲觸發,將領域事件的釋出與領域實體的持久化放到一個事務中來達到一致性。
當然這有利有弊,弊端就是當領域事件的處理非常耗時,很有可能會導致事務超時,最終導致提交失敗。而避免這一問題,也只有做事務拆分,這時就要考慮最終一致性和相應的補償措施,顯然更復雜。
至此,我們可以總結下聚合、倉儲與資料庫之間的關係,如下圖所示。

6. Ordering.Api:應用層
應用層通過應用服務介面來暴露系統的全部功能。在這裡主要涉及到:
- 領域事件的處理
- 整合事件的處理
- CQRS的實現
- 服務註冊
- 認證授權
- 整合事件的訂閱

專案結構
6.1. 領域事件和整合事件
對於領域事件和整合事件的處理,我們需要先明白二者的區別。領域事件是發生在領域內的通訊(同步或非同步均可),而整合事件是基於多個微服務(其他限界上下文)甚至外部系統或應用間的非同步通訊。
領域事件是藉助於MediatR的 INotification 和 INotificationHandler
的介面來實現。
其中 Application/Behaviors
資料夾中是實現MediatR中的 IPipelineBehavior
介面而定義的請求處理管道。

整合事件的釋出訂閱是藉助事件匯流排來完成的,關於事件匯流排之前有文章詳述,這裡不再贅述。在此,僅程式碼舉例其訂閱方式。
private void ConfigureEventBus(IApplicationBuilder app) { var eventBus = app.ApplicationServices.GetRequiredService<BuildingBlocks.EventBus.Abstractions.IEventBus>(); eventBus.Subscribe<UserCheckoutAcceptedIntegrationEvent, IIntegrationEventHandler<UserCheckoutAcceptedIntegrationEvent>>(); // some other code }
6.2. 基於MediatR實現的CQRS
CQRS (Command Query Responsibility Separation):命令查詢職責分離。是一種用來實現資料模型讀寫分離的架構模式。顧名思義,分為兩大職責:
- 命令職責
- 查詢職責
其核心思想是:在客戶端就將資料的新增修改刪除等動作和查詢進行分離,前者稱為Command,通過Command Bus對領域模型進行操作,而查詢則從另外一條路徑直接對資料進行操作,比如報表輸出等。

CQRS
對於命令職責,其是藉助於MediatR充當的CommandBus,使用 IRequest
來定義命令,使用 IRequestHandler
來定義命令處理程式。我們可以看下 CancelOrderCommand
和 CancelOrderCommandHandler
的實現。
public class CancelOrderCommand : IRequest<bool> { [DataMember] public int OrderNumber { get; private set; } public CancelOrderCommand(int orderNumber) { OrderNumber = orderNumber; } } public class CancelOrderCommandHandler : IRequestHandler<CancelOrderCommand, bool> { private readonly IOrderRepository _orderRepository; public CancelOrderCommandHandler(IOrderRepository orderRepository) { _orderRepository = orderRepository; } public async Task<bool> Handle(CancelOrderCommand command, CancellationToken cancellationToken) { var orderToUpdate = await _orderRepository.GetAsync(command.OrderNumber); if(orderToUpdate == null) { return false; } orderToUpdate.SetCancelledStatus(); return await _orderRepository.UnitOfWork.SaveEntitiesAsync(); } }
以上程式碼中,有一點需要指出,就是所有Command中的屬性都定義為 private set
,通過建構函式進行賦值,以確保Command的不變性。
對於查詢職責,通過定義查詢介面,藉助Dapper直接寫SQL語句來完成對資料庫的直接讀取。

查詢示例
而對於定義的命令,為了確保每個命令的合法性,通過引入第三方Nuget包 FluentValdiation
來進行命令的合法性校驗。其程式碼也很簡單,參考下圖。

校驗器的定義和註冊
6.3. 服務註冊
整個訂單微服務中所有服務的註冊,都是放到應用層來做的,在 Ordering.Api\Infrastructure\AutofacModules
資料夾下通過繼承 Autofac.Module
定義了兩個Module來進行服務註冊:
- ApplicationModule:自定義介面相關服務的註冊
- MediatorModule:Mediator相關介面服務的註冊
將所有的服務註冊都放到高層模組來進行註冊,有點違背關注點分離,各層應該關注本層的服務註冊,所以這中實現方式是有待改進的。而具體如何改進,這裡給大家提供一個線索,可參考ABP是如何實現進行服務註冊的分離和整合的。
這裡順帶提一下 Autofac
這個Ioc容器的一個限制,就是所有的服務註冊必須在程式啟動時完成註冊,不允許執行時動態註冊。
7. Ordering.BackgroundTasks:後臺任務
後臺任務,顧名思義,後臺靜默執行的任務,也稱計劃任務。在.NET Core 中,我們將這些型別的任務稱為託管服務,因為它們是在主機/應用程式/微服務中託管的服務/邏輯。請注意,這種情況下託管服務僅簡單表示具有後臺任務邏輯類。
那我們如何實現託管服務了,一種簡單的方式就是使用.NET Core 2.0之後版本中提供了一個名為 IHostedService
的新介面。當然也可以選擇其他的一些後臺任務框架,比如HangFire、Quartz。

該示例專案就是基於 BackgroundService
定義的一個後臺任務。該任務主要用於輪詢訂單表中處於已提交超過1分鐘的訂單,然後釋出整合事件到事件匯流排,最終用來將訂單狀態更新為待核驗(庫存)狀態。
public abstract class BackgroundService : IHostedService, IDisposable { protected BackgroundService(); public virtual void Dispose(); public virtual Task StartAsync(CancellationToken cancellationToken); [AsyncStateMachine(typeof(<StopAsync>d__4))] public virtual Task StopAsync(CancellationToken cancellationToken); protected abstract Task ExecuteAsync(CancellationToken stoppingToken); }
從 BackgroundService
的方法申明中我們可以看出僅需實現 ExecuteAsync
方法即可。
完成後臺任務的定義後,將服務註冊到Ioc容器中即可。
public IServiceProvider ConfigureServices(IServiceCollection services) { //Other DI registrations; // Register Hosted Services services.AddSingleton<IHostedService, GracePeriodManagerService>(); services.AddSingleton<IHostedService, MyHostedServiceB>(); services.AddSingleton<IHostedService, MyHostedServiceC>(); //... }

總之, IHostedService
介面為 ASP.NET Core Web 應用程式啟動後臺任務提供了一種便捷的方法。它的優勢主要在於:當主機本身關閉時,可以利用取消令牌來優雅的清理後臺任務。
8. Ordering.SignalrHub:即時通訊
在訂單微服務中,當訂單狀態變更時,需要實時推送訂單狀態變更訊息給客戶端。而這就涉及到實時通訊。實時 HTTP 通訊意味著,當資料可用時,服務端程式碼會推送內容到已連線的客戶端,而不是服務端等待客戶端來請求新資料。
而對於實時通訊,ASP.NET Core中 SignalR 可以滿足我們的需求,其支援幾種處理實時通訊的技術以確保實時通訊的可靠傳輸。
- WebSockets
- Server-Sent Events
- Long Polling

該示例專案的實現思路很簡單:
- 訂閱訂單狀態變更相關的整合事件
- 繼承
SignalR.Hub
定義一個NotificationsHub
- 在整合事件處理程式中呼叫Hub進行訊息的實時推送
// 訂閱整合事件 private void ConfigureEventBus(IApplicationBuilder app) { var eventBus = app.ApplicationServices.GetRequiredService<IEventBus>(); eventBus.Subscribe<OrderStatusChangedToAwaitingValidationIntegrationEvent, OrderStatusChangedToAwaitingValidationIntegrationEventHandler>(); eventBus.Subscribe<OrderStatusChangedToPaidIntegrationEvent, OrderStatusChangedToPaidIntegrationEventHandler>(); eventBus.Subscribe<OrderStatusChangedToStockConfirmedIntegrationEvent, OrderStatusChangedToStockConfirmedIntegrationEventHandler>(); eventBus.Subscribe<OrderStatusChangedToShippedIntegrationEvent, OrderStatusChangedToShippedIntegrationEventHandler>(); eventBus.Subscribe<OrderStatusChangedToCancelledIntegrationEvent, OrderStatusChangedToCancelledIntegrationEventHandler>(); eventBus.Subscribe<OrderStatusChangedToSubmittedIntegrationEvent, OrderStatusChangedToSubmittedIntegrationEventHandler>(); } // 定義SignalR.Hub [Authorize] public class NotificationsHub : Hub { public override async Task OnConnectedAsync() { await Groups.AddToGroupAsync(Context.ConnectionId, Context.User.Identity.Name); await base.OnConnectedAsync(); } public override async Task OnDisconnectedAsync(Exception ex) { await Groups.AddToGroupAsync(Context.ConnectionId, Context.User.Identity.Name); await base.OnDisconnectedAsync(ex); } } // 在整合事件處理器中呼叫Hub進行訊息的實時推送 public class OrderStatusChangedToPaidIntegrationEventHandler : IIntegrationEventHandler<OrderStatusChangedToPaidIntegrationEvent> { private readonly IHubContext<NotificationsHub> _hubContext; public OrderStatusChangedToPaidIntegrationEventHandler(IHubContext<NotificationsHub> hubContext) { _hubContext = hubContext ?? throw new ArgumentNullException(nameof(hubContext)); } public async Task Handle(OrderStatusChangedToPaidIntegrationEvent @event) { await _hubContext.Clients .Group(@event.BuyerName) .SendAsync("UpdatedOrderState", new { OrderId = @event.OrderId, Status = @event.OrderStatus }); } }
8. 最後
訂單微服務在整個eShopOnContainers中屬於最複雜的一個微服務了。
通過對DDD的簡要介紹,以及對每一層的技術選型以及實現的思路和邏輯的梳理,希望對你有所幫助。