MassTransit | 基于StateMachine实现Saga编排式分布式事务

什么是状态机

状态机作为一种程序开发范例,在实际的应用开发中有很多的应用场景,其中.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充当着事务编排器的角色,通过集中定义状态、转移条件和状态转移的执行顺序,实现高内聚的事务流转控制,也确保了其他伴生服务仅需关注自己的业务逻辑,而无需关心事务的流转,真正实现了关注点分离。

本文由mdnice多平台发布

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容