什麼是狀態機
狀態機作為一種程式開發範例,在實際的應用開發中有很多的應用場景,其中.NET 中的async/await 的核心底層實現就是基於狀態機機制。狀態機分為兩種:有限狀態機和無限狀態機,本文介紹的就是有限狀態機,有限狀態機在任何時候都可以準確地處於有限狀態中的一種,其可以根據一些輸入從一個狀態轉換到另一個狀態。一個有限狀態機是由其狀態列表、初始狀態和觸發每個轉換的輸入來定義的。如下圖展示的就是一個閘機的狀態機示意圖:
從上圖可以看出,狀態機主要有以下核心概念:
- State:狀態,閘機有已開啟(opened)和已關閉(closed)狀態。
- Transition:轉移,即閘機從一個狀態轉移到另一個狀態的過程。
- Transition Condition:轉移條件,也可理解為事件,即閘機在某一狀態下只有觸發了某個轉移條件,才會執行狀態轉移。比如,閘機處於已關閉狀態時,只有接收到開啟事件才會執行轉移動作,進而轉移到開啟狀態。
- Action:動作,即完成狀態轉移要執行的動作。比如要從關閉狀態轉移到開啟狀態,則需要執行開閘動作。
在.NET中,dotnet-state-machine/stateless
和MassTransit
都提供了開箱即用的狀態機實現。本文將重點介紹MassTransit
中的狀態機在Saga 模式中的應用。
MassTransit StateMachine
在MassTransit 中MassTransitStateMachine
就是狀態機的具體抽象,可以用其編排一系列事件來實現狀態的流轉,也可以用來實現Saga模式的分散式事務。並支援與EF Core和Dapper整合將狀態持久化到關係型資料庫,也支援將狀態持久化到MongoDB、Redis等資料庫。是以簡單的下單流程:建立訂單->扣減庫存->支付訂單舉例而言,其示意圖如下所示。
基於狀態機實現編排式Saga事務
那具體如何使用MassTransitStateMachine
來應用編排式Saga 模式呢,接下來就來建立解決方案來實現以上下單流程示例。依次建立以下專案,除共享類庫專案外,均安裝MassTransit
和MassTransit.RabbitMQ
NuGet包。
專案 | 專案名 | 專案型別 |
---|---|---|
訂單服務 | 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
});
}
}
最後來實現訂單狀態機,主要包含以下幾步:
- 定義狀態機狀態: 一個狀態機從啟動到結束可能會經歷各種異常,包括程式異常或物理故障,為確保狀態機能從異常中恢復,因此必須儲存狀態機的狀態。本例中,定義
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; }
}
- 定義狀態機:直接繼承自
MassTransitStateMachine
並同時指定狀態例項即可:
namespace MassTransit.SmDemo.OrderService;
public class OrderStateMachine : MassTransitStateMachine<OrderState>
{
}
- 註冊狀態機:這裡指定記憶體持久化方式來持久化狀態,也可指定諸如MongoDb、MySQL等資料庫進行狀態持久化:
return services.AddMassTransit(x =>
{
//...
x.AddSagaStateMachine<OrderStateMachine, OrderState>()
.InMemoryRepository();
}
- 定義狀態列表:即狀態機涉及到的系列狀態,並透過
State
型別定義,本例中為:- 已建立:
public State Created { get; private set; }
- 庫存已扣減:
public State InventoryDeducted { get; private set; }
- 已支付:
public State Paid { get; private set; }
- 已取消:
public State Canceled { get; private set; }
- 已建立:
- 定義轉移條件:即推動狀態流轉的事件,透過
Event<T>
型別定義,本例涉及有:- 訂單成功建立事件:
public Event<ICreateOrderSucceed> OrderCreated {get; private set;}
- 庫存扣減成功事件:
public Event<IDeduceInventorySucceed> DeduceInventorySucceed {get; private set;}
- 庫存扣減失敗事件:
public Event<IDeduceInventoryFailed> DeduceInventoryFailed {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<ICancelOrderSucceed> OrderCanceled { get; private set; }
- 訂單成功建立事件:
- 定義關聯關係:由於每個事件都是孤立的,但相關聯的事件終會作用到某個具體的狀態機例項上,如何關聯事件以推動狀態機的轉移呢?配置
關聯Id
。以下就是將事件訊息中的傳遞的OrderId
作為關聯ID。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(() => PayOrderSucceed, x => x.CorrelateById(m => m.Message.OrderId));
- 定義狀態轉移:即狀態在什麼條件下做怎樣的動作完成狀態的轉移,本例中涉及的正向狀態轉移有:
(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
}))
);
}
}
庫存服務
庫存服務在整個下單流程的職責主要是庫存的扣減和返還,其僅需要訂閱IDeduceInventoryCommand
和IReturnInventoryCommand
兩個命令並實現即可。程式碼如下所示:
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-state
Exchange,其繫結關係如下圖所示,該Exchange負責從其他同名事件的Exchange轉發事件。
總結
透過以上示例的講解,相信瞭解到MassTransit StateMachine的強大之處。StateMachine充當著事務編排器的角色,透過集中定義狀態、轉移條件和狀態轉移的執行順序,實現高內聚的事務流轉控制,也確保了其他伴生服務僅需關注自己的業務邏輯,而無需關心事務的流轉,真正實現了關注點分離。