配置简单的状态机





5.00/5 (19投票s)
尝试阐明状态机的工作原理及其用途
引言
复杂的系统通常可以通过将其分解为一系列离散的阶段或状态来简化,当系统进展时,状态之间会发生转换。状态机的功能是响应某种输入触发器来管理这些转换。理想情况下,状态机应该很少了解状态及其功能,它只保持对当前状态的引用,并将所有输入导向该状态。当转换为另一个状态时,该状态成为当前状态,输入被导向它。输入触发器决定了下一个选定的状态,因此,如果模拟一个零件的生产,质量控制状态可能会根据收到的触发器转换为发货状态或回收状态。为了说明这一点,可以看一下一种最纯粹的状态机形式的UML状态机图。
祖先状态机
这是现代版本所演变而来的基本状态机形式。只有两种触发器:二进制的1或0。读取图表的方法是从起始状态开始,然后从一个状态转换到另一个状态。状态A是起始状态,即初始当前状态。它通过指向它的、不来自另一个状态的箭头来标记。如果此状态接收到0,则没有转换,就像箭头指向其原点一样。1会导致机器将状态B设为当前状态。现在将输入应用于状态B,其响应是不同的。在这里,0触发器会导致转换回状态A,而1会导致转换到状态C。从状态C开始,1转到状态D,0转到状态A。状态D是结束状态,它没有转换到其他状态。结束状态由两个同心圆标记,可以有多个结束状态,但只有一个起始状态。那么机器在做什么?它正在检测二进制输入字符串中的模式111。如果在数据流结束时,当前状态是D,则输入被接受;如果不是,则被拒绝。请注意,系统中没有记忆,状态之间互不了解,所有转换都由外部输入触发。状态没有内在功能;它们仅作为触发器的目标标识符。
Stateless简介
以下示例使用了流行的状态机Stateless,该库可作为NuGet包下载。机器本身是通用的,您需要提供状态类型和触发器类型,以及初始化时的起始状态。简单的应用程序通常定义一个enum
来表示状态,再定义一个enum
来表示触发器。然后,这些enum
用作标签,以关联到内部管理的一些伪状态和触发器。这种安排消除了定义所有状态的通用接口的需要——在最好的情况下,这是一个困难的任务,因为转换到新状态的重点在于实现不同的功能。
使用简单状态机验证电子邮件地址
此示例显示了如何扩展基本状态机以使其能够验证电子邮件地址。上下文类是EmailValidator
,它封装了状态机,并接收来自外部源的输入字符串。字符串被迭代,其字符被用作触发器,当迭代结束时,如果机器最终处于可接受的状态,则验证字符串。触发器是chars
,状态是enum
。
public enum EmailState
{
Start,
Local,
Domain,
Accepted,
Rejected
}
public class EmailValidator : IValidator
{
private readonly StateMachine<EmailState, char> machine;
public EmailValidator()
{
machine = new StateMachine<EmailState, char>(EmailState.Start);
// ignore unconfigured Trigger exception
machine.OnUnhandledTrigger((state, trigger) => { });
ConfigureMachine();
}
为了简化问题,所有非法字符都会在触发机器之前被过滤掉,这样机器就可以专注于确定电子邮件地址的格式是否正确。
public bool Validate(string dataString)
{
char[] acceptable = new char[] { '@', '.', '-' };
//rinse out all illegal chars
if (dataString.Any(c => !char.IsLetterOrDigit(c) && !acceptable.Contains(c))
{
return false;
}
foreach (var c in dataString)
{
//use the trigger 'x' for all alphanumeric chars
char trigger = char.IsLetterOrDigit(c) ? 'x' : c;
//The Fire method initiates the state transition.
machine.Fire(trigger);
}
var isValid = machine.IsInState(EmailState.Accepted);
//reset to Start
currentState = EmailState.Start;
return isValid;
}
上面的状态机图显示了机器的“接线”方式。第一个字符被输入到Start状态。字符@、点和连字符被拒绝;所有其他字符都被接受,并导致转换到处理电子邮件地址本地部分的Local状态。Local状态接受除@以外的所有字符,@会触发转换到Domain状态。Domain状态中的第一个字符必须是字母数字。Accepted状态接受除连字符和@以外的所有字符。
配置状态机
状态的配置是通过简单地允许从每个状态允许的转换来实现的,使用Permit
方法,该方法接受触发器和状态作为参数。
private void ConfigureMachine()
{
machine.Configure(EmailState.Start)
.Permit('@', EmailState.Rejected)
.Permit('.', EmailState.Rejected)
.Permit('x', EmailState.Local);
machine.Configure(EmailState.Local)
.Permit('@', EmailState.Domain);
.......
}
状态机图
状态机图是一个很好的调试辅助工具,因为它们使您能够可视化机器的配置,以至于一个对配置一无所知的人也能确切地看到机器的设置方式。Stateless有一个方法UmlDotGraph.Format(machine.GetInfo())
,它会输出一个Dot格式的字符串,当将其粘贴到Webgraphviz的文本框中时,就可以生成一个图。该网站提供了一个免费应用程序,您可以下载它来完成同样的事情。
使用状态处理数据
在前面的示例中,状态是沉默的,它们实际上没有执行任何工作。但是,要将状态用于某种生产线,每个状态都需要能够在Context
类的指导下执行工作。Stateless中实现这一点的方法是使用两个Action
委托:OnEntry
和OnExit
Action委托。当进入一个状态时调用OnEntry
,当离开一个状态时调用OnExit
方法。可能会认为Action
委托没有参数且返回void
,因此它们可能不太有用。但事实并非如此,因为这些委托是在Context
中实例化的,所以它们能够捕获Context
的所有public
和private
变量。
private readonly IValidator validator = new EmailValidator();
.....
machine.Configure(State.Validating)
.OnEntry(() =>
{
//prompt for email address
var address= Console.ReadLine();
Trigger trigger = validator.Validate(address) ? Trigger.Accept : Trigger.Fail;
machine.Fire(trigger);
})
....
一个重要的点是,Validator
对状态或与状态关联的机器的OnEntry
方法一无所知。为了保持良好的关注点分离,所有转换都应由Context
处理。不允许像Validator
这样的任何辅助类变得触发器灵敏并自行启动,这并不是一个好主意。
验证示例
此示例模拟了某种验证过程,申请人有三次输入有效电子邮件地址的机会。每次尝试失败后,用户可以选择取消或重试。三次失败的尝试将导致应用程序被拒绝。Validating
状态配置为使用Fail
触发器作为守卫触发器。其转换取决于IsRejected
方法返回的bool
。如果IsRejected
返回true
,则Fail
触发器会导致转换到Rejected
结束状态。false
值会导致转换到Failed
状态,在那里可以选择取消或重试。
machine.Configure(State.Validating)
.OnEntry(()
{
//prompt for an input
Tweet(Constants.StartValidating);
var address= Console.ReadLine();
Trigger trigger = validator.Validate(address) ? Trigger.Accept : Trigger.Fail;
machine.Fire(trigger);
})
.Permit(Trigger.Accept, State.Accepted)
.PermitIf(Trigger.Fail, State.Failed, () => !IsRejected)
.PermitIf(Trigger.Fail, State.Rejected, () => IsRejected);
守卫触发器使用PermitIf
方法进行配置。我个人倾向于不使用它们,并将所有逻辑保留在OnEntry Action
内,而不是让它逃逸到某种没有预定目标的“导弹式”触发器中。
管理当前状态
在验证示例中,当前状态需要由StateMachine
类外部管理,以便在每次验证尝试之前将其重置到Start
状态。这是设置它的方法,只需在构造函数中提供getter和setter作为参数即可。
private EmailState currentState= EmailState.Start;
private readonly StateMachine<EmailState, char> machine;
public EmailValidator()
{
//provide a getter and setter so the currentState can be reset to the Start State
//after each attempt at validation
machine = new StateMachine<EmailState, char>(() => currentState, s => currentState = s);
// ignore unconfigured Trigger exception
machine.OnUnhandledTrigger((state, trigger) => { });
ConfigureMachine();
}
子状态
可以将一个状态指定为另一个状态的子状态。这样做的好处是,当当前状态从超状态转换到子状态时,超状态的OnExit
方法不会被调用。在此示例中,SeatBelt
是Motoring
的子状态,Engine
是SeatBelt
的子状态,Brake
是Engine
的子状态。子状态状态是继承的,因此SeatBelt
、Engine
和Brake
都是Motoring
的子状态。当触发Park
时,OnExit
方法将按顺序调用,从Brake
状态向上冒泡到Motoring
状态。通常,只允许一个外部转换进入Motoring
状态及其子状态,但可以有多个退出触发器。
private void ConfigureMachine()
{
machine.Configure(State.Start)
.Permit(Trigger.Motor, State.Motoring)
.OnEntry(() => Console.WriteLine("In State Start"))
.OnExit(() => Console.WriteLine("Leaving Start"));
machine.Configure(State.Motoring)
.Permit(Trigger.Fasten, State.Seatbelt)
.OnEntry(() => Console.WriteLine("Started Motoring"))
.OnExit(() => Console.WriteLine("Finished Motoring"));
machine.Configure(State.Seatbelt)
.SubstateOf(State.Motoring)
.Permit(Trigger.Engage, State.Engine)
.OnEntry(() => Console.WriteLine("Seatbelt Fastened"))
.OnExit(() => Console.WriteLine("Seatbelt Unfastened"));
machine.Configure(State.Engine)
.SubstateOf(State.Seatbelt)
.Permit(Trigger.Release, State.Brake)
.OnEntry(() => Console.WriteLine("Engine Started"))
.OnExit(() => Console.WriteLine("Engine Off"));
machine.Configure(State.Brake)
.SubstateOf(State.Engine)
.Permit(Trigger.Park, State.Parked)
.OnEntry(() => Console.WriteLine("Brake Released"))
.OnExit(() => Console.WriteLine("Brake Applied"));
machine.Configure(State.Parked)
.OnEntry(() => Console.WriteLine("Parked"));
}
异步 OnExitAsync 和 OnEntryAsync 操作
上一个示例可以通过保持引擎运行并仅在刹车施加后停止它来改进。要做到这一点,Engine
状态需要保持活动状态,即使它不再是当前状态。因此,需要异步运行Engine的OnEntry Action
,并在状态的OnExit
方法被调用时结束它。实现这一点的方法是使用OnExitAsync Func
和FireAsync
方法。
CancellationTokenSource cts = new CancellationTokenSource();
.....
machine.Configure(State.Engine)
.SubstateOf(State.Seatbelt)
.Permit(Trigger.Release, State.Brake)
.OnEntry( () =>
{
//start the task but don't await it here
engineTask = Task.Run(()=>ChugChug(cts.Token));
Log($"Engine Started {engineNoise}");
})
.OnExitAsync(async() =>
{
cts.Cancel();
await engineTask;
Log("Engine Stopped");
});
ChugChug
方法只是一个模拟引擎运行的无意义的方法。
private void ChugChug(CancellationToken token)
{
while (true)
{
//simulate long-running method
Thread.Sleep(5);
//check for cancellation
if (token.IsCancellationRequested) break;
Console.ForegroundColor = ConsoleColor.White;
Console.Write(engineNoise);
}
}
当从Brake
状态转换到Parked
状态时,首先调用当前状态的OnExitAsync Func
,然后调用通过子状态冒泡到Motoring
状态。因此,除了Engine
之外,所有Motoring
状态的OnExitAsync Func
都需要以类似的方式进行配置。
machine.Configure(State.Brake)
.SubstateOf(State.Engine)
.Permit(Trigger.Park, State.Parked)
.OnEntry(() => Log("Brake Released"))
.OnExitAsync(() =>
{
Log("BreakApplied");
//the method expects a Task to be returned
return Task.CompletedTask;
});
StartupAsync
方法将当前状态从Start
状态转换到Parked
状态。
public async Task StartupAsync()
{
machine.Fire(Trigger.Motor);
machine.Fire(Trigger.Fasten);
machine.Fire(Trigger.Engage);
machine.Fire(Trigger.Release);
string msg = machine.IsInState(State.Motoring) ? "is in " : "is not in ";
Log($"The current state is {machine.State}
it {msg}state Motoring",ConsoleColor.Yellow);
await Task.Delay(50);
Log("\r\nFiring Trigger Park",ConsoleColor.Yellow);
//FireAsync calls the OnExitAsync action of the current state
//The call bubbles up through the substates to State.Motoring
await machine.FireAsync(Trigger.Park);
}
StartupAsync
的输出主要是无聊的,但它给出了一个想法,通过简单地触发一个触发器可以释放多少功能。
结论
状态机对于将代码分解为一系列离散的部分并将代码从一个部分推进到另一个部分很有用。诚然,配置机器需要一些仔细的考虑,但一旦设置好,就不太可能被任何其他用户编写的代码损坏。状态机能够生成状态及其转换触发器的可视化表示是一个宝贵的资产,因为它提供了系统设置方式的“接线图”,并极大地简化了其维护和扩展。状态机并非万能药,可以解决所有多路径场景,但它无疑将IfThenElse模式打得落花流水。
历史
- 2020年5月6日:初始版本