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

如上圖所示,該服務基于CQRS 和DDD來實(shi)現。

從項目結構來看,主(zhu)要(yao)包括7個(ge)項目:
- Ordering.API:應用層
- Ordering.Domain:領域層
- Ordering.Infrastructure:基礎設施層
- Ordering.BackgroundTasks:后臺任務
- Ordering.SignalrHub:基于Signalr的消息推送和實時通信
- Ordering.FunctionalTests:功能測試項目
- Ordering.UnitTests:單元測試項目
從以上的(de)(de)項目定義來看,該微服(fu)務的(de)(de)設計并符合(he)DDD經典的(de)(de)四(si)層架構(gou)。

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

其(qi)中戰術(shu)設計了引(yin)入了很多核心要素,指導我(wo)們建模(mo):
- 值對象(Value Object)
- 實體(Entity)
- 領域服務(Domain Service)
- 領域事件(Domain Event)
- 資源庫(Repository)
- 工廠(Factory)
- 聚合(Aggregate)
- 應用服務(Application Service)

其中實體、值對象和領域服務用于表示領域模型,來實現領域邏輯。
聚合用于封裝一到多個實體和值對象,確保業務完整性。
領域事件來豐富領域對象之間的交互。
工廠、資源庫用于管理領域對象的生命周期。
應用服務(wu)是(shi)用來表達用例和(he)用戶故事(shi)。
有了以上的戰術設計要素還不夠,如果它們糅合在一起,還是會很混亂,因此DDD再通過分層架構來確保關注點分離,即將領域模型相關(實體、值對象、聚合、領域服務、領域事件)放到領域層,將資源庫、工廠放到基礎設施層,將應用服務放到應用層。以下就是DDD經典的四層架構:

以上相關圖片來源于:
4. Ordering.Domain:領域層

如果(guo)對訂(ding)單(dan)微服(fu)務應用DDD,那(nei)么(me)要(yao)摒(bing)棄(qi)傳統的(de)面(mian)向數(shu)據庫(ku)建模的(de)思(si)想,轉向領域(yu)建模。該項(xiang)目中主(zhu)要(yao)定義了以下領域(yu)對象(xiang):
- Order:訂單
- OrderItem:訂單項
- OrderStatus:訂單狀態
- Buyer:買家
- Address:地址
- PaymentMethod:支付方式
- CardType:銀行卡片類型
在該示例項目中,定義了兩個聚合:訂單聚合和買家聚合,其中Order和Buyer分屬兩個聚合根,其中訂單聚合通過持有買家聚合的唯一ID進行關聯。如下圖所示:

我們依次來看其對實體、值對象、聚(ju)合、資源庫、領(ling)域(yu)事件的實現方(fang)式。
4.1. 實體、值對象與聚合

實體與值對象最大的區別在于,實體有標識符可變,值對象不可變。為了保證領域的不變性,也就是更好的封裝,所有的屬性字段都設置為private set,集合都設置為只讀的,通過構造函數進行初始化,通過暴露方法供外部調用修改。
從類圖中我們可以看出,其主要定義了一個Entity抽象基類,所有的實體通過繼承Entity來實現命名(ming)約定。這里面有(you)兩點(dian)需要說(shuo)明:
- 通過
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方(fang)法,用來要(yao)求(qiu)子類指定需要(yao)hash的(de)字段(duan),然后將(jiang)每個字段(duan)取hash值(zhi),然后通過異或運算再行聚合得到唯一hash值(zhi)。
所有對聚合中領域對象的(de)操作(zuo)都是(shi)通過聚合根來(lai)維(wei)護的(de)。因此我們可(ke)以看(kan)到聚合根中定義了(le)許多方法來(lai)處理領域邏(luo)輯。
4.2. 倉儲

聚合中的領域對象的持久化借助倉儲來完成的。其提供統一的入口來進行聚合內相關領域對象的CRUD,從而完成透明持久化。從圖中看出,IRepository定義了一個IUnitOfWork屬性,其代表工作單元,主要定義了兩個方法SaveChangesAsync和SaveEntitiesAsync,借助事(shi)務一次性提交(jiao)所(suo)有更(geng)改,以確保數(shu)據的完整性和(he)有效性。
4.3. 領域事件

從類圖中可以看出一個共同特征,都實現了INotification接口。對MediatR熟悉的肯定一眼就明白了。是的,這個是MediatR中定義的接口。借助MediatR,來實現事件處理管道。通過進程內事件處理管道來驅動命令接收,并將它們(在內存中)路由到正確的事件處理器。
關于MeidatR可以(yi)參考我(wo)的(de)這篇博(bo)文(wen):
而關于領域事件的處理,是通過繼承INotificationHanlder接口來實現,這樣INotification與INotificationHandler通過Ioc容器的服務注冊,自動完成事件的訂閱。而領域事件的處理其下放到了Ordering.Api中(zhong)處理(li)了。這(zhe)里大家可(ke)能會有疑(yi)惑,既然叫領(ling)(ling)域事(shi)件,那為(wei)什么(me)領(ling)(ling)域事(shi)件的處理(li)不(bu)放到領(ling)(ling)域層呢?我們可(ke)以這(zhe)樣(yang)理(li)解,事(shi)件是(shi)領(ling)(ling)域內觸發(fa),但對事(shi)件的處理(li),其并非都(dou)是(shi)業務邏輯(ji)的相關處理(li),比如訂(ding)單(dan)創建成功后發(fa)送短信、郵件等就不(bu)屬于(yu)業務邏輯(ji)。
eShopOnContainers中領域(yu)事件的(de)觸發(fa)時機并非(fei)是(shi)即時觸發(fa),選擇的(de)是(shi)延遲(chi)觸發(fa)模式。具體(ti)的(de)實現,后面(mian)會講到。
5. Ordering.Infrastructure:基礎設施層
基(ji)礎(chu)設施層(ceng)主(zhu)要用于提(ti)供基(ji)礎(chu)服務(wu),主(zhu)要是(shi)用來實體映射和持(chi)久化。

從圖中(zhong)可以看到,主(zhu)要包(bao)含以下業務(wu)處理:
- 實體類型映射
- 冪等性控制器的實現
- 倉儲的具體實現
- 數據庫上下文的實現(UnitOfWork的實現)
- 領域事件的批量派發
這里著重下第2、4、5點的介(jie)紹。
5.1. 冪等性控制器
冪(mi)等性(xing)是指某個操作多次(ci)執行(xing)但結果(guo)相同,換句話說(shuo),多次(ci)執行(xing)操作而不改變結果(guo)。舉例來說(shuo):我們在(zai)寫預插腳本時,會添加條件判斷,當(dang)表中不存在(zai)數(shu)據(ju)(ju)時才(cai)將數(shu)據(ju)(ju)插入到表中。無論(lun)重復運行(xing)多少次(ci) SQL 語句,結果(guo)一定是相同的(de),并且結果(guo)數(shu)據(ju)(ju)會包(bao)含在(zai)表中。
那怎樣確保冪等性呢?一種方式就是確保操作本身的冪等性,比如可以創建一個表示“將產品價格設置為¥25”而不是“將產品價格增加¥5”的事件。此時可以安全地處理第一條消息,無論處理多少次結果都一樣,而第二個消息則完全不同。
但是假設價格是一個時刻在變的,而你當前的操作就是要將產品價格增加¥5怎么辦呢?顯然這個操作是不能重復執行的。那我如何確保當前的操作只執行一次呢?
一種簡便的方法就是記錄每次執行的操作。該項目中的Idempotency文(wen)件夾(jia)就是來(lai)做這件事的(de)。

從類圖來看很簡單,就是每次發送事件時生成一個唯一的Guid,然后構造一個ClientRequest對象實例(li)持久(jiu)化到數據庫(ku)中,每(mei)次借助MediatR發送消息時都去檢測消息是(shi)否已經發送。

5.2. UnitOfWork(工作單元的實現)

從(cong)代碼來看,主要(yao)干了兩件(jian)事:
- 在提交變更之前,觸發所有的領域事件
- 批量提交變更
這里需要解釋的一點是,為什么要在持久化之前而不是之后進行領域事件的觸發呢?
這種觸發就是延遲觸發,將領域事件的發布與領域實體的持久化放到一個事務中來達到一致性。
當(dang)(dang)然(ran)這有(you)(you)(you)利有(you)(you)(you)弊,弊端就是當(dang)(dang)領域(yu)事件的(de)處理非常耗時(shi),很有(you)(you)(you)可能會導致事務(wu)超(chao)時(shi),最終導致提交失敗。而避免(mian)這一(yi)問題(ti),也只(zhi)有(you)(you)(you)做事務(wu)拆分,這時(shi)就要考慮最終一(yi)致性和相應的(de)補償措施(shi),顯然(ran)更復(fu)雜。
至此,我們可以總結下聚合、倉儲與數據庫之間的關系,如下圖所示。

6. Ordering.Api:應用層
應(ying)用(yong)層通過應(ying)用(yong)服務接口來暴(bao)露系統的(de)全部功能。在這里主要(yao)涉及到(dao):
- 領域事件的處理
- 集成事件的處理
- CQRS的實現
- 服務注冊
- 認證授權
- 集成事件的訂閱

6.1. 領域事件和集成事件
對于領域事件和集成事件的處理,我們需要先明白二者的區別。領域事件是發生在領域內的通信(同步或異步均可),而集成事件是基于多個微服務(其他限界上下文)甚至外部系統或應用間的異步通信。
領域事件是借助于MediatR的INotification 和 INotificationHandler的接口來實現。
其中Application/Behaviors文件夾中是實現MediatR中的IPipelineBehavior接口(kou)而定義的請求處理管道。

集成(cheng)事件(jian)的發布(bu)訂(ding)閱是借助事件(jian)總線來完成(cheng)的,關于事件(jian)總線之前有文章詳述,這(zhe)里(li)不再(zai)贅(zhui)述。在此,僅代碼舉例其(qi)訂(ding)閱方(fang)式(shi)。
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
(Command Query Responsibility Separation):命令查詢職責(ze)分(fen)(fen)離。是一種用來實(shi)現數據模型(xing)讀寫分(fen)(fen)離的架(jia)構模式(shi)。顧(gu)名思義,分(fen)(fen)為兩大職責(ze):
- 命令職責
- 查詢職責
其(qi)核心思(si)想是:在客戶端就將(jiang)數(shu)據的新(xin)增修改(gai)刪除等動作(zuo)(zuo)和(he)查(cha)詢進(jin)行(xing)分離,前者稱(cheng)為Command,通過(guo)Command Bus對(dui)領域模型進(jin)行(xing)操(cao)作(zuo)(zuo),而查(cha)詢則(ze)從另外一條路徑直接(jie)對(dui)數(shu)據進(jin)行(xing)操(cao)作(zuo)(zuo),比如報表輸出等。

對于命令職責,其是借助于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,通過(guo)構(gou)造函數進行賦值,以(yi)確保(bao)Command的不變性。
對于查詢職責,通過定義查詢接口,借助Dapper直接寫SQL語句來完成對數據庫的直接讀取。

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

6.3. 服務注冊
整個訂單微服務中所有服務的注冊,都是放到應用層來做的,在Ordering.Api\Infrastructure\AutofacModules文件夾下通過繼承Autofac.Module定義了(le)兩個Module來進行服務注冊:
- ApplicationModule:自定義接口相關服務的注冊
- MediatorModule:Mediator相關接口服務的注冊
將所(suo)有的(de)服(fu)(fu)(fu)務(wu)注(zhu)冊(ce)都放到高層模塊來(lai)進行注(zhu)冊(ce),有點(dian)違背關注(zhu)點(dian)分離(li),各層應該關注(zhu)本層的(de)服(fu)(fu)(fu)務(wu)注(zhu)冊(ce),所(suo)以(yi)這(zhe)(zhe)中實現方(fang)式是有待改(gai)進的(de)。而(er)具(ju)體如何改(gai)進,這(zhe)(zhe)里給大家提供一(yi)個線索,可參考ABP是如何實現進行服(fu)(fu)(fu)務(wu)注(zhu)冊(ce)的(de)分離(li)和整合的(de)。
這里順帶提一下Autofac這個Ioc容器的一個限制,就是所有的服務注冊必須在程序啟動時完成注冊,不允許運行時動態注冊。
7. Ordering.BackgroundTasks:后臺任務
后臺任(ren)務,顧(gu)名思義,后臺靜默運行的任(ren)務,也(ye)稱計劃任(ren)務。在(zai).NET Core 中(zhong),我(wo)們將這些類(lei)型的任(ren)務稱為托(tuo)管(guan)服務,因(yin)為它們是(shi)在(zai)主機/應用程序/微服務中(zhong)托(tuo)管(guan)的服務/邏輯。請注意,這種(zhong)情況(kuang)下托(tuo)管(guan)服務僅簡單表示具有后臺任(ren)務邏輯類(lei)。
那我們如何實現托管服務了,一種簡單的方式就是使用.NET Core 2.0之后版本中提供了一個名為IHostedService的新接口。當然也可以選擇其他的一些后臺任務框架,比如HangFire、Quartz。

該示例項目就是基于BackgroundService定(ding)義(yi)的一個后臺任(ren)務。該任(ren)務主要用于(yu)輪詢訂(ding)單表中處于(yu)已提交超過(guo)1分鐘(zhong)的訂(ding)單,然(ran)后發(fa)布集成事件到事件總線,最(zui)終用來將訂(ding)單狀態更新為(wei)待核驗(yan)(庫存)狀態。
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方法即可。
完成后(hou)(hou)臺(tai)任(ren)務的定義后(hou)(hou),將服務注(zhu)冊到Ioc容(rong)器中即可。
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 應用(yong)程(cheng)序啟動后臺任務提供了(le)一種便捷的方法。它的優勢主要在于:當主機本身關閉時,可以(yi)利用(yong)取消令牌來優雅的清理(li)后臺任務。
8. Ordering.SignalrHub:即時通信
在訂(ding)(ding)單(dan)(dan)微服(fu)(fu)務中,當訂(ding)(ding)單(dan)(dan)狀態(tai)變更時,需要實(shi)時推(tui)送訂(ding)(ding)單(dan)(dan)狀態(tai)變更消息給客戶(hu)(hu)端(duan)。而這就涉(she)及到(dao)實(shi)時通(tong)信(xin)。實(shi)時 HTTP 通(tong)信(xin)意味著,當數據(ju)可用(yong)時,服(fu)(fu)務端(duan)代碼會推(tui)送內(nei)容(rong)到(dao)已連接的客戶(hu)(hu)端(duan),而不是服(fu)(fu)務端(duan)等待(dai)客戶(hu)(hu)端(duan)來請求新數據(ju)。
而對于實時(shi)(shi)通(tong)信,ASP.NET Core中可以滿(man)足(zu)我(wo)們的(de)需求,其支持幾(ji)種處理(li)實時(shi)(shi)通(tong)信的(de)技(ji)術以確保實時(shi)(shi)通(tong)信的(de)可靠(kao)傳輸(shu)。
- Server-Sent Events
- Long Polling

該示例(li)項(xiang)目的實現思路(lu)很(hen)簡單:
- 訂閱訂單狀態變更相關的集成事件
- 繼承
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中屬于最復雜的一個微服務了。
通(tong)過對DDD的簡要介紹,以及對每一層的技術選型以及實現的思(si)路和邏輯的梳(shu)理,希望對你有所(suo)幫助。
??面向.NET開發者的AI Agent 開發課程【.NET+AI | 智能體開發進階】已上線,歡迎掃碼加入學習。??
關注我的公眾號『向 AI 而行』,我們微信不見不散。
閱罷此文,如果您覺得本文不錯并有所收獲,請【打賞】或【推薦】,也可【評論】留下您的問題或建議與我交流。 你的支持是我不斷創作和分享的不竭動力!

