Windows Foundation 4

Создание собственных управляющих операций в WF 4

Леон Велички

Поток управления (control flow) относится к тому, как организованы и выполняются индивидуальные инструкции в программе. В Windows Workflow Foundation 4 (WF 4) управляющие операции (control flow activities) контролируют семантику выполнения одной или более дочерних операций. Некоторые примеры в окне инструментария операций WF 4 — Sequence, Parallel, If, ForEach, Pick, Flowchart и Switch.

Исполняющая среда WF не имеет представления ни о каком потоке управления вроде Sequence или Parallel. С ее точки зрения, все это просто операции. Исполняющая среда просто вводит в действие некоторые простые правила (например, «операцию нельзя завершать, пока выполняются любые из ее дочерних операций»). Поток управления в WF основан на иерархии:WF-программа — это дерево операций.

Варианты потока управления в WF 4 не ограничены операциями, встроенными в инфраструктуру. Вы можете написать свои и использовать их в сочетании со встроенными — именно об этом пойдет речь в данной статье. Вы научитесь писать собственные управляющие операции, следуя принципу «ползком, пешком, бегом»: мы начнем с очень простой управляющей операции и по мере прогресса будем ее усложнять, создав в конечном счете новую и полезную управляющую операцию. Исходный код всех примеров можно скачать с сайта MSDN Magazine.

Но для начала поговорим о некоторых базовых концепциях, стоящих за операциями.

Операции

Операции являются базовой единицей выполнения в WF-программе; программа рабочего процесса (workflow program) — это дерево операций, выполняемых исполняющей средой WF. В WF 4 включено более 35 операций; обширный набор, с помощью которого можно моделировать процессы или создавать новые операции. Некоторые из этих операций управляют семантикой того, как выполняются другие операции (например, Sequence, Flowchart, Parallel и ForEach), и они известны под названием «составные операции» (composite activities). Ряд операций выполняет единственную атомарную задачу (WriteLine, InvokeMethod и т. д.). Мы называем их листовыми операциями (leaf activities).

WF-операции реализуются как CLR-типы, и, как таковые, они наследуют от других, существующих типов. Вы можете создавать операции визуально и декларативно, используя дизайнер WF, или императивно, путем написания CLR-кода. Базовые типы, доступные для создания собственных операций, определены в иерархии типов операций (рис. 1). Подробное объяснение этой иерархии типов см. в MSDN Library по ссылке msdn.microsoft.com/library/dd560893.

image: Activity Type Hierarchy

Рис.1. Иерархия типов операций

В этой статье я сосредоточусь на операциях, производных от NativeActivity — базового класса, который обеспечивает доступ ко всем возможностям исполняющей среды WF. Управляющие операции являются составными и наследуют от типа NativeActivity, так как им нужно взаимодействовать с исполняющей средой WF. Чаще всего это требуется для планирования других операций (например, Sequence, Parallel или Flowchart), но может понадобиться при реализации собственной возможности отмены, применении CancellationScope или Pick, создании закладок (bookmarks) с использованием Receive и при сохранении через Persist.

В операции заложена четкая модель работы с данными. Данные определяются с использованием аргументов и переменных. Аргументы выступают в роли связующих терминалов операции и определяют ее открытую сигнатуру в терминах того, какие данные можно передавать в эту операцию (входные аргументы), а какие — возвращать из операции по ее завершении (выходные аргументы). Переменные представляют временные хранилища данных.

Разработчики операций используют аргументы, чтобы определять, как данные передаются в операцию и возвращаются из нее, а переменные служат двум целям:

  • для предоставления редактируемого набора переменных в определении операции, который можно задействовать для совместного использования переменных несколькими операциями (например, набор Variables в Sequence и Flowchart);
  • для моделирования внутреннего состояния операции.

Создатели рабочих процессов используют аргументы для связывания операций со средой (для чего пишутся соответствующие выражения) и объявляют переменные с разными областями видимости в рабочем процессе для разделения данных между операциями. Совместно переменные и аргументы образуют предсказуемую модель взаимодействия между операциями.

Теперь, когда я пояснил некоторые основы операции, приступим к созданию первой управляющей операции.

Простая управляющая операция

Я начну с создания очень простой управляющей операции под названием ExecuteIfTrue. В ней нет ничего особенного: она запускает указанную операцию, если условие оценивается как true. WF 4 содержит операцию If, которая включает дочерние операции Then и Else; довольно часто нам требуется лишь Then, а Else излишня. Для таких случаев нужна операция, выполняющая другую операцию в зависимости от значения булева условия.

Вот как должна работать эта операция.

  • пользователь операции должен указать булево условие (этот аргумент обязателен);
  • пользователь операции может заполнить ее тело — операцию, подлежащую выполнению, если условие равно true;
  • В период выполнения: в период выполнения, если условие равно true и тело отлично от null, указанная операция выполняется.

Вот реализация операции ExecuteIfTrue, которая ведет себя именно таким образом:

public class ExecuteIfTrue : NativeActivity
{
  [RequiredArgument]
  public InArgument<bool> Condition { get; set; }

  public Activity Body { get; set; }

  public ExecuteIfTrue() { }  

  protected override void Execute(NativeActivityContext context)
  {            
    if (context.GetValue(this.Condition) && this.Body != null)
      context.ScheduleActivity(this.Body);
  }
}

Этот код очень прост, но в нем скрыто нечто большее, чем кажется на первый взгляд. ExecuteIfTrue выполняет дочернюю операцию, если условие равно true, а значит, ей нужно планировать (к выполнению) другую операцию. Следовательно, она наследует от NativeActivity, так как ей требуется взаимодействовать с исполняющей средой WF для планирования дочерних операций.

Выбрав базовый класс для операции, вы должны определить ее открытую сигнатуру. В случае ExecuteIfTrue такая сигнатура состоит из входного аргумента типа InArgument<bool> с именем Condition, который хранит условие для оценки, и свойство типа Activity с именем Body, указывающего операцию, которая должна быть выполнена при условии, равном true. Аргумент Condition дополняется атрибутом RequiredArgument, сообщающим исполняющей среде WF, что в этом аргументе обязательно должно быть задано выражение. Тогда исполняющая среда WF будет выполнять эту проверку при подготовке операции к выполнению:

[RequiredArgument]
public InArgument<bool> Condition { get; set; }

public Activity Body { get; set; }

Самая интересная часть кода в этой операции — метод Execute, в котором и происходит «действие». Все операции NativeActivity должны переопределять этот метод. Он принимает аргумент NativeActivityContext, который является нашей точкой взаимодействия с исполняющей средой WF. В ExecuteIfTrue этот контекст используется для получения значения аргумента Condition (context.GetValue(this.Condition)) и для планирования Body вызовом метода ScheduleActivity. Заметьте, что я сказал «для планирования», но не «для выполнения». Исполняющая среда WF не выполняет операции немедленно; вместо этого она добавляет их в список рабочих элементов, планируемых к выполнению:

protected override void Execute(NativeActivityContext context)
{
    if (context.GetValue(this.Condition) && this.Body != null)
        context.ScheduleActivity(this.Body);
}

Также обратите внимание, что этот тип спроектирован согласно шаблону «создание-установка-использование» (create-set-use pattern). На этом шаблоне основан XAML-синтаксис для проектирования типов, где у типа есть открытый конструктор по умолчанию и открытые свойства для записи и/или чтения. Это означает, что данный тип можно будет сериализовать в XAML.

Следующий фрагмент кода демонстрирует, как использовать эту операцию. В этом примере, если текущим днем является суббота, в консоль выводится строка «Rest!»(«отдыхай!»):

var act = new ExecuteIfTrue
{
  Condition = new InArgument<bool>(c => DateTime.Now.DayOfWeek == DayOfWeek.Tuesday),
  Body = new WriteLine { Text = "Rest!" }
};

WorkflowInvoker.Invoke(act);

Первая управляющая операция была создана написанием 15 строк кода. Но пусть вас не обманывает простота этого кода — это действительно полноценная управляющая операция!

Планирование нескольких дочерних операций

Следующая задача — написать упрощенную версию операции Sequence. Цель этого упражнения заключается в том, чтобы освоить, как пишутся управляющие операции, позволяющие планировать несколько дочерних операций и выполнять их в нескольких эпизодах (episodes). Эта операция функционально почти эквивалентна операции Sequence в составе WF.

Вот как должна работать эта операция.

  • Пользователь операции должен передать через свойство Activities набор дочерних операций для последовательного выполнения.
  • В период выполнения:
    • операция содержит внутреннюю переменную с индексом последнего элемента в выполненном наборе;
    • если в наборе есть элементы, планируем первую дочернюю операцию;
    • когда дочерняя операция завершается:
      • увеличивает индекс последнего выполненного элемента;
      • если индекс не вышел за границы набора, планируем следующую дочернюю операцию;
      • повторяем.

Код на рис. 2 реализует операцию SimpleSequence, которая ведет себя именно так, как было описано.

Рис. 2. Операция SimpleSequence

public class SimpleSequence : NativeActivity
{
  // Child activities collection
  Collection<Activity> activities;
  Collection<Variable> variables;

  // Pointer to the current item in the collection being executed
  Variable<int> current = new Variable<int>() { Default = 0 };
     
  public SimpleSequence() { }

  // Collection of children to be executed sequentially by SimpleSequence
  public Collection<Activity> Activities
  {
    get
    {
      if (this.activities == null)
        this.activities = new Collection<Activity>();

      return this.activities;
    }
  }

  public Collection<Variable> Variables 
  { 
    get 
    {
      if (this.variables == null)
        this.variables = new Collection<Variable>();

      return this.variables; 
    } 
  }

  protected override void CacheMetadata(NativeActivityMetadata metadata)
  {
    metadata.SetChildrenCollection(this.activities);
    metadata.SetVariablesCollection(this.variables);
    metadata.AddImplementationVariable(this.current);
  }

  protected override void Execute(NativeActivityContext context)
  {
    // Schedule the first activity
    if (this.Activities.Count > 0)
      context.ScheduleActivity(this.Activities[0], this.OnChildCompleted);
  }

  void OnChildCompleted(NativeActivityContext context, ActivityInstance completed)
  {
    // Calculate the index of the next activity to scheduled
    int currentExecutingActivity = this.current.Get(context);
    int next = currentExecutingActivity + 1;

    // If index within boundaries...
    if (next < this.Activities.Count)
    {
      // Schedule the next activity
      context.ScheduleActivity(this.Activities[next], this.OnChildCompleted);

      // Store the index in the collection of the activity executing
      this.current.Set(context, next);
    }
  }
}

И вновь полнофункциональная управляющая операция была реализована несколькими десятками строк кода, в данном случае около 50. Код прост, но в нем есть несколько интересных концепций.

SimpleSequence выполняет набор дочерних операций последовательно, поэтому ему нужно планировать другие операции. А значит, он наследует от NativeActivity, так как должен взаимодействовать с исполняющей средой для планирования дочерних операций.

Следующий шаг — определить открытую сигнатуру SimpleSequence. В данном случае она состоит из набора операций (типа Collection<Activity>), предоставляемого через свойство Activities, и набора переменных (типа Collection<Variable>), предоставляемого через свойство Variables. Переменные позволяют разделять данные между всеми дочерними операциями. Заметьте, что в этих свойствах есть только акссесоры get, которые обеспечивают доступ к наборам, используя подход с отложенным созданием экземпляра (рис. 3), поэтому при обращении к этим свойствам вы никогда не получите null-ссылку. Благодаря этому данные свойства соответствуют шаблону «создать-установить-использовать».

Рис. 3. Отложенное создание экземпляра

public Collection<Activity> Activities
{
  get
  {
    if (this.activities == null)
      this.activities = new Collection<Activity>();

    return this.activities;
  }
}

public Collection<Variable> Variables 
{ 
  get 
  {
    if (this.variables == null)
      this.variables = new Collection<Variable>();

    return this.variables; 
  } 
}

В этом классе есть один закрытый член, который не является частью сигнатуры: Variable<int> с именем current, в котором хранится индекс выполняемой операции:

// Pointer to the current item in the collection being executed
Variable<int> current = new Variable<int>() { Default = 0 };

Поскольку эта информация — часть внутреннего состояния выполнения для SimpleSequence, вам нужно хранить ее закрытой и не предоставлять пользователям SimpleSequence. Кроме того, вам нужно, чтобы она сохранялась и восстанавливалась при сохранении операции. Для этого вы используете ImplementationVariable.

Переменные реализации (implementation variables) — это внутренние переменные операции. Они используются только разработчиком операции, а не ее пользователем. Переменные реализации сохраняются при сохранении операции и восстанавливаются при повторной загрузке операции, не требуя никаких усилий с вашей стороны. Чтобы прояснить этот момент и продолжить пример с Sequence, отметим:если экземпляр SimpleSequence сохраняется, то при восстановлении он «вспоминает» индекс последней выполнявшейся операции.

Исполняющая среда WF сама по себе ничего не знает о переменных реализации. Если вы хотите использовать ImplementationVariable в какой-либо операции, то должны явным образом уведомить исполняющую среду WF. Делается это в ходе выполнения метода CacheMetadata.

Несмотря на довольно пугающее название, CacheMetadata вовсе не страшен в своей сложности. С концептуальной точки зрения, он весьма прост: это метод, где операция «знакомится» с исполняющей средой. Задумайтесь на секунду об операции If. В CacheMetadata эта операция могла бы сказать: «Привет, я — операция If, у меня есть входной аргумент Condition и две дочерние операции: Then и Else». Ну а SimpleSequence говорит: «Привет, я — SimpleSequence, у меня есть набор дочерних операций, набор переменных и переменная реализации». И весь код в SimpleSequence для CacheMetadata строится вокруг этого:

protected override void CacheMetadata(NativeActivityMetadata metadata)
{
  metadata.SetChildrenCollection(this.activities);
  metadata.SetVariablesCollection(this.variables);
  metadata.AddImplementationVariable(this.current);
}

Исходная реализация CacheMetadata использует отражение для получения таких данных от операции. В примере ExecuteIfTrue я не реализовал CacheMetadata и положился на исходную реализацию в отражении открытых членов. В случае SimpleSequence, напротив, мне нужна своя реализация метода CacheMetadata, так как исходная реализация не сумела бы «догадаться» о моем желании задействовать переменные реализации.

Следующий интересный кусок кода в этой операции — метод Execute. В данном случае, если в наборе есть операции, вы сообщаете исполняющей среде WF: «Пожалуйста, выполни первую операцию из набора, а когда закончишь, вызови метод OnChildCompleted». Эту просьбу вы выражаете в виде NativeActivityContext.ScheduleActivity. Заметьте:когда вы планируете операцию, вы передаете аргумент для второго параметра — CompletionCallback. Это метод, который вызывается один раз по завершении выполнения операции. И вновь важно понимать разницу между планированием и выполнением. CompletionCallback вызывается не после планирования операции, а по окончании выполнения запланированной операции:

protected override void Execute(NativeActivityContext context)
{
  // Schedule the first activity
  if (this.Activities.Count > 0)
    context.ScheduleActivity(this.Activities[0], this.OnChildCompleted);
}

Метод OnChildCompleted — самая интересная в плане обучения часть этой операции, и именно по этой причине я включил в статью операцию SimpleSequence. Этот метод получает следующую операцию из набора и планирует ее. После планирования следующей дочерней операции предоставляется CompletionCallback, который в данном случае указывает на этот же метод. Таким образом, когда дочерняя операция завершается, этот метод запускается снова, чтобы найти следующую дочернюю операцию и выполнить ее. Очевидно, что выполнение происходит периодически, эпизодами. Поскольку рабочие процессы можно сохранять и выгружать из памяти, между двумя эпизодами выполнения может быть большой интервал. Более того, эти эпизоды можно выполнять в разных потоках, процессах или даже на разных машинах (так как сохраненные экземпляры рабочего процесса допускается загружать в другой процесс или на другом компьютере). Научиться программированию в расчете на несколько эпизодов выполнения — одна из трудных задач, без освоения которой не стать экспертом в создании управляющих операций:

void OnChildCompleted(NativeActivityContext context, ActivityInstance completed)
{
  // Calculate the index of the next activity to scheduled
  int currentExecutingActivity = this.current.Get(context);
  int next = currentExecutingActivity + 1;

  // If index within boundaries...
  if (next < this.Activities.Count)
  {
    // Schedule the next activity
    context.ScheduleActivity(this.Activities[next], this.OnChildCompleted);

    // Store the index in the collection of the activity executing
    this.current.Set(context, next);
  }
}

Следующий фрагмент кода демонстрирует, как использовать эту операцию. В этом примере я вывожу в консоль три строки («Hello», «Workflow» и «!»):

var act = new SimpleSequence()
{
  Activities = 
  {
    new WriteLine { Text = "Hello" },
    new WriteLine { Text = "Workflow" },
    new WriteLine { Text = "!" }
  }
};

WorkflowInvoker.Invoke(act);

Я создал собственную SimpleSequence! А теперь самое время перейти к следующей задаче.

Реализация нового шаблона потока управления

Далее я создам сложную управляющую операцию. Как уже упоминалось, вы не ограничены управляющими операциями, встроенными в WF 4. В этом разделе я продемонстрирую, как создать собственную управляющую операцию для поддержки шаблона потока управления, который изначально не поддерживается WF 4.

Эту новую управляющую операцию мы назовем Series. Ее цель проста: обеспечить Sequence поддержкой операций GoTo, где следующей выполняемой операцией можно манипулировать либо явно из рабочего процесса (через GoTo), либо из хоста (через общеизвестную закладку).

Чтобы реализовать этот новый шаблон, потребуется создать две операции: Series (составная операция, которая содержит набор операций и последовательно выполняет их, но позволяет произвольно переходить к любому элементу в последовательности) и GoTo (листовая операция, которую я задействую внутри Series для явного моделирования переходов).

Итак, напомню и дополню цели и требования к этой управляющей операции.

  1. Это последовательность операций.
  2. Она может содержать операции GoTo (с любой глубиной вложенности), которые напрямую переносят точку выполнения на любую дочернюю операцию в Series.
  3. Она может принимать сообщения GoTo извне (например, от пользователя), которые напрямую переносят точку выполнения на любую дочернюю операцию в Series.

Начнем с реализации операции Series. Вот что представляет собой семантика ее выполнения в простых терминах.

  • Пользователь операции должен передать через свойство Activities набор дочерних операций для последовательного выполнения.
  • В методе Execute:
    • создать закладку для GoTo так, чтобы она была доступна дочерним операциям;
    • операция содержит внутреннюю переменную с выполняемым экземпляром операции;
    • если в наборе есть элементы, планируем первую дочернюю операцию;
    • когда дочерняя операция завершается:
      • ищем завершенную операцию в наборе Activities;
      • увеличивает индекс последнего выполненного элемента;
      • если индекс не вышел за границы набора, планируем следующую дочернюю операцию;
      • повторяем.
  • Если закладка GoTo возобновляется:
    • получить имя операции, к которой мы хотим перейти;
    • найти эту операцию в наборе;
    • запланировать целевую операцию в наборе для выполнения и зарегистрировать обратный вызов по завершению выполнения, который будет планировать следующую операцию;
    • отменить операцию, которая выполняется в данный момент;
    • сохранить операцию, выполняемую в данный момент, в переменной current.

Пример кода на рис. 4 демонстрирует реализацию операции Series, которая ведет себя именно так, как было описано.

Рис. 4. Операция Series

public class Series : NativeActivity
{
  internal static readonly string GotoPropertyName = 
    "Microsoft.Samples.CustomControlFlow.Series.Goto";

  // Child activities and variables collections
  Collection<Activity> activities;
  Collection<Variable> variables;

  // Activity instance that is currently being executed
  Variable<ActivityInstance> current = new Variable<ActivityInstance>();
 
  // For externally initiated goto's; optional
  public InArgument<string> BookmarkName { get; set; }

  public Series() { }

  public Collection<Activity> Activities 
  { 
    get {
      if (this.activities == null)
        this.activities = new Collection<Activity>();
    
      return this.activities; 
    } 
  }

  public Collection<Variable> Variables 
  { 
    get {
      if (this.variables == null)
        this.variables = new Collection<Variable>();

      return this.variables; 
    } 
  }
    
  protected override void CacheMetadata(NativeActivityMetadata metadata)
  {                        
    metadata.SetVariablesCollection(this.Variables);
    metadata.SetChildrenCollection(this.Activities);
    metadata.AddImplementationVariable(this.current);
    metadata.AddArgument(new RuntimeArgument("BookmarkName", typeof(string), 
                                              ArgumentDirection.In));
  }

  protected override bool CanInduceIdle { get { return true; } }

  protected override void Execute(NativeActivityContext context)
  {
    // If there activities in the collection...
    if (this.Activities.Count > 0)
    {
      // Create a bookmark for signaling the GoTo
      Bookmark internalBookmark = context.CreateBookmark(this.Goto,
                BookmarkOptions.MultipleResume | BookmarkOptions.NonBlocking);

      // Save the name of the bookmark as an execution property
      context.Properties.Add(GotoPropertyName, internalBookmark);

      // Schedule the first item in the list and save the resulting 
      // ActivityInstance in the "current" implementation variable
      this.current.Set(context, context.ScheduleActivity(this.Activities[0], 
                                this.OnChildCompleted));

      // Create a bookmark for external (host) resumption
      if (this.BookmarkName.Get(context) != null)
        context.CreateBookmark(this.BookmarkName.Get(context), this.Goto,
            BookmarkOptions.MultipleResume | BookmarkOptions.NonBlocking);
    }
  }

  void Goto(NativeActivityContext context, Bookmark b, object obj)
  {
    // Get the name of the activity to go to
    string targetActivityName = obj as string;

    // Find the activity to go to in the children list
    Activity targetActivity = this.Activities
                                  .Where<Activity>(a =>  
                                         a.DisplayName.Equals(targetActivityName))
                                  .Single();

    // Schedule the activity 
    ActivityInstance instance = context.ScheduleActivity(targetActivity, 
                                                         this.OnChildCompleted);

    // Cancel the activity that is currently executing
    context.CancelChild(this.current.Get(context));

    // Set the activity that is executing now as the current
    this.current.Set(context, instance);
  }

  void OnChildCompleted(NativeActivityContext context, ActivityInstance completed)
  {
    // This callback also executes when cancelled child activities complete 
    if (completed.State == ActivityInstanceState.Closed)
    {
      // Find the next activity and execute it
      int completedActivityIndex = this.Activities.IndexOf(completed.Activity);
      int next = completedActivityIndex + 1;

      if (next < this.Activities.Count)
          this.current.Set(context, 
                           context.ScheduleActivity(this.Activities[next],
                           this.OnChildCompleted));
    }
  }
}

Часть этого кода знакома вам по предыдущим примерам. Давайте обсудим реализацию этой операции.

Series наследует от NativeActivity, так как ей нужно взаимодействовать с исполняющей средой WF для планирования дочерних операций, создания закладок, отмены дочерних операций и использования свойств выполнения.

Как и раньше, следующий шаг заключается в определении открытой сигнатуры Series. Аналогично SimpleSequence у нас есть свойства-наборы Activities и Variables. Кроме того, имеется строковый входной аргумент BookmarkName (типа InArgument<string>) с именем закладки, которую надо создать для возобновления хостом. И вновь я следую шаблону «создать-установить-использовать».

В операции Series есть закрытый член current, который содержит выполняемый в данный момент ActivityInstance, а не просто указатель на элемент в наборе, как в SimpleSequence. Почему current является именно Variable<ActivityInstance>, но не Variable<int>? Потому что текущая выполняемая дочерняя операция еще понадобится мне позднее при работе метода GoTo. Детали я поясню потом, а сейчас важно понимать, что у меня будет переменная реализации, в которой хранится текущий выполняемый экземпляр операции:

Variable<ActivityInstance> current = new Variable<ActivityInstance>();

В CacheMetadata вы предоставляете информацию периода выполнения о своей операци��: наборы дочерних операций и переменных, переменную реализации с текущим экземпляром операции и аргумент с именем закладки. Единственное отличие от предыдущего примера — я вручную регистрирую входной аргумент BookmarkName в исполняющей среде WF, добавляя новый экземпляр RuntimeArgument к метаданным операции:

protected override void CacheMetadata(NativeActivityMetadata metadata)
{                        
  metadata.SetVariablesCollection(this.Variables);
  metadata.SetChildrenCollection(this.Activities);
  metadata.AddImplementationVariable(this.current);
  metadata.AddArgument(new RuntimeArgument("BookmarkName",  
                                           typeof(string), ArgumentDirection.In));
}

Следующее новшество — перегруженная версия свойства CanInduceIdle. Это просто дополнительные метаданные, которые операция предоставляет исполняющей среде WF. Когда это свойство возвращает true, я сообщаю исполняющей среде, что данная операция может перевести рабочий процесс в состояние простоя. Мне нужно переопределить это свойство и возвращать true для операций, создающих закладки, так как они заставят рабочий процесс перейти в состояние простоя и ожидать возобновления. Значение этого свойства по умолчанию — false. Если свойство возвращает false, а мы создадим закладку, то при выполнении операции будет сгенерировано исключение InvalidOperationException:

protected override bool CanInduceIdle { get { return true; } }

В методе Execute становится интереснее, где я создаю закладку (internalBookmark) и сохраняю ее в свойстве выполнения. Но, прежде чем продолжить, давайте обсудим, что такое закладки и свойства выполнения.

Закладки (bookmarks) — это механизм, с помощью которого операция может пассивно ждать возобновления. Когда операции нужно «блокироваться» в ожидании определенного события, она регистрирует закладку, а затем возвращает состояние выполнения как продолжающееся. Это информирует исполняющую среду о том, что, хотя выполнение данной операции не закончено, ей пока больше нечего делать. Благодаря закладкам вы можете создавать свои операции, используя некую форму выполнения с обратной связью (reactive execution): при создании закладки операция возвращает управление, а при возобновлении закладки в ответ на это событие вызывается блок кода (обратный вызов возобновления закладки).

В отличие от программ, прямо ориентированных на CLR, программы рабочих процессов являются деревьями операций с иерархией областей видимости, выполняемых в независимой от потоков среде. Это означает, что для определения того, какой контекст находится в области видимости данного рабочего элемента, нельзя применять стандартные механизмы локальной памяти потоков (thread local storage, TLS). Контекст выполнения рабочего процесса вводит в среду операции свойства выполнения (execution properties), чтобы любая операция могла объявлять свойства, которые находятся в области видимости для своего поддерева, и совместно использовать их со своими дочерними операциями. В итоге через эти свойства операция может обмениваться данными со своими потомками.

Теперь, когда вы знаете, что такое закладки и свойства выполнения, вернемся к коду. В самом начале метода Execute я создаю закладку (используя context.CreateBookmark) и сохраняю ее в свойстве выполнения (через context.Properties.Add). Это многократно возобновляемая закладка (multiple-resume bookmark), и она будет доступна, пока ее родительская операция находится в состоянии выполнения. Она также NonBlocking, поэтому не препятствует завершению операции после того, как та сделает свою работу. Когда закладка возобновляется, вызывается метод GoTo, так как я передал BookmarkCompletionCallback в CreateBookmark (первый параметр). Причина ее сохранения в свойстве выполнения — сделать ее доступной всем дочерним операциям. (Позднее вы увидите, как операция GoTo использует эту закладку.) Заметьте, что у свойств выполнения есть имена. Поскольку эти имена строковые, я определил константу (GotoPropertyName) с именем для свойства в операции. При этом я следую рекомендации использовать полные имена (fully qualified names):

internal static readonly string GotoPropertyName = 
                                "Microsoft.Samples.CustomControlFlow.Series.Goto";

...
...

// Create a bookmark for signaling the GoTo
Bookmark internalBookmark = context.CreateBookmark(this.Goto,                                         
                       BookmarkOptions.MultipleResume | BookmarkOptions.NonBlocking);

// Save the name of the bookmark as an execution property
context.Properties.Add(GotoPropertyName, internalBookmark);

После объявления закладки можно планировать первую операцию. Это нам знакомо, так как мы уже делали это в предыдущих операциях. Я буду планировать первую операцию в наборе и сообщу исполняющей среде вызвать метод OnChildCompleted по завершении операции (как в SimpleSequence). Context.ScheduleActivity возвращает ActivityInstance — он представляет экземпляр текущей выполняемой операции, который я присваиваю нашей переменной реализации current. Позвольте мне здесь кое-что прояснить. Activity является определением подобно классу; ActivityInstance — это реальный экземпляр, как объект. У нас может быть несколько ActivityInstance от одного Activity:

// Schedule the first item in the list and save the resulting 
// ActivityInstance in the "current" implementation variable
this.current.Set(context, context.ScheduleActivity(this.Activities[0],  
                                                   this.OnChildCompleted));

Наконец, мы создаем закладку, которая может использоваться хостом для перехода к любой операции в Series. Механика этого проста: поскольку хосту известно имя закладки, он может возобновить ее переходом к любой операции в Series:

// Create a bookmark for external (host) resumption
 if (this.BookmarkName.Get(context) != null)
     context.CreateBookmark(this.BookmarkName.Get(context), this.Goto,
                           BookmarkOptions.MultipleResume | BookmarkOptions.NonBlocking);

Метод OnChildCompleted теперь должен быть понятен, так как он очень похож на таковой в SimpleSequence: я нахожу следующий элемент в наборе операций и планирую его. Основное различие в том, что я планирую следующую операцию, только если текущая операция успешно завершила свое выполнение (т. е. перешла в состояние «закрыта» и не была отменена или завершена неудачно).

Метод GoTo, вероятно, является самым интересным. Это метод, который выполняется в результате возобновления закладки GoTo. Он принимает некоторые входные данные, которые передаются при возобновлении закладки. В нашем случае данные — это имя операции, к которой мы хотим перейти:

void Goto(NativeActivityContext context, Bookmark b, object data)
{
  // Get the name of the activity to go to
  string targetActivityName = data as string;
       
  ...
 }

Имя целевой операции является свойством DisplayName этой операции. Я ищу определение запрошенной операции в наборе операций. Найдя нужную, я планирую ее, указывая, что, когда эта операция завершится, должен быть вызван метод OnChildCompleted:

// Find the activity to go to in the children list
Activity targetActivity = this.Activities
                              .Where<Activity>(a =>  
                                       a.DisplayName.Equals(targetActivityName))
                              .Single();
// Schedule the activity 
ActivityInstance instance = context.ScheduleActivity(targetActivity, 
                                                     this.OnChildCompleted);

Далее я отменяю экземпляр операции, который выполняется в данный момент, и присваиваю его ActivityInstance, запланированному на предыдущем этапе. Для обеих задач я использую переменную current. Сначала я передаю ее как параметр метода CancelChild контекста NativeActivityContext, а затем обновляю ее значением ActivityInstance, который был запланирован в предыдущем блоке кода:

// Cancel the activity that is currently executing
context.CancelChild(this.current.Get(context));

// Set the activity that is executing now as the current
this.current.Set(context, instance);

Операция GoTo

Операцию GoTo можно использовать только внутри операции Series для перехода к нужной операции внутри ее набора Activities. Она аналогична оператору GoTo в императивной программе. Принцип ее работы очень прост: она возобновляет закладку GoTo, созданную операцией Series, где она содержится, с указанием имени операции, к которой мы хотим перейти. При возобновлении закладки Series перейдет к заданной операции.

Вот простое описание семантики выполнения.

  • Пользователь операции должен указать строку TargetActivityName (этот аргумент обязателен).
  • В период выполнения:
    • операция GoTo будет искать закладку «GoTo», созданную операцией Series;
    • если закладка найдена, операция GoTo возобновит ее, передав TargetActivityName;
    • она создаст синхронизирующую закладку, поэтому текущая операция не завершится;
      • эта операция будет отменена операцией Series.

Реализация операции GoTo, которая ведет себя именно так, как описано, показана на рис. 5.

Рис. 5. Операция GoTo

public class GoTo : NativeActivity
{
  public GoTo() 
  { }
       
  [RequiredArgument]
  public InArgument<string> TargetActivityName { get; set; }

  protected override bool CanInduceIdle { get { return true; } }
    
  protected override void Execute(NativeActivityContext context)
  {
    // Get the bookmark created by the parent Series
    Bookmark bookmark = context.Properties.Find(Series.GotoPropertyName) as Bookmark;

    // Resume the bookmark passing the target activity name
    context.ResumeBookmark(bookmark, this.TargetActivityName.Get(context));

    // Create a bookmark to leave this activity idle waiting when it does
    // not have any further work to do. Series will cancel this activity 
    // in its GoTo method
    context.CreateBookmark("SyncBookmark");
  }

}

GoTo наследует от NativeActivity, так как ей нужно взаимодействовать с исполняющей средой WF для создания и возобновления закладок и использовать свойства выполнения. Ее открытая сигнатура состоит из входного строкового аргумента TargetActivityName, который содержит имя операции, к которой мы хотим перейти. Я дополнил этот аргумент атрибутом RequiredArgument.

Я полагаюсь на исходную реализацию CacheMetadata, которая отражает открытое содержимое операции для поиска и регистрации метаданных для исполняющей среды.

Самая важная часть — метод Execute. Сначала я ищу закладку, созданную родительской операцией Series. Поскольку закладка сохранена как свойство выполнения, я ищу ее в context.Properties. Найдя закладку, я возобновляю ее, передавая TargetActivityName как входные данные. Возобновление закладки приведет к вызову метода Series.Goto (это обратный вызов закладки, предоставленный при ее создании). Этот метод найдет следующую операцию в наборе, запланирует ее и отменит текущую выполняемую операцию:

// Get the bookmark created by the parent Series
Bookmark bookmark = context.Properties.Find(Series.GotoPropertyName) as Bookmark; 

// Resume the bookmark passing the target activity name
context.ResumeBookmark(bookmark, this.TargetActivityName.Get(context));

Последняя строка кода самая хитрая: создание закладки для синхронизации, благодаря чему выполнение операции GoTo будет продолжено. Поэтому, когда завершается метод GoTo.Execute, эта операция по-прежнему будет в состоянии «выполняется», ожидая сигнала на возобновление закладки. Обсуждая код для Series.Goto, я упомянул, что он отменяет выполняемую операцию. В данном случае Series.Goto действительно отменяет экземпляр операции Goto, ожидающий возобновления этой закладки.

А теперь поясню подробнее. Экземпляр операции GoTo был запланирован операцией Series. Когда эта операция завершается, обратный вызов в Series (OnChildCompleted), инициируемый по завершении, ищет следующую операцию в наборе Series.Activities и планирует ее. В данном случае мне не нужно планировать следующую операцию — я хочу запланировать операцию, на которую ссылается TargetActivityName. Эта закладка позволяет решить такую задачу, поскольку сохраняет операцию GoTo в состоянии «выполняется», пока планируется целевая операция. Когда GoTo отменяется, никаких действий в обратном вызове Series.OnChildCompleted не выполняется, потому что он планирует следующую операцию, только если состояние завершения соответствует Closed (и в данном случае Cancelled):

// Create a bookmark to leave this activity idle waiting when it does
// not have any further work to do. Series will cancel this activity 
// in its GoTo method
context.CreateBookmark("SyncBookmark");

На рис. 6 показан пример использования этой операции. Здесь я возвращаюсь перебором в цикле к предыдущему состоянию в соответствии со значением переменной. Это простой пример, иллюстрирующий основы применения Series, но эту операцию можно использовать в реализации сложных бизнес-сценариев, где нужно пропускать и повторять какие-то этапы в последовательном процессе.

Рис.

var counter = new Variable<int>();

var act = new Series
{
  Variables = { counter},
  Activities =
  {
    new WriteLine 
    {
      DisplayName = "Start",
      Text = "Step 1"
    },
    new WriteLine
    {
      DisplayName = "First Step",
      Text = "Step 2"
    },
    new Assign<int>
    {
      To = counter,
      Value = new InArgument<int>(c => counter.Get(c) + 1)
    },
    new If 
    {
      Condition = new InArgument<bool>(c => counter.Get(c) == 3),
      Then = new WriteLine
      {
        Text = "Step 3"
      },
      Else = new GoTo { TargetActivityName = "First Step" }
    },
    new WriteLine 
    {
      Text = "The end!"
    }
  }
};

WorkflowInvoker.Invoke(act);

Ссылки

Windows Workflow Foundation 4 Developer Center
msdn.microsoft.com/netframework/aa663328

Endpoint.tv: Activities Authoring Best Practices
channel9.msdn.com/shows/Endpoint/endpointtv-Workflow-and-Custom-Activities-Best-Practices-Part-1/

Проектирование и реализация собственных операций
msdn.microsoft.com/library/dd489425

Класс ActivityInstance
msdn.microsoft.com/library/system.activities.activityinstance

Класс RuntimeArgument
msdn.microsoft.com/library/dd454495

Заключение

В этой статье я представил вам общие аспекты написания собственных управляющих операций. В WF 4 спектр таких операций не зафиксирован, а создание своих операций радикально упрощено. Если заранее определенные операции вас не устраивают, вы легко можете написать собственные. В этой статье я начал с простой управляющей операции, а затем постепенно реализовал собственную управляющую операцию, которая вводит в WF 4 новую семантику выполнения.Если вы хотите узнать больше, на сайте CodePlex доступен полный исходный код Community Technology Preview for State Machine. Кроме того, на Channel 9 есть ряд видеороликов по наиболее эффективным способам создания операций. Разрабатывая собственные операции, вы можете выразить в WF любой шаблон потока управления и адаптировать WF к специфике ваших задач.

Леон Велички (Leon Welicki) — менеджер программ в группе Windows Workflow Foundation (WF) в Microsoft, занимается исполняющей средой WF. До перехода в Microsoft работал ведущим архитектором и руководителем разработок в крупной испанской телекоммуникационной компании, а также внештатным доцентом на факультете компьютерных наук в Епископальном университете Саламанки (Pontifical University of Salamanca) в Мадриде.

Выражаю благодарность за рецензирование статьи эксперту Джейсен Тенни (Jasen Tenney). Джо Клэнси (Joe Clancy), Дэну Глику (Dan Glick), Раджешу Сампатху (Rajesh Sampath), Бобу Шмидту (Bob Schmidt) и Айзеку Юну (Isaac Yuen)