命令查詢職責分離 - CQRS

磊_磊發表於2022-11-30

概念

CQRS是一種與領域驅動設計和事件溯源相關的架構模式, 它的全稱是Command Query Responsibility Segregation, 又叫命令查詢職責分離, Greg Young在2010年創造了這個術語, 它是基於Bertrand Meyer 的 CQS (Command-Query Separation 命令查詢分離原則) 設計模式。

CQRS認為不論業務多複雜在最終實現的時候, 無非是讀寫操作, 因此建議將應用程式分為兩個方面, 即Command(命令)和Query(查詢)

  • 命令端:

    • 關注各種業務如何處理, 更新狀態進行持久化
    • 不返回任何結果 (void)
  • 查詢端:

    • 查詢, 並從不修改資料庫

CQRS的三種實現

單一資料庫的CQRS

命令查詢職責分離 - CQRS

命令與讀取操作的是同一個資料庫, 命令端透過ORM框架將實體儲存到資料庫中, 查詢端透過資料訪問層獲取資料 (資料訪問層透過ORM框架或者儲存過程獲取資料)

雙資料庫的CQRS

命令查詢職責分離 - CQRS

命令與讀取操作的是不同的資料庫, 命令端透過ORM框架將實體儲存到 寫庫 (Write Db), 並將本地改動推送到 讀庫 (Read Db), 查詢端透過資料訪問層訪問 讀庫 (Read Db), 使用這種模式可以帶來以下好處:

  • 查詢更簡單
    • 讀操作不需要任何的完整性校驗, 也不需要外來鍵約束, 可以減少鎖爭用, 我們可以針對查詢端單獨最佳化, 還可以使用剛好包含每個模板需要的資料的資料庫檢視,使得查詢變得更快更簡單
  • 提升查詢端的使用體驗
    • 由於這種架構將讀寫徹底分離,由於一般系統是讀操作遠遠大於寫操作, 這給我們的系統帶來了巨大的效能提升, 極大的提升了客戶的使用體驗
  • 關注點分離
    • 讀寫分離的模型可以使得關注點分離, 使得讀模型會變得相對簡單

事件溯源 (Event Sourcing) CQRS

命令查詢職責分離 - CQRS

透過事件溯源實現的CQRS中會將應用程式的改變都以事件的方式儲存起來, 使用這種模式可以帶來以下好處:

  • 事件儲存中了完整的審計跟蹤, 後續出現問題時方便跟蹤
  • 可以在任何的時間點重建實體的狀態, 它將有助於排查問題並修復問題
  • 提升查詢端的使用體驗
    • 查詢端與命令端可以是完全不同的資料來源, 查詢端可以針對查詢條件做針對應的最佳化, 或者使用像ESRedis等用來儲存資料, 提升查詢效率
  • 獨立縮放
    • 命令端與查詢端可以被獨立縮放, 減少鎖爭用

當然事情有利自然也有弊, CQRS的使用固然會帶來很多好處, 但同樣它也會給專案帶來複雜度的提升, 並且雙資料庫模式、事件溯源模式CQRS, 使用的是最終一致性, 這些都是我們在選擇技術方案時必須要考慮的

設計

上述文章中我們瞭解到了CQRS其本質上是一種讀寫分離的設計思想, 它並不是強制性的規定必須要怎樣去做, 這點與之前的IEvent (程式內事件IIntegrationEvent (跨程式事件不同, 它並不是強制性的, 根據CQRS的設計模式我們將事件分成CommandQuery

  • 由於Query (查詢) 是需要有返回值的, 因此我們在繼承IEvent的同時, 還額外增加了一個Result屬性用以儲存結果, 我們希望將查詢的結果儲存到Result中, 但它不是強制性的, 我們並沒有強制性要求必須要將結果儲存到Result中。

  • 由於Command (命令) 是沒有返回值的, 因此我們並沒有額外新增Result屬性, 我們認為命令會更新資料, 那就需要用到工作單元, 因此Command除了繼承IEvent之外, 還繼承了ITransaction,這方便了我們在Handler中的可以透過@event.UnitOfWork來管理工作單元, 而不需要透過建構函式來獲取

MasaFramework 並沒有要求必須使用 Event Sourcing 模式 或者 雙資料庫模式 的CQRS, 具體使用哪種實現, 它取決於業務的決策者

下面就就來看看MasaFramework提供的CQRS是如何使用的

入門

  1. 新建ASP.NET Core 空專案Assignment.CqrsDemo,並安裝Masa.Contrib.Dispatcher.EventsMasa.Contrib.Dispatcher.IntegrationEventsMasa.Contrib.Dispatcher.IntegrationEvents.DaprMasa.Contrib.ReadWriteSplitting.CqrsMasa.Contrib.Development.DaprStarter.AspNetCore
dotnet new web -o Assignment.CqrsDemo
cd Assignment.CqrsDemo

dotnet add package Masa.Contrib.Dispatcher.Events --version 0.7.0-preview.9 //使用程式內事件匯流排
dotnet add package Masa.Contrib.Dispatcher.IntegrationEvents --version 0.7.0-preview.9 //使用跨程式事件匯流排
dotnet add package Masa.Contrib.Dispatcher.IntegrationEvents.Dapr --version 0.7.0-preview.9 //使用Dapr提供pubsub能力
dotnet add package Masa.Contrib.ReadWriteSplitting.Cqrs --version 0.7.0-preview.9 //使用CQRS

dotnet add package Masa.Contrib.Development.DaprStarter.AspNetCore --version 0.7.0-preview.9  //開發環境下協助 Dapr Sidecar, 用於透過Dapr釋出整合事件
  1. 註冊跨程式事件匯流排、程式內事件匯流排, 修改類Program.cs

示例中未真實使用DB, 不再使用發件箱模式, 只需要使用整合事件提供的PubSub能力即可

builder.Services.AddIntegrationEventBus(dispatcherOptions =>
{
    dispatcherOptions.UseDapr();//使用 Dapr 提供的PubSub能力
    dispatcherOptions.UseEventBus();//使用程式內事件匯流排
});
  1. 註冊Dapr Starter 協助管理Dapr Sidecar (開發環境使用)
if (builder.Environment.IsDevelopment())
    builder.Services.AddDaprStarter();
  1. 新增加新增商品方法, 修改類Program.cs
app.MapPost("/goods/add", async (AddGoodsCommand command, IEventBus eventBus) =>
{
    await eventBus.PublishAsync(command);
});

/// <summary>
/// 新增商品引數, 用於接受商品引數
/// </summary>
public record AddGoodsCommand : Command
{
    public string Name { get; set; }

    public string Cover { get; set; }

    public decimal Price { get; set; }

    public int Count { get; set; }
}
  1. 新增加查詢商品的方法, 修改類Program.cs
app.MapGet("/goods/{id}", async (Guid id, IEventBus eventBus) =>
{
    var query = new GoodsItemQuery(id);
    await eventBus.PublishAsync(query);
    return query.Result;
});

/// <summary>
/// 用於接收查詢商品資訊引數
/// </summary>
public record GoodsItemQuery : Query<GoodsItemDto>
{
    public Guid Id { get; set; } = default!;

    public override GoodsItemDto Result { get; set; }

    public GoodsItemQuery(Guid id)
    {
        Id = id;
    }
}

/// <summary>
/// 用於返回商品資訊
/// </summary>
public class GoodsItemDto
{
    public Guid Id { get; set; }

    public string Name { get; set; }

    public string Cover { get; set; }

    public decimal Price { get; set; }

    public int Count { get; set; }

    public DateTime DateTime { get; set; }
}
  1. 新增Command處理程式, 新增類CommandHandler.cs
public class CommandHandler
{
    /// <summary>
    /// 將商品新增到Db,併傳送跨程式事件
    /// </summary>
    /// <param name="command"></param>
    /// <param name="integrationEventBus"></param>
    [EventHandler]
    public async Task AddGoods(AddGoodsCommand command, IIntegrationEventBus integrationEventBus)
    {
        //todo: 模擬新增商品到db併傳送新增商品整合事件

        var goodsId = Guid.NewGuid(); //模擬新增到db後並獲取商品id
        await integrationEventBus.PublishAsync(new AddGoodsIntegrationEvent(goodsId, command.Name, command.Cover, command.Price,
            command.Count));
    }
}

/// <summary>
/// 跨程式事件, 傳送新增商品事件
/// </summary>
/// <param name="Id"></param>
/// <param name="Name"></param>
/// <param name="Cover"></param>
/// <param name="Price"></param>
/// <param name="Count"></param>
public record AddGoodsIntegrationEvent(Guid Id, string Name, string Cover, decimal Price, int Count) : IntegrationEvent
{
    public Guid Id { get; set; } = Id;

    public string Name { get; set; } = Name;

    public string Cover { get; set; } = Cover;

    public decimal Price { get; set; } = Price;

    public int Count { get; set; } = Count;

    public override string Topic { get; set; } = nameof(AddGoodsIntegrationEvent);
}
  1. 新增Query處理程式, 新增類QueryHandler.cs
public class QueryHandler
{
    /// <summary>
    /// 從快取查詢商品資訊
    /// </summary>
    /// <param name="query"></param>
    /// <returns></returns>
    [EventHandler]
    public Task GetGoods(GoodsItemQuery query)
    {
        //todo: 模擬從cache獲取商品
        var goods = new GoodsItemDto();

        query.Result = goods;
        return Task.CompletedTask;
    }
}
  1. 新增新增商品的跨程式事件的處理服務, 修改Program.cs
app.MapPost(
    "/integration/goods/add",
    [Topic("pubsub", nameof(AddGoodsIntegrationEvent))]
    (AddGoodsIntegrationEvent @event, ILogger<Program> logger) =>
    {
        //todo: 模擬新增商品到快取
        logger.LogInformation("新增商品到快取, {Event}", @event);
    });

// 使用 dapr 來訂閱跨程式事件
app.UseRouting();
app.UseCloudEvents();
app.UseEndpoints(endpoint =>
{
    endpoint.MapSubscribeHandler();
});

流水賬式的服務會使得Program.cs變得十分臃腫, 可以透過Masa Framework提供的MinimalAPIs來簡化Program.cs 點選檢視詳情

總結

我們上面的例子是透過事件匯流排來完成解耦以及資料模型的同步, 使用的雙資料庫模式, 但讀庫使用的是 快取資料庫, 在Command端做商品的新增操作, 在Query端只做查詢, 且兩端分別使用各自的資料來源, 兩者業務互不影響, 並且由於快取資料庫效能更強, 它將最大限度的提升效能, 使得我們有更好的使用體驗。

Masa Framework中僅僅是透過ICommandIQuery將讀寫分開, 但這並沒有硬性要求, 事實上你使用IEvent也是可以的, CQRS只是一種設計模式, 這點我們要清楚, 它只是告訴我們要按照一個什麼樣的標準去做, 但具體怎麼來做, 取決於業務的決策者, 除此之外, 後續Masa Framework還會增加對Event Sourcing事件溯源)的支援, 透過事件重放, 允許我們隨時重建到物件的任何狀態

本章原始碼

Assignment15

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

CQRS架構專案:https://github.com/masalabs/MASA.EShop/tree/main/src/Services/Masa.EShop.Services.Catalog

參考

開源地址

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, 歡迎聯絡我們

16373211753064.png

相關文章