C#/WPF 由 DGML 驱动的状态机
使用可视化工具设计 DGML 状态机,然后依赖 C# 反射在应用程序中实现状态机的边界和触发器。
引言
Visual Studio 提供了一个设施,可以图形化地编辑有向图标记语言 (DGML) 来创建有向流程图。 在为我需要实现的状态机创建了这样的图之后,直接使用该图作为程序集资源来运行状态机似乎是很有利的。 我很欣赏下面的类给我的项目带来的灵活性。 如果有更多时间,可以编写一个编译器工具来比较图和代码,并提供健全性错误或警告。
一个状态机示例
这是 DGML 状态机设计的可视化示例,随后是 .dgml 文件的简化版本。
<?xml version="1.0" encoding="utf-8"?>
<DirectedGraph xmlns="http://schemas.microsoft.com/vs/2009/dgml">
<Nodes>
<Node Id="Aborting" />
<Node Id="AutoUpdating" />
<Node Id="End" NodeRadius="50" />
<Node Id="EnteringPasscode" />
<Node Id="IssuingTravelToken" />
<Node Id="Restarting" />
<Node Id="RetrievingAuthorities" />
<Node Id="Start" NodeRadius="50" />
<Node Id="StartingSiteManager" />
<Node Id="ValidatingAuthorities" />
<Node Id="ValidatingSigOnFile" />
<Node Id="ValidatingTravelToken" />
<Node Id="VerifyingPasscode" />
</Nodes>
<Links>
<Link Source="Aborting" Target="End" Label="Exit" />
<Link Source="AutoUpdating" Target="Aborting" Label="SigOrUpdateFailed" />
<Link Source="AutoUpdating" Target="IssuingTravelToken" Label="SWUpdatesNotRequired" />
<Link Source="AutoUpdating" Target="Restarting" Label="SWUpdatesApplied" />
<Link Source="EnteringPasscode" Target="Aborting" Label="PasscodeEntryCanceled" />
<Link Source="EnteringPasscode" Target="VerifyingPasscode" Label="PasscodeSubmitted" />
<Link Source="IssuingTravelToken" Target="StartingSiteManager" Label="TravelTokenIssued" />
<Link Source="Restarting" Target="End" Label="Restart" />
<Link Source="RetrievingAuthorities" Target="ValidatingAuthorities" Label="AuthoritiesRetrieved" />
<Link Source="RetrievingAuthorities" Target="ValidatingTravelToken" Label="AuthoritiesRetrievalFailed" />
<Link Source="Start" Target="RetrievingAuthorities" Label="Start" />
<Link Source="StartingSiteManager" Target="End" Label="Continue" />
<Link Source="ValidatingAuthorities" Target="Aborting" Index="2147483647" Label="AuthoritiesValidationFailed" />
<Link Source="ValidatingAuthorities" Target="ValidatingSigOnFile" Label="AuthoritiesValidated" />
<Link Source="ValidatingSigOnFile" Target="AutoUpdating" Label="SigOnFileValidationFailed" />
<Link Source="ValidatingSigOnFile" Target="EnteringPasscode" Label="SigOnFileValidated" />
<Link Source="ValidatingTravelToken" Target="Aborting" Label="TravelTokenExpired" />
<Link Source="ValidatingTravelToken" Target="StartingSiteManager" Label="TravelTokenApproved" />
<Link Source="VerifyingPasscode" Target="AutoUpdating" Label="PasscodeVerified" />
<Link Source="VerifyingPasscode" Target="EnteringPasscode" Label="PasscodeNotVerified" />
</Links>
<Properties>
<Property Id="GraphDirection" DataType="Microsoft.VisualStudio.Diagrams.Layout.LayoutOrientation" />
<Property Id="Label" Label="Label" Description="Displayable label of an Annotatable object" DataType="System.String" />
<Property Id="Layout" DataType="System.String" />
<Property Id="NodeRadius" Label="Node Radius" Description="Node Radius" DataType="System.Double" />
</Properties>
</DirectedGraph>
为了在视觉上指示开始和结束状态,我手动添加了属性 NodeRadious="50"
。 这在图形上使这些状态的角变圆。
Link
节点由图中的状态之间的线表示,并用作状态机的触发器。
在您的应用程序中,派生一个类实例自抽象的 StateMachine
类,并开始编码转换。
public partial class EntryStateMachine : StateMachine
{
private Entry _EntryWindow;
public EntryStateMachine(Entry _ew) : base(_ew.GetType().Name)
{
_EntryWindow = _ew;
}
//...................................................................
protected void OnStartExit()
{
_EntryWindow.Dispatcher.BeginInvoke((Action)(() =>
{
_EntryWindow._Progress.Maximum = 2 + this.Count() / 2;
_EntryWindow._Progress.Value = 1;
_EntryWindow._Instruction.Text = "Please wait,... AutoUpdate cleanup in progress.";
}));
}
}
在派生类中,为 DGML 的任何 Exit、Trigger 或 Entry 边界创建受保护的 void 方法。 注意命名约定 "On"+state.name+"Exit"|"Entry" 以及 "On"+trigger.name+"Trigger"(稍后会详细介绍)。 当您想开始执行状态机时,例如在 MainWindow 加载后,调用构造函数并提交初始的 "Start" 触发器。
void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
_EntrySM = new EntryStateMachine(this);
_EntrySM.ProcessTrigger("Start");
}
请注意,状态机运行在与 UI 分离的线程上,因此所有 UI 交互必须通过窗口元素的 BeginInvoke()
方法进行括起来。 C# 的一个优点是 BeginInvoke()
的代码可以内联编写。 在上面,我正在设置进度条和指令文本的初始状态。
还要注意,必须通过显式调用 ProcessTrigger()
来启动状态机。 也可能存在需要暂停状态机的情况,例如等待用户输入,然后需要通过调用 ProcessTrigger()
来使其恢复处理。
StateMachine 基类
让我们回顾一下状态机类的要点。 您可以在 这里 下载该类的完整源代码。
设置一个异常,用于处理可能需要抛出的糟糕状态转换。
[Serializable()]
public class InvalidStateTransitionException : System.Exception
{
public InvalidStateTransitionException() : base() { }
public InvalidStateTransitionException(string message) : base(message) { }
public InvalidStateTransitionException(string message, System.Exception inner) : base(message, inner) { }
// A constructor is needed for serialization when an
// exception propagates from a remoting server to the client.
protected InvalidStateTransitionException(System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context) { }
}
设置关联类来跟踪触发器、源和目标。
public struct StateAssociation
{
public string trigger;
public string source;
public string target;
public StateAssociation(string _trigger, string _source, string _target)
{
trigger = _trigger;
source = _source;
target = _target;
}
}
设置线程操作类,用于从顺序队列中执行。 每个实例保存一个状态转换的可执行反射。
public class StateAction
{
public MethodInfo mi;
public Object[] parms;
public StateAction(MethodInfo _mi, Object[] _parms)
{
mi = _mi;
parms = _parms;
}
}
声明抽象的偏类和状态机类的成员变量和构造函数。
public abstract partial class StateMachine
{
private List<StateAssociation> _Associations;
private ConcurrentQueue<string> _Triggers;
private BlockingCollection<StateAction> _Actions;
private ManualResetEvent _ActionRequired;
public volatile string _CurrentState;
public volatile string _LastTrigger;
public StateMachine(string _StateMachineResource)
{
_Associations = new List<StateAssociation>();
_Triggers = new ConcurrentQueue<string>();
_Actions = new BlockingCollection<StateAction>();
_ActionRequired = new ManualResetEvent(false);
XmlDocument xDoc = new XmlDocument();
xDoc.Load(GetType().Assembly.GetManifestResourceStream("CONTROL." + _StateMachineResource + ".dgml"));
foreach (XmlNode n in xDoc.DocumentElement.GetElementsByTagName("Link"))
{
_Associations.Add(new StateAssociation(n.Attributes["Label"].Value //Trigger name
, n.Attributes["Source"].Value //Source state name
, n.Attributes["Target"].Value //Target state name
));
}
_CurrentState = "Start";
Task.Run(() => ActionThread());
}
我通过构造函数从程序集的资源加载 .dgml 文件。 作为额外的便利,我将 .dgml 文件和控制窗口命名为相同的根名称。 这样它们在 Visual Studio 的解决方案资源管理器中会分组在一起。 命名空间参数 "CONTROL"
将被替换为您的应用程序的命名空间;您可以传入它,也可以更改构造函数以直接接收 XmlDocument
作为 .dgml。
只需要从 .dgml 中收集 Link
节点,并将 Label
属性解释为源状态和目标状态之间关联的触发器。 然后将 _CurrentState
设置为 "Start" 并启动后台线程。
请注意,.dgml 必须有一个名为 "Start" 的初始状态,并起到其命名的作用。
public int Count() { return _Associations.Count; }
public virtual void ProcessTrigger(string _trigger)
{
_Triggers.Enqueue(_trigger);
_ActionRequired.Set();
}
private void UpdateState(string _state, string _trigger)
{
_CurrentState = _state;
_LastTrigger = _trigger;
if (String.Equals(_state, "End", StringComparison.OrdinalIgnoreCase))
_Actions.CompleteAdding();
}
Count()
是一个便捷函数,可用于衡量复杂性或估算进度条的上限。 ProcessTriger()
是暴露的,以便状态机可以从外部进行操作。 _Triggers
是一个线程安全、可重入的队列,强制触发器按顺序完全处理。 外部实体可以异步施加触发器。 这个 Enqueue()
门将所有转换请求通道到一个串行队列,而不会中断状态机的处理线程。 每个触发器都由状态机完全处理,然后下一个触发器才会被释放出来处理。
_ActionRequired
是一个同步事件,此处用于唤醒状态机的处理线程(如果需要)。
私有的、非线程安全的 UpdateState()
方法由 _Action
队列内部使用,用于区分和协调状态的转换(稍后会详细介绍)。 _Actions
队列用作线程安全的协调机制。 作为一个操作队列,它可以是满的或空的,但它还有一个状态,即它是否期望进一步向队列添加元素。 当转换到 "End" 状态并且不再期望任何进一步的操作时,在这里设置该状态。 这就是状态机的后台线程知道何时退出的方式。
private void ActionThread()
{
while (!_Actions.IsCompleted)
{
StateAction a = null;
_ActionRequired.Reset();
while (_Actions.TryTake(out a))
{
if (a != null && a.mi != null)
{
a.mi.Invoke(this, a.parms);
}
}
ActionThread()
在后台运行。 第一条语句检查 _Actions
队列是否为空 AND 是否不再期望接收额外的操作。 由于线程当前拥有控制权(已唤醒),同步事件 _ActionRequired
被清除。 所有等待的操作随后被串行调用,按照提交的顺序,直到 _Actions
队列变空。
在 _Actions
队列变空之后,允许 _Triggers
队列处理单个等待的触发器。 由于触发器会 enque 动作,而动作会 enque 触发器,因此 ActionThread()
被设计为在处理下一个触发器之前完成所有 enqueued 动作的处理。 这使得状态机保持健全和确定性。
string _trigger;
if (_Triggers.TryDequeue(out _trigger))
{
bool state_trigger_match_found = false;
foreach (StateAssociation sa in _Associations)
{
if (sa.source.CompareTo(_CurrentState) == 0 && sa.trigger.CompareTo(_trigger) == 0)
{
提取下一个触发器(如果存在),我查看 _Associations
以验证触发器是否具有 _CurrentState
作为转换的关联源。
MethodInfo mi = null;
mi = GetType().GetMethod("On" + sa.source + "Exit",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (mi != null) _Actions.Add(new StateAction(mi,null));
如果转换合法,我使用 C# 反射在程序集中查找 "On"+state.name+"Exit" 方法。 如果找到,我将此方法添加到 _Actions 队列。 如果找不到,我继续。 状态机不需要任何特定的状态转换来实现操作。 请注意,_CurrentState
此时尚未更改。
mi = GetType().GetMethod("On" + _trigger + "Trigger",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (mi != null) _Actions.Add(new StateAction(mi, null));
接下来,我使用 C# 反射查找程序集中的 "On"+trigger.name+"Trigger" 方法。 如果找到,我将此方法添加到 _Actions 队列。 如果找不到,我继续。 请注意,_CurrentState
此时尚未更改。
mi = GetType().BaseType.GetMethod("UpdateState"
, BindingFlags.NonPublic | BindingFlags.Instance
, Type.DefaultBinder
, new Type[] { typeof(string), typeof(string) }
, null );
if (mi != null)
_Actions.Add(new StateAction(mi, new Object[] { sa.target, _trigger.ToString() } ));
现在所有状态退出操作以及所有触发器操作都已入队,我将私有的 UpdateState()
方法与触发器状态转换的详细信息一起入队。 这将允许所有先前的操作函数在状态从 _Actions
队列中转换之前运行。 请注意,_CurrentState
和 _LastTrigger
的成员变量(或者 _CurrentState
是如何输入的)是公开可访问的,以防它们对操作函数有用。 "Exit" 操作和 "Trigger" 操作在状态更改之前运行,而 "Entry" 操作在状态更改之后运行。 以这种方式入队 UpdateState()
可确保状态更改在后台线程上以独立的方式发生。
mi = GetType().GetMethod("On" + sa.target + "Entry",
System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
if (mi != null) _Actions.Add(new StateAction(mi, null));
state_trigger_match_found = true;
break;
}
}
if (!state_trigger_match_found)
throw new InvalidStateTransitionException("Transition for [" + _trigger + "] not found in current state (" + _CurrentState + ").");
}
最后,我使用 C# 反射查找程序集中的 "On"+state.name+"Entry" 方法。 如果找到,我将其添加到 _Actions
队列,该队列将在状态更改后调用。 如果找不到,我继续。 在中断搜索触发器关联之前,我设置一个标志以指示已找到并处理了一个健全的触发器。 如果搜索触发器没有找到匹配项,则会抛出异常。
else
{
if (!_Actions.IsAddingCompleted)
_ActionRequired.WaitOne();
}
}
}
如果没有要运行的操作并且没有要处理的触发器,我会让状态机的后台线程休眠,等待外部调用 ProcessTrigger()
来唤醒线程。
结论
此类设计的核心是能够在一个 DGML 中可视化地设计状态机,并在后台线程上执行它,通过派生的状态机代码中使用 "On"+name+"Exit"|"Trigger"|"Entry" 方法的简单命名约定。 通过基类处理转换,您可以专注于实现每个单独状态转换的简单任务。
注意事项
尽管 Visual Studio DGML 设计器支持状态中的状态嵌套,但此代码不支持状态嵌套。
如果 Visual Studio 中有一个类型检查工具,可以将程序集中的代码与 .dgml 文件进行比较,那就太好了。 它可以产生警告或错误,当
- 在 .dgml 中没有 "Start" 状态。
- 在 .dgml 文件中存在嵌套,而此状态机代码当前不支持这种嵌套。
- 存在一个 "On"+some.name+"Exit"|"Trigger"|"Entry" 方法,在 .dgml 文件中未匹配,这可能是一个拼写错误。
- 对所有
ProcessTrigger()
调用进行检查可以验证触发器是否存在于 .dgml 中。
目前,这些差异必须手动检查。