MassTransit | 基於StateMachine實現Saga編排式分散式事務

2023-01-02 18:01:31

什麼是狀態機

狀態機作為一種程式開發範例,在實際的應用開發中有很多的應用場景,其中.NET 中的async/await 的核心底層實現就是基於狀態機機制。狀態機分為兩種:有限狀態機和無限狀態機,本文介紹的就是有限狀態機,有限狀態機在任何時候都可以準確地處於有限狀態中的一種,其可以根據一些輸入從一個狀態轉換到另一個狀態。一個有限狀態機是由其狀態列表、初始狀態和觸發每個轉換的輸入來定義的。如下圖展示的就是一個閘機的狀態機示意圖:

從上圖可以看出,狀態機主要有以下核心概念:

  1. State:狀態,閘機有已開啟(opened)和已關閉(closed)狀態。
  2. Transition:轉移,即閘機從一個狀態轉移到另一個狀態的過程。
  3. Transition Condition:轉移條件,也可理解為事件,即閘機在某一狀態下只有觸發了某個轉移條件,才會執行狀態轉移。比如,閘機處於已關閉狀態時,只有接收到開啟事件才會執行轉移動作,進而轉移到開啟狀態。
  4. Action:動作,即完成狀態轉移要執行的動作。比如要從關閉狀態轉移到開啟狀態,則需要執行開閘動作。

在.NET中,dotnet-state-machine/statelessMassTransit都提供了開箱即用的狀態機實現。本文將重點介紹MassTransit中的狀態機在Saga 模式中的應用。

MassTransit StateMachine

在MassTransit 中MassTransitStateMachine就是狀態機的具體抽象,可以用其編排一系列事件來實現狀態的流轉,也可以用來實現Saga模式的分散式事務。並支援與EF Core和Dapper整合將狀態持久化到關係型資料庫,也支援將狀態持久化到MongoDB、Redis等資料庫。是以簡單的下單流程:建立訂單->扣減庫存->支付訂單舉例而言,其示意圖如下所示。

基於狀態機實現編排式Saga事務

那具體如何使用MassTransitStateMachine來應用編排式Saga 模式呢,接下來就來建立解決方案來實現以上下單流程範例。依次建立以下專案,除共用類庫專案外,均安裝MassTransitMassTransit.RabbitMQNuGet包。

專案 專案名 專案型別
訂單服務 MassTransit.SmDemo.OrderService ASP.NET Core Web API
庫存服務 MassTransit.SmDemo.InventoryService Worker Service
支付服務 MassTransit.SmDemo.PaymentService Worker Service
共用類庫 MassTransit.SmDemo.Shared Class Library

三個服務都新增擴充套件類MassTransitServiceExtensions,並在Program.cs類中呼叫services.AddMassTransitWithRabbitMq();註冊服務。

using System.Reflection;
using MassTransit.CourierDemo.Shared.Models;

namespace MassTransit.CourierDemo.InventoryService;

public static class MassTransitServiceExtensions
{
    public static IServiceCollection AddMassTransitWithRabbitMq(this IServiceCollection services)
    {
        return services.AddMassTransit(x =>
        {
            x.SetKebabCaseEndpointNameFormatter();

            // By default, sagas are in-memory, but should be changed to a durable
            // saga repository.
            x.SetInMemorySagaRepositoryProvider();

            var entryAssembly = Assembly.GetEntryAssembly();
            x.AddConsumers(entryAssembly);
            x.AddSagaStateMachines(entryAssembly);
            x.AddSagas(entryAssembly);
            x.AddActivities(entryAssembly);
            x.UsingRabbitMq((context, busConfig) =>
            {
                busConfig.Host(
                    host: "localhost",
                    port: 5672,
                    virtualHost: "masstransit",
                    configure: hostConfig =>
                    {
                        hostConfig.Username("guest");
                        hostConfig.Password("guest");
                    });

                busConfig.ConfigureEndpoints(context);
            });
        });
    }
}

訂單服務

訂單服務作為下單流程中的核心服務,主要職責包含接收建立訂單請求和訂單狀態機的實現。先來定義OrderController如下:

namespace MassTransit.SmDemo.OrderService.Controllers;
[ApiController]
[Route("[controller]")]
public class OrderController : ControllerBase
{
    private readonly IBus _bus;
    public OrderController(IBus bus)
    {
        _bus = bus;
    }

    [HttpPost]
    public async Task<IActionResult> CreateOrder(CreateOrderDto createOrderDto)
    {
        await _bus.Publish<ICreateOrderCommand>(new
        {
            createOrderDto.CustomerId,
            createOrderDto.ShoppingCartItems
        });
        return Ok();
    }
}

緊接著,訂閱ICreateOrderCommand,執行訂單建立邏輯,訂單建立完畢後會釋出ICreateOrderSucceed事件。

public class CreateOrderConsumer : IConsumer<ICreateOrderCommand>
{
    private readonly ILogger<CreateOrderConsumer> _logger;

    public CreateOrderConsumer(ILogger<CreateOrderConsumer> logger)
    {
        _logger = logger;
    }
    public async Task Consume(ConsumeContext<ICreateOrderCommand> context)
    {
        var shoppingItems =
            context.Message.ShoppingCartItems.Select(item => new ShoppingCartItem(item.SkuId, item.Price, item.Qty));
        var order = new Order(context.Message.CustomerId).NewOrder(shoppingItems.ToArray());
        await OrderRepository.Insert(order);
        
        _logger.LogInformation($"Order {order.OrderId} created successfully");
        await context.Publish<ICreateOrderSucceed>(new
        {
            order.OrderId,
            order.OrderItems
        });
    }
}

最後來實現訂單狀態機,主要包含以下幾步:

  1. 定義狀態機狀態: 一個狀態機從啟動到結束可能會經歷各種異常,包括程式異常或物理故障,為確保狀態機能從異常中恢復,因此必須儲存狀態機的狀態。本例中,定義OrderState以儲存狀態機範例狀態資料:
using MassTransit.SmDemo.OrderService.Domains;

namespace MassTransit.SmDemo.OrderService;

public class OrderState : SagaStateMachineInstance
{
    public Guid CorrelationId { get; set; }
    public string CurrentState { get; set; }
    public Guid OrderId { get; set; }
    public decimal Amount { get; set; }
    public List<OrderItem> OrderItems { get; set; }
}
  1. 定義狀態機:直接繼承自MassTransitStateMachine並同時指定狀態範例即可:
namespace MassTransit.SmDemo.OrderService;

public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
}
  1. 註冊狀態機:這裡指定記憶體持久化方式來持久化狀態,也可指定諸如MongoDb、MySQL等資料庫進行狀態持久化:
return services.AddMassTransit(x =>
{
    //...
    x.AddSagaStateMachine<OrderStateMachine, OrderState>()
        .InMemoryRepository();
}
  1. 定義狀態列表:即狀態機涉及到的系列狀態,並通過State型別定義,本例中為:
    1. 已建立:public State Created { get; private set; }
    2. 庫存已扣減:public State InventoryDeducted { get; private set; }
    3. 已支付:public State Paid { get; private set; }
    4. 已取消:public State Canceled { get; private set; }
  2. 定義轉移條件:即推動狀態流轉的事件,通過Event<T>型別定義,本例涉及有:
    1. 訂單成功建立事件:public Event<ICreateOrderSucceed> OrderCreated {get; private set;}
    2. 庫存扣減成功事件:public Event<IDeduceInventorySucceed> DeduceInventorySucceed {get; private set;}
    3. 庫存扣減失敗事件:public Event<IDeduceInventoryFailed> DeduceInventoryFailed {get; private set;}
    4. 訂單支付成功事件:public Event<IPayOrderSucceed> PayOrderSucceed {get; private set;}
    5. 訂單支付失敗事件:public Event<IPayOrderFailed> PayOrderFailed {get; private set;}
    6. 庫存已返還事件:public Event<IReturnInventorySucceed> ReturnInventorySucceed { get; private set; }
    7. 訂單取消事件:public Event<ICancelOrderSucceed> OrderCanceled { get; private set; }
  3. 定義關聯關係:由於每個事件都是孤立的,但相關聯的事件終會作用到某個具體的狀態機範例上,如何關聯事件以推動狀態機的轉移呢?設定關聯Id。以下就是將事件訊息中的傳遞的OrderId作為關聯ID。
    1. Event(() => OrderCreated, x => x.CorrelateById(m => m.Message.OrderId));
    2. Event(() => DeduceInventorySucceed, x => x.CorrelateById(m => m.Message.OrderId));
    3. Event(() => DeduceInventoryFailed, x => x.CorrelateById(m => m.Message.OrderId));
    4. Event(() => PayOrderSucceed, x => x.CorrelateById(m => m.Message.OrderId));
  4. 定義狀態轉移:即狀態在什麼條件下做怎樣的動作完成狀態的轉移,本例中涉及的正向狀態轉移有:

(1) 初始狀態->已建立:觸發條件為OrderCreated事件,同時要傳送IDeduceInventoryCommand推動庫存服務執行庫存扣減。

Initially(
    When(OrderCreated)
        .Then(context =>
        {
            context.Saga.OrderId = context.Message.OrderId;
            context.Saga.OrderItems = context.Message.OrderItems;
            context.Saga.Amount = context.Message.OrderItems.Sum(x => x.Price * x.Qty);
        })
        .PublishAsync(context => context.Init<IDeduceInventoryCommand>(new
        {
            context.Saga.OrderId,
            DeduceInventoryItems =
                context.Saga.OrderItems.Select(x => new DeduceInventoryItem(x.SkuId, x.Qty)).ToList()
        }))
        .TransitionTo(Created));

(2) 已建立-> 庫存已扣減:觸發條件為DeduceInventorySucceed事件,同時要傳送IPayOrderCommand推動支付服務執行訂單支付。

During(Created,
    When(DeduceInventorySucceed)
        .Then(context =>
        {
            context.Publish<IPayOrderCommand>(new
            {
                context.Saga.OrderId,
                context.Saga.Amount
            });
        }).TransitionTo(InventoryDeducted),
    When(DeduceInventoryFailed).Then(context =>
    {
        context.Publish<ICancelOrderCommand>(new
        {
            context.Saga.OrderId
        });
    })
);

(3) 庫存已扣減->已支付:觸發條件為PayOrderSucceed事件,轉移到已支付後,流程結束。

During(InventoryDeducted,
    When(PayOrderFailed).Then(context =>
    {
        context.Publish<IReturnInventoryCommand>(new
        {
            context.Message.OrderId,
            ReturnInventoryItems =
                context.Saga.OrderItems.Select(x => new ReturnInventoryItem(x.SkuId, x.Qty)).ToList()
        });
    }),
    When(PayOrderSucceed).TransitionTo(Paid).Then(context => context.SetCompleted()));

最終完整版的OrderStateMachine如下所示:

using MassTransit.SmDemo.OrderService.Events;
using MassTransit.SmDemo.Shared.Contracts;

namespace MassTransit.SmDemo.OrderService;

public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
    public State Created { get; private set; }
    public State InventoryDeducted { get; private set; }
    public State Paid { get; private set; }
    public State Canceled { get; private set; }

    public Event<ICreateOrderSucceed> OrderCreated { get; private set; }
    public Event<IDeduceInventorySucceed> DeduceInventorySucceed { get; private set; }
    public Event<IDeduceInventoryFailed> DeduceInventoryFailed { get; private set; }
    public Event<ICancelOrderSucceed> OrderCanceled { get; private set; }
    public Event<IPayOrderSucceed> PayOrderSucceed { get; private set; }
    public Event<IPayOrderFailed> PayOrderFailed { get; private set; }
    public Event<IReturnInventorySucceed> ReturnInventorySucceed { get; private set; }
    public Event<IOrderStateRequest> OrderStateRequested { get; private set; }
    
	public OrderStateMachine()
    {
        Event(() => OrderCreated, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => DeduceInventorySucceed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => DeduceInventoryFailed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => ReturnInventorySucceed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PayOrderSucceed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => PayOrderFailed, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => OrderCanceled, x => x.CorrelateById(m => m.Message.OrderId));
        Event(() => OrderStateRequested, x =>
        {
            x.CorrelateById(m => m.Message.OrderId);
            x.OnMissingInstance(m =>
            {
                return m.ExecuteAsync(x => x.RespondAsync<IOrderNotFoundOrCompleted>(new { x.Message.OrderId }));
            });
        });

        InstanceState(x => x.CurrentState);

        Initially(
            When(OrderCreated)
                .Then(context =>
                {
                    context.Saga.OrderId = context.Message.OrderId;
                    context.Saga.OrderItems = context.Message.OrderItems;
					var amount = context.Message.OrderItems.Sum(x => x.Price * x.Qty);
                    context.Saga.Amount = amount;
                })
                .PublishAsync(context => context.Init<IDeduceInventoryCommand>(new
                {
                    context.Saga.OrderId,
                    DeduceInventoryItems =
                        context.Saga.OrderItems.Select(x => new DeduceInventoryItem(x.SkuId, x.Qty)).ToList()
                }))
                .TransitionTo(Created));

        During(Created,
            When(DeduceInventorySucceed)
                .Then(context =>
                {
                    context.Publish<IPayOrderCommand>(new
                    {
                        context.Saga.OrderId,
                        context.Saga.Amount
                    });
                }).TransitionTo(InventoryDeducted),
            When(DeduceInventoryFailed).Then(context =>
            {
                context.Publish<ICancelOrderCommand>(new
                {
                    context.Saga.OrderId
                });
            })
        );

        During(InventoryDeducted,
            When(PayOrderFailed).Then(context =>
            {
                context.Publish<IReturnInventoryCommand>(new
                {
                    context.Message.OrderId,
                    ReturnInventoryItems =
                        context.Saga.OrderItems.Select(x => new ReturnInventoryItem(x.SkuId, x.Qty)).ToList()
                });
            }),
            When(PayOrderSucceed).TransitionTo(Paid).Then(context => context.SetCompleted()),
            When(ReturnInventorySucceed)
                .ThenAsync(context => context.Publish<ICancelOrderCommand>(new
                {
                    context.Saga.OrderId
                })).TransitionTo(Created));

        DuringAny(When(OrderCanceled).TransitionTo(Canceled).ThenAsync(async context =>
        {
            await Task.Delay(TimeSpan.FromSeconds(10));
            await context.SetCompleted();
        }));


        DuringAny(
            When(OrderStateRequested)
                .RespondAsync(x => x.Init<IOrderStateResponse>(new
                {
                    x.Saga.OrderId,
                    State = x.Saga.CurrentState
                }))
        );
    }
}

庫存服務

庫存服務在整個下單流程的職責主要是庫存的扣減和返還,其僅需要訂閱IDeduceInventoryCommandIReturnInventoryCommand兩個命令並實現即可。程式碼如下所示:

using MassTransit.SmDemo.InventoryService.Repositories;
using MassTransit.SmDemo.Shared.Contracts;

namespace MassTransit.SmDemo.InventoryService.Consumers;

public class DeduceInventoryConsumer : IConsumer<IDeduceInventoryCommand>
{
    private readonly ILogger<DeduceInventoryConsumer> _logger;

    public DeduceInventoryConsumer(ILogger<DeduceInventoryConsumer> logger)
    {
        _logger = logger;
    }

    public async Task Consume(ConsumeContext<IDeduceInventoryCommand> context)
    {
        if (!CheckStock(context.Message.DeduceInventoryItems))
        {
            _logger.LogWarning($"Insufficient stock for order [{context.Message.OrderId}]!");
            await context.Publish<IDeduceInventoryFailed>(
                new { context.Message.OrderId, Reason = "insufficient stock" });
        }
        else
        {
            _logger.LogInformation($"Inventory has been deducted for order [{context.Message.OrderId}]!");
            DeduceStocks(context.Message.DeduceInventoryItems);
            await context.Publish<IDeduceInventorySucceed>(new { context.Message.OrderId });
        }
    }


    private bool CheckStock(List<DeduceInventoryItem> deduceItems)
    {
        foreach (var stockItem in deduceItems)
        {
            if (InventoryRepository.GetStock(stockItem.SkuId) < stockItem.Qty) return false;
        }

        return true;
    }

    private void DeduceStocks(List<DeduceInventoryItem> deduceItems)
    {
        foreach (var stockItem in deduceItems)
        {
            InventoryRepository.TryDeduceStock(stockItem.SkuId, stockItem.Qty);
        }
    }
}
namespace MassTransit.SmDemo.InventoryService.Consumers;

public class ReturnInventoryConsumer : IConsumer<IReturnInventoryCommand>
{
    private readonly ILogger<ReturnInventoryConsumer> _logger;

    public ReturnInventoryConsumer(ILogger<ReturnInventoryConsumer> logger)
    {
        _logger = logger;
    }

    public async Task Consume(ConsumeContext<IReturnInventoryCommand> context)
    {
        foreach (var returnInventoryItem in context.Message.ReturnInventoryItems)
        {
            InventoryRepository.ReturnStock(returnInventoryItem.SkuId, returnInventoryItem.Qty);
        }

        _logger.LogInformation($"Inventory has been returned for order [{context.Message.OrderId}]!");
        await context.Publish<IReturnInventorySucceed>(new { context.Message.OrderId });
    }
}

支付服務

對於下單流程的支付用例來說,要麼成功要麼失敗,因此僅需要訂閱IPayOrderCommand命令即可,具體PayOrderConsumer實現如下:

using MassTransit.SmDemo.Shared.Contracts;

namespace MassTransit.SmDemo.PaymentService.Consumers;

public class PayOrderConsumer : IConsumer<IPayOrderCommand>
{
    private readonly ILogger<PayOrderConsumer> _logger;

    public PayOrderConsumer(ILogger<PayOrderConsumer> logger)
    {
        _logger = logger;
    }
    public async Task Consume(ConsumeContext<IPayOrderCommand> context)
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
        if (context.Message.Amount % 2 == 0)
        {_logger.LogInformation($"Order [{context.Message.OrderId}] paid successfully!");
            await context.Publish<IPayOrderSucceed>(new { context.Message.OrderId });
        }
        else
        {
            _logger.LogWarning($"Order [{context.Message.OrderId}] payment failed!");
            await context.Publish<IPayOrderFailed>(new
            {
                context.Message.OrderId,
                Reason = "Insufficient account balance"
            });
        }
    }
}

執行結果

啟動三個專案,並在Swagger中發起訂單建立請求,如下圖所示:

由於訂單總額為奇數,因此支付會失敗,最終控制檯輸出如下圖所示:

開啟RabbitMQ後臺,可以看見MassTransit按照約定建立了以下佇列用於服務間的訊息傳遞:

其中order-state佇列繫結到型別為fanout的同名order-stateExchange,其繫結關係如下圖所示,該Exchange負責從其他同名事件的Exchange轉發事件。

總結

通過以上範例的講解,相信瞭解到MassTransit StateMachine的強大之處。StateMachine充當著事務編排器的角色,通過集中定義狀態、轉移條件和狀態轉移的執行順序,實現高內聚的事務流轉控制,也確保了其他伴生服務僅需關注自己的業務邏輯,而無需關心事務的流轉,真正實現了關注點分離。