65.9K
CodeProject 正在变化。 阅读更多。
Home

C#/WPF 由 DGML 驱动的状态机

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.20/5 (2投票s)

2014 年 11 月 22 日

CPOL

7分钟阅读

viewsIcon

27811

downloadIcon

469

使用可视化工具设计 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 中。

目前,这些差异必须手动检查。

© . All rights reserved.