C#状态机实战:从if-else到Stateless的优雅重构
1. 为什么要用状态机从一团乱麻的if-else说起先从一个真实场景切入。我在做C#上位机的时候遇到过最痛苦的事就是设备的通讯流程越来越复杂。一开始只有一个连接、一个启动、一个停止逻辑很简单写几个if-else就搞定了。但随着需求膨胀什么手动模式、自动模式、暂停、复位、超时重连、心跳保活……这些状态互相之间有严格的先后顺序和切换条件你用一堆布尔变量if-else去硬扛扛到后面就是灾难。我来描述一下这个灾难长什么样。你有一个bool isConnected和一个bool isRunning当用户点了界面的某个按钮你要判断当前到底该不该执行某个操作。然后你发现状态不仅仅有两个你引入了枚举再配合switch。可是随着状态数量变多你每个方法里都有一大段switch逻辑分散在各处改一个地方容易漏掉另一个地方而且非法迁移你根本拦不住——用户完全有可能再“已断开”的状态下去执行“写入数据”的操作你在代码里只要忘了判断就会产生运行时异常甚至通讯错乱。这就是状态机存在的意义。状态机把“状态”和“状态的迁移规则”集中管理起来它告诉你你只能从A状态通过某个事件迁移到B状态其他路径全部禁止。我实际用下来C#里做状态机无论是最简单的switch方案、还是完整的状态模式、还是引入现成的状态机库本质都是在做同一件事把规则表化让代码自己约束自己。再说说适合谁。这篇文章适合正在用C#写上位机、写设备控制、写通信协议处理的开发者。如果你刚入门C#但已经碰到“状态多到理不清”的代码同样有参考价值。当然如果只是给一个简单的串口收发写逻辑杀鸡不用牛刀状态机会显得多余这一点后面我会具体说。2. 状态机核心概念拆解状态、事件、迁移、动作我在给团队讲状态机的时候最爱用红绿灯和自动售货机打比方。红绿灯就是最典型的状态机红灯、黄灯、绿灯是状态时间到了是事件红灯-绿灯是迁移切换的同时亮起某个灯这是动作。你用同样的思维去看C#上位机里的设备连接流程完全是一一对应的。先捋清几个概念。状态State程序在某一个时刻所处的稳定情形。在C#里最常见的就是用枚举定义比如Disconnected、Connecting、Connected、Reconnecting。这里有一个关键点状态应该尽量少每个状态必须能被清晰描述。我踩过的坑就是把“正在接收数据”和“正在解析数据”拆成了两个独立状态结果发现迁移关系一下子变得极其复杂最后意识到这俩其实是一回事合并掉以后清爽多了。事件Event/Trigger触发状态迁移的输入。事件可以是用户点击按钮、网络数据到达、定时器超时也可以是某个方法被调用。注意事件本身不关心当前状态是谁它只负责“投递”。至于事件的投递会不会引起迁移由状态机的规则表决定。迁移Transition从源状态A在某个事件E的作用下移动到目标状态B。迁移可以有条件比如“只有在tries 3时才允许从Connecting迁移到Reconnecting否则迁移到Aborted”。迁移是状态机的核心几乎所有的业务逻辑都写在迁移判定里。动作Action迁移发生时程序要执行的具体操作。动作可以发生在进入新状态时Entry Action可以发生在退出旧状态时Exit Action也可以在迁移过程中完成。C#里这些都是简单的函数调用或者事件回调。这四个概念里最容易搞混的是事件和动作。事件是“发生了一件事”动作是“你要做什么”。比如TCP连接超时超时是事件把ConnectionState从Connecting改成Disconnected并弹窗通知用户这是动作。初学者容易把动作写进事件里导致逻辑耦合、复用困难。有了这套概念再回头看业务代码你会发现绝大部分状态混乱的场景都能被归纳成一张迁移表。我习惯先用表格把迁移规则列出来再去写代码这张表既是设计文档也是后面实现和测试的依据。表头就是源状态、事件、触发条件、目标状态、动作列表。这五列列完代码怎么写基本已经呼之欲出了。3. 三种典型的C#状态机实现方案对比方案没有绝对的好坏关键看你项目的规模和团队维护的能力。我用三种方案分别做过实际项目各自都有深刻教训。3.1 最朴素的枚举switch方案这是我最开始用的方案。定义一个枚举public enum ConnectionState { Disconnected, Connecting, Connected, Reconnecting }然后定义一个核心迁移方法用switch加一个二维结构梳理迁移逻辑private ConnectionState currentState ConnectionState.Disconnected; private ConnectionState NextState(ConnectionState current, EventType evt) { switch (current) { case ConnectionState.Disconnected: if (evt EventType.ConnectRequest) return ConnectionState.Connecting; break; case ConnectionState.Connecting: if (evt EventType.Connected) return ConnectionState.Connected; if (evt EventType.Timeout) return ConnectionState.Reconnecting; break; // ... } throw new InvalidOperationException($非法迁移{current} {evt}); }这个方案的优点非常突出代码直观、零依赖、调试时打断点方便一整个文件就能看清所有规则。对于状态数量在5个以内、迁移逻辑不会频繁变更的小项目我推荐直接用这个别折腾别的。缺点也同样明显状态和事件一旦多起来switch会膨胀得很难维护状态本身的Entry/Exit动作没有统一管理你得自己在迁移方法里写操作很容易漏写。3.2 状态模式C#面向对象方案状态模式是GoF经典设计模式之一。它把每个状态封装成一个类每个类负责处理自己能响应的事件。核心思想是迁移逻辑不再集中在一大坨switch里而是分别存放在各个状态类内部。我给出一个最精简的结构。先定义状态基类和事件处理接口public abstract class ConnectionStateBase { protected ConnectionContext context; public abstract void Connect(); public abstract void Disconnect(); public abstract void OnConnected(); public abstract void OnTimeout(); }然后具体的DisconnectedState重写方法自己决定是否切换到下一个状态public class DisconnectedState : ConnectionStateBase { public override void Connect() { Console.WriteLine(正在发起连接...); context.ChangeState(new ConnectingState(context)); } public override void Disconnect() { // 已经是断开状态不执行任何操作 } public override void OnConnected() { // 非连接过程中收到已连接事件忽略 } public override void OnTimeout() { // 忽略 } }状态模式的优点每个状态的代码内聚、可读性高新增一个状态只需要增加一个类修改状态内部的逻辑也不会影响到其他状态。它的代价是类的数量变得很多如果项目里状态和事件都特别多十几个类之间互相引来引去新人接手往往容易懵。状态模式还容易出现跨状态逻辑复用困难的问题我当时处理这个问题的办法是把公共逻辑抽到一个service层去状态类只做路由。3.3 引入现成状态机库Stateless的实战体验第三类方案就是引入现成库我用的是StatelessNuGet直接搜索Stateless就能装。这个库给我的感觉是它把状态机最烦人的样板代码全部吃掉了你只需要描述规则。Stateless用起来非常丝滑。核心就是配置状态机和迁移规则var machine new StateMachineConnectionState, EventType(ConnectionState.Disconnected); machine.Configure(ConnectionState.Disconnected) .Permit(EventType.ConnectRequest, ConnectionState.Connecting); machine.Configure(ConnectionState.Connecting) .PermitIf(EventType.Connected, ConnectionState.Connected, () _tries 3) .PermitIf(EventType.Timeout, ConnectionState.Reconnecting, () _tries 3) .Permit(EventType.Timeout, ConnectionState.Aborted) .OnEntry(() { /* 进入连接中的动作 */ }); machine.Configure(ConnectionState.Connected) .OnEntry(() { _heartbeatTimer.Start(); }) .OnExit(() { _heartbeatTimer.Stop(); }); machine.Fire(EventType.ConnectRequest);我印象最深的是它的PermitIf条件迁移和Ignore方法。Ignore用来显式声明某个事件在当前状态下“什么都不做”而不是因为没定义该迁移直接抛异常。这在生产环境里非常重要因为实际运行中有太多你预料不到的重复事件比如连续收到两次OnConnected回调。Stateless还支持OnEntry和OnExit正好对应前面说的进入动作和退出动作。比如我在连接到Connected状态时启动心跳定时器在退出该状态时停止定时器这个逻辑就非常适合放在状态内部而不是散落在各个调用方。库方案唯一的风险是“框架陌生感”。团队里的老手不一定熟悉这个库的API。我的建议是如果团队都愿意学项目够复杂用Stateless是性价比最高的方案如果团队里就你一个人能写核心模块那方案1或方案2可能更稳妥。下面我画一张表把三种方案的关键区别列出来方便你选型时权衡。方案优点缺点适用场景枚举switch零依赖、直观、易调试状态多时switch膨胀、动作管理混乱状态少于5个规则简单固定状态模式内聚、易扩展、职责清晰类数量多、跨状态逻辑复用困难状态较多且常有新状态增加Stateless库规则表清晰、动作管理完善、API丰富引入第三方依赖、团队需学习状态/事件交织复杂的中大型项目4. 实战拆解C#上位机通讯连接管理状态机讲了这么多概念和方案接下来我直接拿一个具体项目来说C#上位机通过TCP/S7通讯去连接PLC设备完整的状态机是怎么落地的。这一类场景在热词里出现的频率非常高连接西门子OPC、连接DCS、Modbus TCP客户端都是同一类而且通讯连接本身的状态切换特别适合用状态机来收敛逻辑。4.1 需求定义列出状态、事件和迁移表我先描述业务场景。设备通讯模块要做的事包括用户点击“连接”、TCP建立连接、PLC握手比如S7的Job/ACK、通讯空闲、通讯断开、用户主动断开、网络异常、超时重连等。我把它抽象成如下状态集Disconnected初始状态未连接Connecting正在发起TCP连接握手Handshaking已经建立TCP连接正在做PLC握手确认Ready握手成功可以正常收发数据Reconnecting连接意外断开或握手超时准备重连Stopped手动停止设备不再允许重连事件集如下ConnectRequest用户点击连接按钮TcpConnectedTCP连接建立的回调HandshakeOkPLC握手返回正确Timeout操作超时ConnectionLost底层Socket断开/心跳超时StopRequest用户点击断开/停止按钮Retry重连定时器触发迁移规则我直接在代码里用Stateless配置关键部分如下_machine new StateMachineConnState, ConnEvent(ConnState.Disconnected); _machine.Configure(ConnState.Disconnected) .Permit(ConnEvent.ConnectRequest, ConnState.Connecting) .Permit(ConnEvent.StopRequest, ConnState.Stopped); _machine.Configure(ConnState.Connecting) .Permit(ConnEvent.TcpConnected, ConnState.Handshaking) .Permit(ConnEvent.Timeout, ConnState.Reconnecting) .Permit(ConnEvent.StopRequest, ConnState.Stopped) .OnEntry(() _logger.Info(开始连接...)) .OnExit(() _logger.Info(退出连接中状态)); _machine.Configure(ConnState.Handshaking) .Permit(ConnEvent.HandshakeOk, ConnState.Ready) .Permit(ConnEvent.Timeout, ConnState.Reconnecting) .Permit(ConnEvent.ConnectionLost, ConnState.Reconnecting) .Permit(ConnEvent.StopRequest, ConnState.Stopped); _machine.Configure(ConnState.Ready) .Permit(ConnEvent.ConnectionLost, ConnState.Reconnecting) .Permit(ConnEvent.StopRequest, ConnState.Stopped) .OnEntry(() { _heartbeatTimer.Start(); }) .OnExit(() { _heartbeatTimer.Stop(); }); _machine.Configure(ConnState.Reconnecting) .Permit(ConnEvent.Retry, ConnState.Connecting) .Permit(ConnEvent.StopRequest, ConnState.Stopped); _machine.Configure(ConnState.Stopped) .Ignore(ConnEvent.ConnectRequest) .Ignore(ConnEvent.Retry);写到这里你可能已经发现了配置状态机的过程其实就是把业务规则变成一张表格、然后逐行翻译成API调用的过程。这里有两个细节我特别想强调第一Stopped状态里我没有用Permit到任何状态而是全部Ignore。因为停止后如果再连接用户应该重新走一遍“复位”流程而不是直接回到Connecting。这个业务规则如果不靠状态机明确挡住很容易在界面上误操作。第二Reconnecting到Connecting我没有使用定时器自循环而是通过Retry事件触发这样底层重连策略延迟时间、次数上限可以完全放在外部控制状态机本身只负责状态流转不负责sleep。4.2 动作执行与外部事件回传状态机只是“大脑”它不会真的去建立TCP连接或者发送S7报文真正执行这些工作的是外部的Service。我的做法是状态机本身不持有任何IO对象只维护状态和触发迁移。TCP连接建立成功后底层会回调一个事件我在这个事件里调用_machine.Fire(ConnEvent.TcpConnected)状态机才从Connecting迁到Handshaking。这种“外部事件驱动状态机”的方式是生产环境里最干净的做法。我在实际操作中的体会是把IO回调直接写在状态机的动作里是最容易翻车的因为动作里一旦出现耗时操作比如同步Read整个线程会被卡住状态机就没有机会响应超时事件了。我的建议是动作里只做两件事——记录日志以及把任务丢给后台线程池。比如OnEntry(Connecting)时启动一个Task.Run去执行TcpConnectAsync连接完成后的结果通过事件回调触发下一次Fire。这样状态机的状态切换永远是瞬时完成的不会因为IO阻塞导致状态“卡死”。4.3 超时机制和重连条件控制状态机里最容易被忽略的是超时控制。以Handshaking为例TCP已经连上了但握手报文迟迟没有收到ACK这时候如果不管它这个连接就会永远挂在Handshaking。我的方案是在进入Handshaking时启动一个超时定时器比如5秒到期后投递Timeout事件状态机从Handshaking迁移到Reconnecting然后在Reconnecting的OnEntry里决定重连次数和延时。这个方案的妙处在于超时事件本身就是状态机的一个输入源。它和用户点击、网络回调一样都被统一抽象成了事件。这样一来编写上层业务代码时根本不需要关心“当前是什么状态”只需要投递事件就行了。我用C#的System.Timers.Timer配合ConcurrentQueue来投递超时事件但要注意定时器回调里不要直接操作UI控件需要调度到UI线程。5. 状态机和C#异步编程的搭配Task和async/await需要注意的事C#的异步编程模型和状态机搭配得好会非常舒服搭配不好就是死锁和竞态的噩梦。在生产环境里我见过最多的bug就是状态机迁移和异步回调互相打架——底层连接已经断开了异步任务还在跑跑完以后又重新触发了一次ConnectionLost导致状态连续反复切换。5.1 异步投递事件时的线程安全先说线程安全。Stateless内部不是线程安全的默认情况下它要求事件的触发是串行的。如果TCP回调线程、UI线程、定时器线程同时去调用machine.Fire()就会产生预期之外的迁移顺序。我之前的做法是引入一个SemaphoreSlim做一个轻量级锁或者更简单的用Channel生产消费者模式把所有事件投递到同一个后台线程去处理private readonly ChannelConnEvent _eventChannel Channel.CreateUnboundedConnEvent(); private readonly CancellationTokenSource _cts new CancellationTokenSource(); private async Task ProcessEventsAsync() { await foreach (var evt in _eventChannel.Reader.ReadAllAsync(_cts.Token)) { try { _machine.Fire(evt); } catch (InvalidOperationException ex) { _logger.Error($非法迁移被拦截: {ex.Message}); } } } public void PostEvent(ConnEvent evt) _eventChannel.Writer.TryWrite(evt);所有外部回调网络线程、定时器、UI按钮都只调用PostEvent实际的状态机迁移全部在单一的消费者线程内完成天然避开了线程安全问题。这个模式我觉得非常值得推荐它让事件源的并发完全解耦你不需要到处加锁只需要保证消费者线程里的事件顺序符合业务预期。5.2 异步动作和状态迁移的时序还有一个坑是异步动作和状态迁移的先后顺序。假设你在OnExit(Ready)停止心跳定时器但同时心跳回调已经被安排在某个线程上运行那么这个回调在停止之后仍然可能触发一次ConnectionLost事件。由于此时状态已经是ReconnectingConnectionLost在Reconnecting状态里没有定义Permit规则就会抛InvalidOperationException。我处理这个坑的策略是在PostEvent里面对所有异常做一个统一的catch并且把非法迁移事件记录下来。同时状态机的Ignore规则要尽量完整把所有“理论上不会发生但现实中无法保证不会发生”的事件都显式写出来挂上Ignore。避免让未知事件直接抛异常炸掉主流程这是状态机上线前最应该做的一项扫尾工作。async/await本身不会给状态机带来困难真正困难的是你能否做到“await之前和await之后的事件状态机的当前状态还是你预期的那个”。基于这一点我的实践准则是绝不把await穿插在状态机的OnEntry/OnExit动作中间。所有动作都设计成同步启动需要耗时IO的部分全部丢到独立的service方法里完成后回调PostEvent。这套规则我坚持了三年通讯模块的稳定性有了一个质的提升。6. 踩坑实录状态机实践中的典型问题排查踩坑是成长最快的方式。下面这几个问题是我和团队在从“状态模式”迁移到“Stateless”以及日常维护过程中真实遇到并逐一解决的每个都值得单独拎出来讲。6.1 非法迁移拦截Fire抛出InvalidOperationException这是最常见的问题。在状态机上没有定义迁移规则的事件一旦被触发会抛出InvalidOperationException。对于初次使用者来说这个异常很容易让人摸不着头脑因为报错信息里可能就显示一句“无法从Ready状态触发HandshakeOk事件”。我的排查思路三步走。第一步看异常信息里的当前状态和触发事件回到设计阶段的迁移表确定这个组合到底该不该允许第二步如果业务上该允许补上Permit/PermitIf规则如果业务上不该允许加一个Ignore显式声明“就忽略它”第三步如果出现频率非常高通常说明底层的某些逻辑产生了重复回调比如重试机制触发了一次Retry但连接线程还没来得及置为中间状态——这种情况要去查上游调用而不是在状态机上打补丁。6.2 状态机“卡死”在某一个状态不动了“卡死”往往不是真的卡死而是有迁移事件根本没投递过来。我遇到过最典型的情况是底层Socket断开事件在UI线程上被吞掉了因为异常和崩溃处理不当导致回调没有执行或者定时器的回调被反复取消和创建结果最后一次创建根本没有设置AutoReset true只触发了一次超时事件就永远等不到了。排查这类问题时我推荐在状态机的每个Fire方法入口处打一条结构化日志里面包含TimeStamp、CurrentState、Event、TriggerThreadId。日志出来了以后卡在哪一目了然。我在自己的项目里封装了一个LogStateTransition的AOP方法后来几乎所有的现场问题都是靠这份日志定位的。6.3 UI卡顿和界面状态不同步做上位机时状态机和WPF/WinForms界面的同步也是一个重要课题。很多初学C#的同学会把状态机的判断直接写在按钮点击事件里或者在状态机动作里直接访问界面的控件这样做会导致界面卡顿。我推荐的做法是状态机只负责维护领域状态UI的状态通过事件发布出来。简单来说就是状态机状态变化时触发一个StateMachineChanged事件界面订阅这个事件在Dispatcher的线程上更新按钮的Enabled属性、调整图片和状态栏文字。实现上可以用INotifyPropertyChanged也可以用IProgressT封装一个UiContext帮助类。核心原则仍然是UI线程绝不直接调用状态机迁移而是通过PostEvent投递UI只负责订阅和渲染。6.4 状态机测试的小技巧状态机逻辑非常适合做单元测试因为它是纯逻辑没有外部依赖。我习惯用xUnit配合迁移表来写测试用例基本思路是枚举所有“源状态事件”的组合断言迁移目标是否符合预期。对于非法迁移的测试则用Assert.ThrowsInvalidOperationException或者验证未抛出异常。Stateless这个库提供了一个IsPermitted方法可以直接在测试里断言某个事件是否被允许[Fact] public void Ready_ConnectionLost_ShouldGoToReconnecting() { var machine BuildMachine(); machine.Fire(ConnEvent.ConnectRequest); machine.Fire(ConnEvent.TcpConnected); machine.Fire(ConnEvent.HandshakeOk); Assert.Equal(ConnState.Ready, machine.State); Assert.True(machine.CanFire(ConnEvent.ConnectionLost)); machine.Fire(ConnEvent.ConnectionLost); Assert.Equal(ConnState.Reconnecting, machine.State); }一个完整的迁移表测试套件大概是一百多行测试代码维护起来非常轻松。有了这套测试后续任何人改迁移规则时跑一遍测试就能立刻知道哪些场景被破坏了这对多人协作项目的价值是巨大的。7. 末尾再聊几句我个人在实际操作中的体会是状态机不是银弹但它对“状态多、规则硬”的场景是真的好用。我最初也是从几百行if-else里爬出来的最开始改用switch时只是小步快跑后来在很多项目里反复迭代才沉淀出了前面这些比较适合自己的玩法。如果你正准备把一个通讯模块的乱七八糟的状态逻辑理清楚我建议别急着往上堆库——先把迁移表写出来哪怕是在纸上花一两个小时梳理后面写代码的速度都会快很多。画得越清楚代码就越不容易翻车。再一个想提醒的是状态机的迁移日志一定要留好我见过太多现场问题了一份完整的日志能让排查时间从几天缩减到半小时。接下来的扩展方向其实也很多比如把状态机的迁移过程和业务流引擎结合、给状态机可视化做一个实时监控界面、用状态机去拆解复杂的机器人工位流程都是能立刻上手的点子。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →