MasaFramework -- 領域驅動設計

2022-12-05 15:01:21

概念

什麼是領域驅動設計

領域驅動的主要思想是, 利用確定的業務模型來指導業務與應用的設計和實現。主張開發人員與業務人員持續地溝通和模型的持續迭代,從而保證業務模型與程式碼的一致性,實現有效管理業務的複雜度,優化軟體設計的目的

痛點

基於領域驅動設計的模型有很多難點需要克服

  • 統一認知
    • 語言統一, 領域模型術語、DDD模式名稱、技術專業術語、設計模式、業務術語等統一為大家都能認可且理解的名詞, 避免在溝通中出現語言不統一, 從而出現高昂的溝通成本
    • 開發人員應統一認知, 清晰應用服務、領域服務職責、明確聚合根、實體、值物件的基礎概念
  • 劃分限界上下文、找到業務中的核心域、子域、支撐域、通用域
  • 建立聚合根、實體、值物件,明確領域服務與物件的依賴關係

Masa Framework框架提供了基礎設施使得基於領域驅動設計的開發更容易實現, 但它並不能教會你什麼是DDD, 這些概念知識需要我們自己去學習、理解

功能科普

為了方便更好的理解, 下面會先說說關於領域驅動設計的包以及功能職責

Masa.BuildingBlocks.Ddd.Domain

提供了DDD中一些介面以及實現, 它們分別是:

  • Entity (實體) 介面規範、實體實現

未指定主鍵型別的實體需要通過重寫GetKeys方法來指定主鍵, 聚合根支援新增領域事件 (並在EventBus的Handler執行完成後執行)

小竅門: 繼承以AggregateRoot結尾的類是聚合根、繼承以Entity結尾的類是實體

  • Event (事件) 介面

領域事件是由聚合根或者領域服務發出的事件, 其中根據事件型別又可以分為本地事件 (DomainEvent)、整合事件 (IntegrationDomainEvent), 而本地事件根據讀寫性質不同劃分為DomainCommandDomainQuery

IDomainEventBus (領域事件匯流排)被用於釋出領域事件, 支援釋出本地事件整合事件, 同時它還支援事件的壓棧傳送, 壓棧傳送的時間將在 UnitOfWork(工作單元) 提交後依次傳送

  • Repository (倉儲) 介面、倉儲基礎類別實現

遮蔽業務邏輯和持久化基礎設施的差異, 針對不同的儲存設施, 會有不同的實現方式, 但這些不會對我們的業務產生影響, 作為開發者只需要根據實際情況使用對應的依賴包即可, 與 DAO (資料存取物件)略有不同, DAO是資料存取技術的抽象, 而Repository是領域驅動設計的一部分, 我們僅會提供針對聚合根做簡單的增刪改查操作, 而並非針對單個表

由於一些特殊的原因, 我們解除了對非聚合根的限制, 使得它們也可以使用IRepository, 但這個是錯誤的, 後續版本仍然會增加限制, 屆時IRepository將只允許對聚合根進行操作

  • Enumeration (列舉類)

提供列舉類基礎類別, 使用列舉類來代替使用列舉, 檢視原因

  • Services 服務

領域服務是領域模型的操作者, 被用來處理業務邏輯, 它是無狀態的, 狀態由領域物件來儲存, 提供面向應用層的服務, 完成封裝領域知識, 供應用層使用。與應用服務不同的是, 應用服務僅負責編排和轉發, 它將要實現的功能委託給一個或多個領域物件來實現, 它本身只負責處理業務用例的執行順序以及結果的拼裝, 在應用服務中不應該包含業務邏輯

繼承IDomainService的類被標記為領域服務, 領域服務支援從DI獲取, 其中提供了EventBus (用於提供傳送領域事件)

  • Values: 值物件

繼承ValueObject的類被標記為值物件。值物件沒有唯一標識, 任何屬性的變化都視為新的值物件

在專案開發中, 我們可以通過模型對映將值物件對映儲存到單獨的表中也可以對映為一個json字串儲存又或者根據屬性拆分為多列使用, 這些都是可以的, 但無論資料是以什麼方式儲存, 它們是值物件這點不會改變, 因此我們不能錯誤的理解為在資料庫中的表一定是實體或者聚合根, 這種想法是錯誤的

Masa.BuildingBlocks.Data.UoW

提供工作單元介面標準, 工作單元管理者, 確保Repository的操作可以在同一個工作單元下的一致性 (全部成功或者全部失敗)

功能與對應的nuget

  • Masa.Contrib.Ddd.Domain: 領域驅動設計
  • Masa.Contrib.Data.EFCore.SqlServer: 基於EFCore的實現
  • Masa.Contrib.Ddd.Domain.Repository.EFCore: 提供倉儲的預設實現
  • Masa.Contrib.Development.DaprStarter.AspNetCore: 協助管理Dapr Sidecar, 執行dapr
  • Masa.Contrib.Dispatcher.Events.FluentValidation: 提供基於FluentValidation的中介軟體, 為事件提供引數驗證的功能 (後續與MasaBlazor對接後引數錯誤提示更友好, 而不是簡單的Toast)
  • Masa.Contrib.Dispatcher.Events: 本地事件匯流排實現
  • Masa.Contrib.Dispatcher.IntegrationEvents.Dapr: 基於dapr的整合事件實現
  • Masa.Contrib.Dispatcher.IntegrationEvents.EventLogs.EFCore: 為整合事件提供發件箱模式支援
  • Masa.Contrib.Data.UoW.EFCore: 提供工作單元實現
  • FluentValidation.AspNetCore: 提供基於FluentValidation的引數驗證
  • FluentValidation.AspNetCore: 提供基於FluentValidation的引數驗證

入門

我們先簡單瞭解一下下單的流程, 如下圖所示

其中事務中介軟體 (預設提供) 與驗證中介軟體是公共程式碼, 程序內事件釋出後都會執行, 但事務中介軟體不支援巢狀

通過Ddd設計下單設計到的程式碼過多, 下面程式碼只會展示重要部分, 不會逐步講解, 希望大家諒解, 有不理解的加群或者評論探討

  1. 分別建立Assignment17.Ordering.API (訂單服務, ASP.NET Core Web專案)、Assignment17.Ordering.Domain (訂單領域, 類庫)、Assignment17.Ordering.Infrastructure (訂單基礎設施, 類庫)

  2. 註冊DomainEventBus (領域事件匯流排), EventBus (事件匯流排), IntegrationEventBus (整合事件匯流排), 並註冊Repository (倉儲), IUnitOfWork (工作單元)

builder.Services
    .AddValidatorsFromAssembly(Assembly.GetEntryAssembly())//提供基於FluentValidation的引數驗證
    .AddDomainEventBus(assemblies.Distinct().ToArray(), options =>
    {
        options
            .UseIntegrationEventBus(dispatcherOptions => dispatcherOptions.UseDapr().UseEventLog<OrderingContext>())
            .UseEventBus(eventBuilder => eventBuilder.UseMiddleware(typeof(ValidatorMiddleware<>)))
            .UseUoW<OrderingContext>(dbContextBuilder => dbContextBuilder.UseSqlServer())
            .UseRepository<OrderingContext>();
    });
  1. Program.cs中註冊DaprStarter
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddDaprStarter(options =>
    {
        options.DaprGrpcPort = 3000;
        options.DaprGrpcPort = 3001;
    });
}

如果不使用Dapr, 則可以不註冊DaprStarter

  1. Dapr訂閱整合事件
app.UseRouting();

app.UseCloudEvents();
app.UseEndpoints(endpoints =>
{
    endpoints.MapSubscribeHandler();
});
  1. 下單引數驗證

為下單提供引數驗證, 確保進入應用服務Handler的請求引數是合法有效的

public class CreateOrderCommandValidator: AbstractValidator<CreateOrderCommand>
{
    public CreateOrderCommandValidator()
    {
        RuleFor(o => o.Country).NotNull().WithMessage("收件人資訊有誤");
        RuleFor(o => o.City).NotNull().WithMessage("收件人資訊有誤");
        RuleFor(o => o.Street).NotNull().WithMessage("收件人資訊有誤");
        RuleFor(o => o.ZipCode).NotNull().WithMessage("收件人郵政編碼資訊有誤");
    }
}

引數驗證無需手動觸發, 框架會根據傳入ValidatorMiddleware自動觸發

  1. 下單Handler
public class OrderCommandHandler
{
    private readonly IOrderRepository _orderRepository;
    private readonly ILogger<OrderCommandHandler> _logger;

    public OrderCommandHandler(IOrderRepository orderRepository, ILogger<OrderCommandHandler> logger)
    {
        _orderRepository = orderRepository;
        _logger = logger;
    }

    [EventHandler]
    public async Task CreateOrderCommandHandler(CreateOrderCommand message, CancellationToken cancellationToken)
    {
        var address = new Address(message.Street, message.City, message.State, message.Country, message.ZipCode);
        var order = new Order(message.UserId, message.UserName, address, message.CardTypeId, message.CardNumber, message.CardSecurityNumber,
            message.CardHolderName, message.CardExpiration);

        foreach (var item in message.OrderItems)
        {
            order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Discount, item.PictureUrl, item.Units);
        }

        _logger.LogInformation("----- Creating Order - Order: {@Order}", order);

        await _orderRepository.AddAsync(order, cancellationToken);
    }
}
  1. 下單時聚合根釋出訂單狀態變更事件
public Order(string userId, string userName, Address address, int cardTypeId, string cardNumber, string cardSecurityNumber,
        string cardHolderName, DateTime cardExpiration, int? buyerId = null, int? paymentMethodId = null) : this()
{
    _buyerId = buyerId;
    _paymentMethodId = paymentMethodId;
    _orderStatusId = OrderStatus.Submitted.Id;
    _orderDate = DateTime.UtcNow;
    Address = address;

    AddOrderStartedDomainEvent(userId, userName, cardTypeId, cardNumber,
                                cardSecurityNumber, cardHolderName, cardExpiration);
}

private void AddOrderStartedDomainEvent(string userId,
    string userName,
    int cardTypeId,
    string cardNumber,
    string cardSecurityNumber,
    string cardHolderName,
    DateTime cardExpiration)
{
    var orderStartedDomainEvent = new OrderStartedDomainEvent(this, userId, userName, cardTypeId,
                                                                cardNumber, cardSecurityNumber,
                                                                cardHolderName, cardExpiration);
    this.AddDomainEvent(orderStartedDomainEvent);
}

/// <summary>
/// Event used when an order is created
/// </summary>
public record OrderStartedDomainEvent(Order Order,
    string UserId,
    string UserName,
    int CardTypeId,
    string CardNumber,
    string CardSecurityNumber,
    string CardHolderName,
    DateTime CardExpiration) : DomainEvent;
  1. 訂單狀態變更領域事件Handler
public class BuyerHandler
{
    private readonly IBuyerRepository _buyerRepository;
    private readonly IIntegrationEventBus _integrationEventBus;
    private readonly ILogger<BuyerHandler> _logger;

    public BuyerHandler(IBuyerRepository buyerRepository,
        IIntegrationEventBus integrationEventBus,
        ILogger<BuyerHandler> logger)
    {
        _buyerRepository = buyerRepository;
        _integrationEventBus = integrationEventBus;
        _logger = logger;
    }

    [EventHandler]
    public async Task ValidateOrAddBuyerAggregateWhenOrderStarted(OrderStartedDomainEvent orderStartedEvent)
    {
        var cardTypeId = (orderStartedEvent.CardTypeId != 0) ? orderStartedEvent.CardTypeId : 1;
        var buyer = await _buyerRepository.FindAsync(orderStartedEvent.UserId);
        bool buyerOriginallyExisted = buyer != null;

        if (!buyerOriginallyExisted)
        {
            buyer = new Buyer(orderStartedEvent.UserId, orderStartedEvent.UserName);
        }

        buyer!.VerifyOrAddPaymentMethod(cardTypeId,
            $"Payment Method on {DateTime.UtcNow}",
            orderStartedEvent.CardNumber,
            orderStartedEvent.CardSecurityNumber,
            orderStartedEvent.CardHolderName,
            orderStartedEvent.CardExpiration,
            orderStartedEvent.Order.Id);

        var buyerUpdated = buyerOriginallyExisted ?
            _buyerRepository.Update(buyer) :
            _buyerRepository.Add(buyer);

        var orderStatusChangedToSubmittedIntegrationEvent = new OrderStatusChangedToSubmittedIntegrationEvent(
            orderStartedEvent.Order.Id,
            orderStartedEvent.Order.OrderStatus.Name,
            buyer.Name);
        await _integrationEventBus.PublishAsync(orderStatusChangedToSubmittedIntegrationEvent);

        _logger.LogTrace("Buyer {BuyerId} and related payment method were validated or updated for orderId: {OrderId}.",
            buyerUpdated.Id, orderStartedEvent.Order.Id);
    }
}
  1. 訂閱訂單狀態更改為已提交整合事件, 修改Program.cs
app.MapPost("/integrationEvent/OrderStatusChangedToSubmitted",
    [Topic("pubsub", nameof(OrderStatusChangedToSubmittedIntegrationEvent))]
    (ILogger<Program> logger, OrderStatusChangedToSubmittedIntegrationEvent @event) =>
    {
        logger.LogInformation("接收到訂單提交事件, {Order}", @event);
    });

最終的專案結構:

下單的核心邏輯來自於eShopOnContainers, 屬於簡化版的下單, 通過它大家可以更快的理解如何藉助Masa Framework, 方便快捷的設計出基於領域驅動設計的業務系統

參考

本章原始碼

Assignment17

https://github.com/zhenlei520/MasaFramework.Practice

開源地址

MASA.Framework:https://github.com/masastack/MASA.Framework

MASA.EShop:https://github.com/masalabs/MASA.EShop

MASA.Blazor:https://github.com/BlazorComponent/MASA.Blazor

如果你對我們的 MASA Framework 感興趣,無論是程式碼貢獻、使用、提 Issue,歡迎聯絡我們