尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

.NET 8.0实战:用Stateless构建可落地的状态机

.NET 8.0实战:用Stateless构建可落地的状态机 简介本资源是一个基于.NET 8.0 WinForm客户端的状态机实践项目面向C#中高级开发者聚焦状态驱动逻辑的设计与落地特别适用于设备控制、工作流引擎、故障处理等需强状态约束的工业或业务场景。项目采用轻量级开源库Stateless实现状态建模完整封装了状态显示、触发条件判定、故障修复响应及状态切换日志等核心功能代码结构清晰分层合理含Common基础服务、WinTool主界面及状态服务接口定义等模块。压缩包共84个文件以16个C#源码文件如ToolState.cs、ToolWorkService.cs、FrmMain.cs为核心辅以12个缓存文件、11个配置JSON、10个运行时DLL及2个可执行EXE整体仅340KB便于快速导入与调试。已有155人学习下载读者可直接运行体验状态流转效果参考完整项目结构、接口契约设计及Stateless在真实GUI场景中的集成方式。 状态管理这块几乎是每个业务系统都绕不开的脏活累活。工单要流转、订单要流转、审批要流转一旦状态多了代码里到处是if else判断当前状态能不能执行某个操作再加几个状态字段互相牵扯改着改着就崩。尤其是在.NET 8.0这种LTS版本上做长期项目状态流转必须有一套清晰的规则而不是散落在各个业务方法里的临时判断。这篇文章会从实际项目经验出发讲讲如何基于.NET 8.0用Stateless这个开源库搭一个可落地的状态机实例把核心API怎么用、层级状态怎么设计、状态怎么持久化、以及我踩过的几个坑都展开讲透。Stateless在.NET生态里算老牌状态机库了包很小核心概念就State和Trigger两个再配上Permit、Guard、OnEntry这些配置就能把一张状态迁移图固化到代码里。比手写if else强在两点一是非法迁移会被库本身拦住不需要你在业务代码里重复判断二是整个状态图在一处集中配置后续维护时看代码就是看图省去大量沟通成本。1. 为什么要用状态机而不是散落的if else1.1 业务系统里状态判断为什么容易写烂我先说一个真实的场景。之前接手过一个工单系统里面有个工单实体状态字段是int业务代码里到处都是这种写法if (order.Status (int)OrderStatus.Pending) { if (action start user.IsApprover) { order.Status (int)OrderStatus.Processing; } } else if (order.Status (int)OrderStatus.Processing) { if (action complete) { order.Status (int)OrderStatus.Completed; } }这种代码第一个问题是状态迁移规则散落在各个Action方法、甚至前端按钮的显隐逻辑里。你根本不知道从Pending能不能直接到Completed这种问题该去哪个文件里找答案。第二个问题是一旦新增一个状态比如已挂起你就得把所有相关的if else都翻一遍漏掉一个就是线上bug。第三个问题是判断能不能做某个操作和执行操作后改状态这两件事被揉在一起根本没法单独测试。状态机要解决的就是这三点迁移规则集中化、非法迁移检测前置化、状态变化和业务动作解耦。你只需要定义好状态、触发器、迁移表剩下的合法性校验交给状态机内部完成。1.2 Stateless库的基本情况与核心模型Stateless的操作对象很轻量它不依赖容器、不依赖数据库就是一个纯内存的规则引擎核心模型可以用一张表说清楚。概念作用类比State状态用枚举或字符串表示你当前所处的位置Trigger触发事件触发后可能发生状态迁移你做的某个动作Permit配置一条状态触发器→目标状态的合法迁移路线规则Guard守卫条件满足条件才允许这条迁移允许通行的门槛OnEntry / OnExit进入/离开状态时执行的回调到达和离开时的例行检查Stateless最核心的用法就是链式配置。每个状态把自己的合法迁移、进入动作、离开动作都配好然后业务代码只管触发事件至于能不能迁移、迁移后干什么都在配置里定义。这种风格的好处是配置即文档状态图直接长在代码里。在.NET 8.0项目里引入Stateless也非常简单NuGet一条命令dotnet add package Stateless目前最新的稳定版本对.NET 8.0兼容良好底层走的是标准的.NET Standard APILTS项目里可以放心用。2. 实战搭建一个工单流转状态机2.1 状态与触发事件怎么设计为了把方案落地我选一个非常典型的业务场景工单流转。这个场景在IT运维、售后服务、制造业、企业内部支持系统里都有状态不算多但足以讲清楚状态机的核心玩法。先定义状态枚举和触发器枚举public enum WorkOrderState { Pending, // 待处理 Processing, // 处理中 Completed, // 已完成 Cancelled // 已取消 } public enum WorkOrderTrigger { StartProcessing, // 开始处理 CompleteProcessing, // 处理完成 Cancel, // 取消 Reopen // 重新打开 }然后定义迁移规则。这一步是整个设计里最重要的环节在写代码之前我建议先整理一张迁移表当前状态触发事件目标状态是否合法PendingStartProcessingProcessing合法PendingCancelCancelled合法ProcessingCompleteProcessingCompleted合法ProcessingCancelCancelled合法CompletedReopenProcessing合法CancelledReopenPending合法PendingCompleteProcessing无非法CompletedCancel无非法这张表就是状态机的灵魂。你把它画成图贴在项目文档里再把同样的规则用Stateless配置到代码里开发和测试就都有据可依。2.2 核心配置代码与触发流程有了迁移表配置Stateless就非常自然了var machine new StateMachineWorkOrderState, WorkOrderTrigger(WorkOrderState.Pending); machine.Configure(WorkOrderState.Pending) .Permit(WorkOrderTrigger.StartProcessing, WorkOrderState.Processing) .Permit(WorkOrderTrigger.Cancel, WorkOrderState.Cancelled) .OnEntry(() Console.WriteLine(工单进入待处理状态)) .OnExit(() Console.WriteLine(离开待处理状态)); machine.Configure(WorkOrderState.Processing) .Permit(WorkOrderTrigger.CompleteProcessing, WorkOrderState.Completed) .Permit(WorkOrderTrigger.Cancel, WorkOrderState.Cancelled) .OnEntry(() Console.WriteLine(工单开始处理)); machine.Configure(WorkOrderState.Completed) .Permit(WorkOrderTrigger.Reopen, WorkOrderState.Processing) .OnEntry(() Console.WriteLine(工单已完成)); machine.Configure(WorkOrderState.Cancelled) .Permit(WorkOrderTrigger.Reopen, WorkOrderState.Pending) .OnEntry(() Console.WriteLine(工单已取消));这段配置写完工单状态流转的核心规则就已经固化了。触发某个事件时只需要调用Firemachine.Fire(WorkOrderTrigger.StartProcessing); Console.WriteLine(machine.State); // 输出 Processing我来解释一下这里面的执行流程。当你调用Fire时Stateless会做这几件事检查当前状态下该Trigger是否配置了合法迁移如果配置了Guard还会先执行Guard条件判断条件满足则依次执行当前状态的OnExit、进入目标状态的OnEntry最后把State更新为目标状态。如果当前状态下根本没有配置这个Trigger对应的迁移Stateless会直接抛InvalidOperationException。这个异常就是防呆的第一道防线非法操作在库内部就被拦住了。2.3 在ASP.NET Core中注册和使用实际项目里不会像控制台一样new完就算需要在ASP.NET Core中使用。状态机本身是轻量对象而且它真正持有的只是状态迁移规则和当前状态值所以一个推荐的做法是状态机工厂单例注册每次请求按需创建实例。builder.Services.AddSingletonWorkOrderStateMachineFactory();工厂类负责集中配置避免每个调用方重复写Configurepublic class WorkOrderStateMachineFactory { public StateMachineWorkOrderState, WorkOrderTrigger Create(WorkOrderState initialState) { var machine new StateMachineWorkOrderState, WorkOrderTrigger(initialState); Configure(machine); return machine; } private static void Configure(StateMachineWorkOrderState, WorkOrderTrigger machine) { machine.Configure(WorkOrderState.Pending) .Permit(WorkOrderTrigger.StartProcessing, WorkOrderState.Processing) .Permit(WorkOrderTrigger.Cancel, WorkOrderState.Cancelled); machine.Configure(WorkOrderState.Processing) .Permit(WorkOrderTrigger.CompleteProcessing, WorkOrderState.Completed) .Permit(WorkOrderTrigger.Cancel, WorkOrderState.Cancelled); machine.Configure(WorkOrderState.Completed) .Permit(WorkOrderTrigger.Reopen, WorkOrderState.Processing); machine.Configure(WorkOrderState.Cancelled) .Permit(WorkOrderTrigger.Reopen, WorkOrderState.Pending); } }Controller里的用法[ApiController] [Route(api/workorders)] public class WorkOrderController : ControllerBase { private readonly WorkOrderStateMachineFactory _factory; private readonly WorkOrderDbContext _db; public WorkOrderController(WorkOrderStateMachineFactory factory, WorkOrderDbContext db) { _factory factory; _db db; } [HttpPost({id}/start)] public IActionResult Start(long id) { var order _db.WorkOrders.Find(id); if (order is null) { return NotFound(); } var machine _factory.Create(order.Status); machine.Fire(WorkOrderTrigger.StartProcessing); order.Status machine.State; _db.SaveChanges(); return Ok(new { order.Status }); } }这里有个关键点我想强调一下状态机的实例不要被Service长期持有。它是按请求创建的用完之后就丢弃状态值始终保持以数据库为准。这样设计的好处是即使多个请求同时操作同一个工单状态机的内存状态也不会互相污染并发安全的压力会集中在数据库层逻辑上要清晰得多。3. 深入了解Stateless的几个关键机制3.1 OnEntry和OnExit回调的用途与注意事项前面案例里已经出现了OnEntry和OnExit但它们的真正价值还没有完全体现。在真实业务里状态变更往往伴随着一连串副作用发通知、写日志、触发消息队列、更新时间戳等等。如果这些逻辑写在业务方法里每处触发都要重复一遍。而Stateless把回调挂在状态迁移配置上迁移发生时自动执行。举个例子工单从Pending变为Processing时可能需要给处理人推送一条消息从Processing变为Completed时需要记录完成时间并把结果发到消息队列。可以这样配置machine.Configure(WorkOrderState.Processing) .Permit(WorkOrderTrigger.CompleteProcessing, WorkOrderState.Completed) .OnEntry(() { _logger.LogInformation(工单开始处理); _notificationService.Notify(new WorkOrderStartedEvent(orderId)); }); machine.Configure(WorkOrderState.Completed) .OnEntry(() { order.CompletedAt DateTime.UtcNow; _messageBus.Publish(new WorkOrderCompletedEvent(orderId)); });需要注意的地方是OnEntry和OnExit里的代码如果抛异常状态机的状态其实已经改变了但外部业务动作会失败。这就要求回调里的逻辑要尽量做成可补偿的或者幂等的。我的经验是回调里只做轻量级副作用比如写日志、发通知重操作比如调用外部接口最好放到消息队列里异步处理避免回调拖垮状态迁移本身。3.2 用PermitIf和Guard实现条件迁移真实业务里状态迁移几乎不可能不带条件。比如Pending状态下只有审批人才能StartProcessingProcessing状态下只有填了完成说明才能CompleteProcessing。这个时候就要用到PermitIf也就是带守卫条件的Permitmachine.Configure(WorkOrderState.Pending) .PermitIf(WorkOrderTrigger.StartProcessing, WorkOrderState.Processing, () _currentUser.IsApprover) .PermitIf(WorkOrderTrigger.StartProcessing, WorkOrderState.Pending, () !_currentUser.IsApprover);这里有一个很多人容易忽略的设计技巧当Guard条件不满足时如果不希望抛异常而是希望状态保持不变且业务能继续走那就配置一条自环迁移也就是PermitIf的目标状态仍然是当前状态。这样触发StartProcessing时条件不满足就走自环状态不变OnEntry不会执行但代码不会中断。如果只想抛异常也可以直接配置PermitIf不带自环machine.Configure(WorkOrderState.Pending) .PermitIf(WorkOrderTrigger.StartProcessing, WorkOrderState.Processing, () _currentUser.IsApprover);这样非审批人触发时Stateless会因为找不到满足条件的迁移而抛InvalidOperationException。关于PermitIf还有一个实际排序的细节如果同一个状态下一个Trigger配置了多个PermitIfStateless会按配置的顺序依次检查Guard第一个返回true的规则生效。如果全部返回false则继续检查是否存在不带Guard的Permit如果存在就执行它。所以同一个Trigger下带条件的规则和兜底规则是可以共存的兜底的Permit要放在所有PermitIf之后配置。3.3 状态持久化与状态机恢复Stateless是内存状态机它本身不负责持久化。但业务系统必须把状态存到数据库里否则重启一次所有状态就丢了。这里有两种常用做法。第一种是我最常推荐的方式构造状态机时传入状态的读写委托让状态机和实体属性直接绑定var machine new StateMachineWorkOrderState, WorkOrderTrigger( () order.Status, status order.Status status);这样调用Fire之后order.Status已经被自动更新了不需要手动赋值。对业务代码来说非常干净几乎无感。第二种方式是显式赋值就像前面Controller里的写法machine.Fire(WorkOrderTrigger.StartProcessing); order.Status machine.State;两种方式本质上都一样核心原则只有一条数据库里保存的状态永远是状态机的当前状态。每次请求进来先从数据库读取状态用这个状态初始化状态机触发事件后再把新状态写回数据库。只要保证这个循环状态机即使每次new一个实例也没有任何问题。这里需要额外提醒一点Fire之后如果持久化失败比如SaveChanges抛异常内存中的状态机已经更新了但数据库没变。所以不要把状态机实例跨请求复用用完就丢弃让数据库状态作为唯一事实来源。4. 层级状态机和更复杂的业务场景4.1 用SubstateOf处理大类状态下的细分状态有一类业务场景表面上只有一个状态但内部其实有多个细分阶段。还是拿工单举例处理中这个状态可能是审批中也可能是执行中两者的操作完全不同但对用户来说都属于处理中。如果用扁平枚举来设计就会出现一长串状态ProcessingApproving、ProcessingExecuting、ProcessingVerifying迁移表瞬间膨胀。Stateless的层级状态Hierarchical State Machine就是专门解决这个问题的。用SubstateOf配置子状态与父状态的关系machine.Configure(WorkOrderState.ProcessingApproving) .SubstateOf(WorkOrderState.Processing) .Permit(WorkOrderTrigger.Approve, WorkOrderState.ProcessingExecuting); machine.Configure(WorkOrderState.ProcessingExecuting) .SubstateOf(WorkOrderState.Processing) .Permit(WorkOrderTrigger.Execute, WorkOrderState.Completed); machine.Configure(WorkOrderState.Processing) .Permit(WorkOrderTrigger.Cancel, WorkOrderState.Cancelled);这种设计有一个很实用的特性当状态机处于ProcessingApproving或ProcessingExecuting时IsInState(WorkOrderState.Processing)会返回true。也就是说你查询这个工单是否在处理中的时候不用关心它具体处于哪个子状态直接判断大类状态即可业务查询会简化很多。层级状态还有一个特点子状态会继承父状态的Permit规则。比如Processing状态配置了Cancel到Cancelled那么不管当前是ProcessingApproving还是ProcessingExecuting触发Cancel都是合法的会先离开子状态再执行父状态的Cancel迁移。4.2 状态机与数据库的并发一致性处理用状态机管理状态理论上可以挡住很多非法迁移但有一个问题状态机本身解决不了并发操作。两个请求同时进来都读到Pending状态A请求把工单变成ProcessingB请求也把工单变成Processing就会出现重复操作。虽然最终状态可能一样但中间的副作用比如发两次通知、记录两次日志是不可接受的。解决这个问题的核心是数据库层面的乐观锁。在工单表上加一个Version字段public class WorkOrder { public long Id { get; set; } public WorkOrderState Status { get; set; } public int Version { get; set; } }更新时带上版本号条件var order _db.WorkOrders.FirstOrDefault(x x.Id id); var machine _factory.Create(order.Status); machine.Fire(WorkOrderTrigger.StartProcessing); var affected _db.WorkOrders .Where(x x.Id id x.Version order.Version) .ExecuteUpdate(setters setters .SetProperty(x x.Status, machine.State) .SetProperty(x x.Version, order.Version 1)); if (affected 0) { // 说明并发冲突返回提示让用户重试 return Conflict(工单状态已被其他人修改请刷新后重试); }状态机保证的是从某个状态只能合法迁移到某些状态数据库乐观锁保证的是同一个状态的并发修改只有一个成功。两者合起来才是最完整的方案状态机管业务规则数据库管并发互斥。4.3 异步操作与FireAsync现代业务里状态迁移之前往往需要做异步校验迁移之后需要发异步消息。Stateless对异步场景的支持也很完整可以在配置里使用异步版本machine.Configure(WorkOrderState.Pending) .PermitAsync( WorkOrderTrigger.StartProcessing, WorkOrderState.Processing, () Task.FromResult(_currentUser.IsApprover)); machine.Configure(WorkOrderState.Processing) .OnEntryAsync(async () { await _notificationService.SendAsync(工单已开始处理); });对应触发时调用的是FireAsync而不是Fireawait machine.FireAsync(WorkOrderTrigger.StartProcessing);这里有一个比较容易踩的坑如果配置里同时存在同步的OnEntry和异步的OnEntryAsync用Fire触发时异步回调不会执行用FireAsync触发时同步回调会执行异步回调也会执行。所以要么统一用Async版本要么统一用同步版本混用的话会非常容易产生为什么有时候回调没执行的困惑。5. 常见问题与排查实录5.1 InvalidOperationException触发了未定义的迁移这是Stateless最经常遇到的异常。报错信息大概是Trigger X is not valid in state Y含义是当前状态下这个触发器没有配置合法的迁移。新手看到这个异常容易懵但其实这个异常恰恰是状态机在保护你的业务规则。排查思路很简单先看当前状态是什么再看触发的是什么事件最后回到代码里检查这个状态是否配置了该Trigger的Permit或PermitIf。如果确实应该允许这个迁移就补配置如果不应该那就是上游业务逻辑漏了前置判断需要先拦截再触发。更稳妥的做法是在触发前先做一次能力检查if (machine.CanFire(WorkOrderTrigger.StartProcessing)) { machine.Fire(WorkOrderTrigger.StartProcessing); } else { // 处理不允许触发的情况 }Stateless还提供了GetPermittedTriggers方法返回当前状态下所有可触发的触发器列表。这个在调试和生成前端按钮显隐逻辑时非常有用。5.2 OnEntry回调里再次触发导致递归爆栈我在项目里见过一次比较诡异的StackOverflow异常排查了很久最终定位到OnEntry回调里也调用了Fire方法而且触发的是同一个Trigger。比如配置了Pending的OnEntry里调用machine.Fire(StartProcessing)状态进入Processing后如果Processing也配置了类似的回调就会无限递归下去直接爆栈。我的经验是约定一个原则OnEntry和OnExit里只做副作用日志、通知、事件发布绝对不在回调里触发状态变更。真正需要触发的状态变更统一由外部业务方法发起不要让回调自己触发自己。这个约定能避免绝大多数递归问题也让状态迁移的入口变得非常清晰。5.3 状态机实例的线程安全与共享问题Stateless的实例本身不是线程安全的。如果在ASP.NET Core里把状态机注册成单例并且多个请求同时调用Fire状态值会被互相覆盖可能出现不可预期的结果。这一点我在项目里做过一次测试并发触发100次最终状态完全乱掉。正确的做法是状态机实例按需创建用完即弃。在WebAPI场景下这是最稳妥的方案。如果需要长期驻留的后台任务比如轮询扫描工单、定时推进状态那就必须给状态机的访问加锁保证同一时间只有一个线程在触发事件private readonly object _stateLock new object(); public void Execute() { lock (_stateLock) { machine.Fire(WorkOrderTrigger.StartProcessing); // 持久化 } }5.4 状态和触发器多了配置类怎么维护项目一旦做大状态可能几十个触发器也可能几十个如果全部塞进一个类里配置会变得非常长。我个人的组织方式是按业务模块拆分配置方法每个模块维护自己的状态迁移表再通过扩展方法或者分部类合并到一起。比如把工单的配置拆到单独的文件public static class WorkOrderStateMachineConfiguration { public static StateMachineWorkOrderState, WorkOrderTrigger ConfigureWorkOrder( this StateMachineWorkOrderState, WorkOrderTrigger machine) { machine.Configure(WorkOrderState.Pending) .Permit(WorkOrderTrigger.StartProcessing, WorkOrderState.Processing); machine.Configure(WorkOrderState.Processing) .Permit(WorkOrderTrigger.CompleteProcessing, WorkOrderState.Completed); return machine; } }工厂类里按顺序调用即可machine.ConfigureWorkOrder().ConfigureWorkOrderTimeout();还有一个很实用的经验状态机配置要支持FailFast。Stateless在配置阶段就会校验Permit的目标状态是否存在于枚举定义中如果配置了不存在的状态启动时就直接报错。这是很好的特性等于把状态的合法性校验提前到了启动阶段而不是等到用户操作时才炸。所以状态机配置尽量在程序启动时完成不要运行时动态配置。最后再分享一个我在项目中常用的技巧。状态机的枚举定义和配置类我会单独放到一个独立的类库项目里和数据库、业务服务分开。这样状态机配置可以单独测试、单独复用而且整个团队review状态迁移规则时只需要看这个类库不用在业务代码里翻来找去。状态机这种东西配置得越集中维护成本越低出问题的概率也越小。如果你正在.NET 8.0项目里整理状态流转建议先画一张迁移表再动手写代码整个过程会顺畅很多。本文还有配套的精品资源点击获取
返回列表