Подсистемы Foundation

Workflow Services для локального взаимодействия

Мэтт Милнер

Загрузка примера кода

В одной из прошлых статей (см. номер MSDN Magazine за сентябрь 2007 г. по ссылке msdn.microsoft.com/magazine/cc163365.aspx) я писал о базовой коммуникационной архитектуре в Windows Workflow Foundation 3 (WF3). Одна тема, которую я тогда не затронул, — операции, связанные с локальным взаимодействием; это одна из абстракций поверх коммуникационной архитектуры. Если вы посмотрите на первую бета-версию .NET Framework 4, то заметите отсутствие операции HandleExternalEvent. Фактически в WF4 коммуникационные операции встроены в Windows Communication Foundation (WCF). Сегодня я покажу, как использовать WCF для взаимодействия между рабочим процессом и хост-приложением в Windows Workflow Foundation 3. Это поможет вам в разработках с применением WF3 и подготовит к работе с WF4, где WCF является лишь абстракцией поверх механизма очередей [в WF4 их называют закладками (bookmarks)]. (Базовые сведения о Workflow Services в WF3 см. в журнале MSDN Magazine по ссылке msdn.microsoft.com/magazine/cc164251.aspx.)

Обзор

Взаимодействие между хост-приложением и рабочими процессами оказалось для некоторых разработчиков весьма проблематичным делом — они упускали из виду тот факт, рабочий процесс и хост часто выполняются в разных потоках. Дизайн коммуникационной архитектуры рассчитан на то, чтобы избавить разработчиков от забот, связанных с управлением контекстами потоков, маршалингом данных и другими низкоуровневыми вещами. Одна из абстракций поверх архитектуры очередей в WF — интеграция обмена сообщениями WCF, введенная в .NET Framework 3.5. Большинство примеров и упражнений демонстрирует, как использовать операции и расширения WCF, чтобы предоставлять рабочий процесс клиентам, внешним по отношению к хост-процессу, но та же коммуникационная инфраструктура годится для взаимодействия и внутри одного процесса.

Реализация взаимодействия включает несколько этапов, но не требует больше усилий, чем при работе с локальными операциями взаимодействия.

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

Моделирование коммуникаций

Первый шаг в моделировании коммуникаций — определение контрактов между хост-приложением и рабочим процессом. WCF-сервисы используют контракты для определения набора операций, из которых собственно и состоит сервис, и сообщений, которые могут посылаться и приниматься. В данном случае, поскольку вы «протягиваете коммуникации» от хоста к рабочему процессу и от рабочего процесса к хосту, нужно определить два контракта сервиса и соответствующие контракты данных (рис. 1).

Рис. 1. Контракты для взаимодействия

[ServiceContract(
    Namespace = "urn:MSDN/Foundations/LocalCommunications/WCF")]
public interface IHostInterface
{
[OperationContract]
void OrderStatusChange(Order order, string newStatus, string oldStatus);
}

[ServiceContract(
    Namespace="urn:MSDN/Foundations/LocalCommunications/WCF")]
public interface IWorkflowInterface
{
    [OperationContract]
    void SubmitOrder(Order newOrder);

    [OperationContract]
    bool UpdateOrder(Order updatedOrder);
}

[DataContract]
public class Order
{
    [DataMember]
    public int OrderID { get; set; }
    [DataMember]
    public string CustomerName { get; set; }
    [DataMember]
    public double OrderTotal { get; set; }
    [DataMember]
    public string OrderStatus { get; set; }
    }

После этого моделирование рабочего процесса с использованием операций Send и Receive, происходит так же, как и при удаленной коммуникационной связи. Это один из элегантных моментов в WCF: модель программирования одинакова независимо от того, какие коммуникации вы применяете — удаленные или локальные. Простой пример на рис. 2 показывает рабочий процесс с двумя операциями Receive и одной Send, моделирующий взаимодействие между рабочим процессом и хостом. Операции Receive конфигурируются с применением контракта сервиса IWorkflowInterface, а операция Send использует контракт IHostInterface.

До сих пор использование WCF для локальных коммуникаций мало чем отличалось от такового для удаленных коммуникаций и очень похоже на применение локальных коммуникационных операций и сервисов. Основное различие проявляется в том, как пишется код хоста для запуска рабочего процесса и обработки коммуникационной связи, исходящей от рабочего процесса.

Рис. 2. Рабочий процесс, смоделированный на основе контрактов

Размещение сервисов

Поскольку нам нужна двухсторонняя коммуникационная связь на основе WCF, требуется размещение двух сервисов: сервис рабочего процесса, запускающий этот процесс, и сервис в хост-приложении, принимающий сообщения от рабочего процесса. В своем примере я создал простое WPF-приложение (Windows Presentation Foundation), выступающее в роли хоста и использующее методы OnStartup и OnExit класса App для управления хостом. Вашим первым порывом может быть попытка создать класс WorkflowServiceHost и открыть его прямо в методе OnStartup. Так как метод Open не блокируется после открытия хоста, можно продолжать обработку, загружать UI и начинать взаимодействие с рабочим процессом. Однако, поскольку WPF (и другие клиентские технологии) использует единственный поток для обработки, это очень скоро приведет к проблемам — сервис и клиент не могут работать в одном потоке, поэтому время ожидания сервиса истечет. Чтобы избежать этого, класс WorkflowServiceHost создается в другом потоке через ThreadPool, как показано на рис 3.

Рис. 3. Размещение сервиса рабочего потока

ThreadPool.QueueUserWorkItem((o) =>
{

//host the workflow
workflowHost = new WorkflowServiceHost(typeof(
    WorkflowsAndActivities.OrderWorkflow));
workflowHost.AddServiceEndpoint(
    "Contracts.IWorkflowInterface", LocalBinding, WFAddress);
try
{
    workflowHost.Open();
}
catch (Exception ex)
{
    workflowHost.Abort();
    MessageBox.Show(String.Format(
        "There was an error hosting the workflow as a service: {0}",
    ex.Message));
}
});

Со следующей проблемой вы столкнетесь, выбирая подходящую привязку для локального взаимодействия. В настоящее время не существует привязки в памяти или внутри процесса, облегченной для ситуаций такого рода. Лучший вариант — использовать NetNamedPipeBinding с отключенной защитой. Увы, если вы попытаетесь задействовать эту привязку и разместите рабочий процесс как сервис, вы получите ошибку с уведомлением о том, что хосту нужна привязка с каналом Context, так как ваш контракт сервиса может потребовать создания сеанса. Хуже того, в .NET Framework такой привязки вообще нет — эта инфраструктура поставляется только с тремя контекстными привязками: BasicHttpContextBinding, NetTcpContextBinding и WSHttpContextBinding. К счастью, можно создавать собственные привязки, включающие каналы контекста. На рис. 4 показана собственная привязка, производная от класса NetNamedPipeBinding и вводящая в него ContextBindingElement. Теперь коммуникации в обоих направлениях могут использовать эту привязку, указывая разные адреса.

Рис. 4 NetNamedPipeContextBinding

public class NetNamedPipeContextBinding : NetNamedPipeBinding
{
    public NetNamedPipeContextBinding() : base(){}

    public NetNamedPipeContextBinding(
        NetNamedPipeSecurityMode securityMode):
        base(securityMode) {}

    public NetNamedPipeContextBinding(string configurationName) :
        base(configurationName) {}

    public override BindingElementCollection CreateBindingElements()
    {
        BindingElementCollection baseElements = base.CreateBindingElements();
        baseElements.Insert(0, new ContextBindingElement(
            ProtectionLevel.EncryptAndSign,
            ContextExchangeMechanism.ContextSoapHeader));

        return baseElements;
    }
}

Располагая этой новой привязкой, вы можете создать конечную точку в WorkflowServiceHost и открыть хост — ошибок больше не будет. Рабочий процесс готов принимать данные от хоста по контракту сервиса. Для передачи данных вы должны создать прокси и вызвать операцию, как показано на рис. 5.

Рис. 5. Код хоста для запуска рабочего процесса

App a = (App)Application.Current;
    IWorkflowInterface proxy = new ChannelFactory<IWorkflowInterface>(
    a.LocalBinding, a.WFAddress).CreateChannel();

    proxy.SubmitOrder(
        new Order
        {
            CustomerName = "Matt",
            OrderID = 0,
            OrderTotal = 250.00
        });

Поскольку контракты используются совместно, класса прокси нет, и вам придется использовать ChannelFactory<TChannel> для создания клиентского прокси.

Хотя рабочий процесс размещен и готов принимать сообщения, его еще нужно настроить на передачу сообщений хосту. Самое главное в том, что рабочему процессу нужна возможность получать конечную точку клиента при использовании операции Send. Эта операция позволяет указывать имя конечной точки, которое обычно сопоставляется с именованной конечной точкой в конфигурационном файле. Хотя размещение информации о конечной точке в конфигурационном файле работает, вы также можете использовать ChannelManagerService (см. мою статью за август 2008 г. по ссылке msdn.microsoft.com/magazine/cc721606.aspx) для хранения конечных точек клиентов, применяемых вашими операциями Send в рабочем процессе. На рис. 6 показан код хоста для создания сервиса, предоставления ему именованной конечной точки и его добавления в WorkflowRuntime, размещенного в WorkflowServiceHost.

Рис. 6. Добавление ChannelManagerService в исполняющую среду

ServiceEndpoint endpoint = new ServiceEndpoint
(
    ContractDescription.GetContract(typeof(Contracts.IHostInterface)),
        LocalBinding, new EndpointAddress(HostAddress)
);
endpoint.Name = "HostEndpoint";

WorkflowRuntime runtime =
    workflowHost.Description.Behaviors.Find<WorkflowRuntimeBehavior>().
WorkflowRuntime;

ChannelManagerService chanMan =
    new ChannelManagerService(
        new List<ServiceEndpoint>
        {
            endpoint
        });

runtime.AddService(chanMan);

Размещение сервиса рабочего процесса дает возможность посылать сообщения от хоста в рабочий процесс, но для передачи сообщений хосту нужен WCF-сервис, способный принимать сообщения от рабочего процесса. Это стандартный WCF-сервис, автоматически размещаемый в приложении. Так как он не относится к рабочему процессу, можно задействовать стандартную привязку NetNamedPipeBinding или повторно использовать NetNamedPipeContextBinding, показанную ранее. Наконец, поскольку этот сервис вызывается из рабочего процесса, его можно разместить в UI-потоке, что упрощает взаимодействие с UI-элементами. Код размещения сервиса приведен на рис. 7.

Рис. 7. Размещение сервиса хоста

ServiceHost appHost = new ServiceHost(new HostService());
appHost.AddServiceEndpoint("Contracts.IHostInterface",
LocalBinding, HostAddress);

try
{
    appHost.Open();
}
catch (Exception ex)
{
    appHost.Abort();
    MessageBox.Show(String.Format(
        "There was an error hosting the local service: {0}",
    ex.Message));
}

Разместив оба сервиса, можно запустить рабочий процесс и передать/принять сообщение. Однако, если вы попытаетесь послать второе сообщение, используя этот код, второй операции Receive в рабочем процессе, вы получите сообщение об ошибке, связанной с контекстом.

Корреляция экземпляров

Один из способов справиться с проблемой контекстов — использовать один и тот же клиентский прокси для каждого вызова сервиса. Это позволяет клиентскому прокси управлять идентификаторами контекстов (через NetNamedPipeContextBinding) и посылать их обратно сервису с последующими запросами.

В некоторых ситуациях один и тот же прокси не годится для всех запросов. Рассмотрим случай, когда вы запускаете рабочий процесс, сохраняете его в базе данных и закрываете клиентское приложение. При следующем запуске клиентского приложения вам нужно как-то возобновить рабочий процесс, послав другое сообщение этому конкретному экземпляру. Еще один распространенный вариант — вы хотите использовать единственный клиентский прокси, но вам нужно взаимодействовать с несколькими экземплярами рабочего процесса, каждый из которых имеет уникальный идентификатор. Например, в UI выводится список заказов, каждому из которых соответствует свой рабочий процесс, а когда пользователь инициирует какую-то операцию применительно к выбранному заказу, вам требуется послать сообщение этому экземпляру рабочего процесса. Возложить на привязку управление идентификатором контекста в этой ситуации не удастся, так как она всегда использует идентификатор последнего рабочего процесса, с которым вы взаимодействовали.

В первом случае — использование нового прокси для каждого вызова — вам нужно вручную записывать идентификатор рабочего процесса в контекст с помощью интерфейса IContextManager. Этот интерфейс доступен через метод GetProperty<TProperty> интерфейса IClientChannel. Получив IContextManager, вы можете использовать его для получения или установки контекста.

Контекст представляет собой словарь, состоящий из пар «имя-значение», где самое важное — значение instanceId. В следующем фрагменте кода показано, как получить идентификатор (ID) из контекста, чтобы ваше клиентское приложение могло сохранить его на будущее, когда вам понадобится взаимодействовать с тем же экземпляром рабочего процесса. В этом примере ID просто отображается в клиентском UI, а не сохраняется в базе данных:

IContextManager mgr = ((IClientChannel)proxy).GetProperty<IContextManager>();
      
string wfID = mgr.GetContext()["instanceId"];
wfIdText.Text = wfID;

Как только вы впервые вызываете сервис рабочего процесса, в контекст автоматически записывается идентификатор экземпляра этого рабочего процесса (это выполняется контекстной привязкой в конечной точке сервиса).

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

IContextManager mgr = ((IClientChannel)proxy).GetProperty<IContextManager>();
  mgr.SetContext(new Dictionary<string, string>{
    {"instanceId", wfIdText.Text}
  });

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

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

Для первого вызова создайте все прокси с помощью одного ChannelFactory<TChannel>. Это приведет к повышению общей производительности, так как создание ChannelFactory требует некоторых издержек, которые вы вряд ли захотите накапливать при каждом первом вызове. С помощью кода вроде показанного на рис. 5 можно использовать один ChannelFactory<TChannel> для создания начального прокси. Закончив вызов, вы должны вызывать метод Close, чтобы освободить этот прокси.

Это стандартный WCF-код для создания прокси методом фабрики канала. Поскольку привязка является контекстной, вы получаете автоматическое управление контекстом по умолчанию, а значит, можете получить идентификатор экземпляра рабочего процесса из контекста после первого вызова этого процесса.

При последующих вызовах вам нужно управлять контекстом самостоятельно, и для этого требуется использовать код WCF-клиента, который нечасто применяется разработчиками. Чтобы вручную установить контекст, вы должны задействовать OperationContextScope и сами создать MessageContextProperty. MessageContextProperty задается в посылаемом сообщении, что эквивалентно использованию IContextManager для установки контекста с тем исключением, что применение этого свойства срабатывает, даже когда управление контекстом отключено. На рис. 8 представлен код для создания прокси с помощью того же ChannelFactory<TChannel>, что и для создания начального прокси. В данном случае разница в том, что IContextManager используется для отключения автоматического управления контекстом, а кеширование прокси позволяет обойтись без создания нового прокси для каждого запроса.

Рис. 8. Отключение автоматического управления контекстом

App a = (App)Application.Current;

if (updateProxy == null)
{
    if (factory == null)
        factory = new ChannelFactory<IWorkflowInterface>(
            a.LocalBinding, a.WFAddress);

        updateProxy = factory.CreateChannel();
        IContextManager mgr =
            ((IClientChannel)updateProxy).GetProperty<IContextManager>();
        mgr.Enabled = false;
        ((IClientChannel)updateProxy).Open();
}

Создав прокси, вы должны создать OperationContextScope и добавить MessageContextProperty в свойства исходящего сообщения в пределах области видимости контекста. На рис. 9 приведен код для создания и установки свойства сообщения с применением OperationContextScope.

Рис. 9. Применение OperationContextScope

using (OperationContextScope scope =
    new OperationContextScope((IContextChannel)proxy))
{
    ContextMessageProperty property = new ContextMessageProperty(
        new Dictionary<string, string>
        {
            {“instanceId”, wfIdText.Text}
        });

OperationContext.Current.OutgoingMessageProperties.Add(
    "ContextMessageProperty", property);

proxy.UpdateOrder(
    new Order
        {
            CustomerName = "Matt",
            OrderID = 2,
            OrderTotal = 250.00,
            OrderStatus = "Updated"
        });
}

Довольно много работы только для того, чтобы хост и рабочий процесс могли взаимодействовать друг с другом. Хорошая новость — большая часть этой логики и управление идентификаторами могут быть инкапсулированы в нескольких классах. Однако это требует кодирования вашего клиента конкретным способом, обеспечивающим корректное управление контекстом в тех случаях, в которых вам нужно посылать экземпляру рабочего процесса более одного сообщения. В исходный код, который можно скачать для этой статьи, я включил пример хоста и рабочего процесса, где используются локальные коммуникации и где предпринята попытка инкапсулировать большинство сложностей в классы, а также приложение-пример, демонстрирующий, как работать с этим хостом.

Пара слов о взаимодействии с UI

Одна из основных причин, по которой вы передаете данные от рабочего процесса хосту, заключается в том, что вам нужно представить их пользователю в UI приложения. К счастью, в этой модели у вас есть некоторые варианты в использовании преимуществ UI-средств, в том числе связывания с данными, поддерживаемого WPF. Вот простой пример. Если вы хотите использовать связывание с данными и обновлять UI, когда от рабочего процесса поступают какие-либо данные, то можете связать свой UI прямо с экземпляром сервиса хоста.

Ключ к использованию экземпляра сервиса как контекста данных для какого-либо окна в UI — этот экземпляр должен быть размещен как singleton-объект (объект, существующий только в одном экземпляре). В этом случае вы получаете доступ к экземпляру и можете использовать его в своем UI. Простой сервис хоста, показанный на рис. 10, обновляет свойство после приема информации от рабочего процесса и с помощью INotifyPropertyChangedInterface сообщает инфраструктуре связывания с данными немедленно получить изменения. Заметьте: атрибут ServiceBehavior указывает, что данный класс должен быть размещен как singleton. Если снова посмотреть на рис. 7, можно заметить, что ServiceHost создается не как тип, а как экземпляр класса.

Рис. 10. Реализация сервиса с применением INotifyPropertyChanged

[ServiceBehavior(InstanceContextMode=InstanceContextMode.Single)]
internal class HostService : IHostInterface, INotifyPropertyChanged
{
    public void OrderStatusChange(Order order, string newStatus,
        string oldStatus)
    {
        CurrentMessage = String.Format("Order status changed to {0}",
            newStatus);
    }

private string msg;

public string CurrentMessage {
get { return msg; }
set
    {
        msg = value;
        if (PropertyChanged != null)
            PropertyChanged(this, new PropertyChangedEventArgs(
                "CurrentMessage"));
        }
    }

    public event PropertyChangedEventHandler PropertyChanged;
}

Чтобы связать это значение, DataContext окна или конкретный элемент управления в окне можно изменять данными экземпляра. Экземпляр можно получить через свойство SingletonInstance класса ServiceHost:

HostService host = ((App)Application.Current).appHost.SingletonInstance as HostService;
  if (host != null)
    this.DataContext = host;

Теперь вы можете просто связать элементы в окне со свойствами объекта — как на примере этого TextBlock:

<TextBlock Text="{Binding CurrentMessage}" Grid.Row="3" />

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

В ожидании WF4

В WF4 введен ряд средств, которые еще больше упрощают локальные коммуникации через WCF. Основное из них — корреляция сообщений, независимая от протокола. То есть использовать идентификатор экземпляра рабочего процесса можно будет по-прежнему, но у вас появится новая возможность разрешить корреляцию сообщений на основе их содержимого. Поэтому, если в каждом из ваших сообщений содержится идентификатор заказа, клиента или чего-либо еще, вы можете определить сопоставления между этими сообщениями и обойтись без привязки, поддерживающей управление контекстом.

Кроме того, WPF и WF в .NET Framework 4 опираются на одни и те же базовые XAML API, что открывает некоторые интересные возможности для интеграции этих технологий новыми способами. Поближе к дате выпуска .NET Framework 4 я обязательно подробно расскажу об интеграции WF с WCF и WPF, а также о внутреннем устройстве WF4.            

Мэтт Милнер (Matt Milner) — сотрудник Pluralsight, специализирующийся на технологиях подключенных систем (Windows WF, WCF, BizTalk, «Dublin» и Azure Services Platform). Также является независимым консультантом по проектированию и разработке приложений для Microsoft .NET. Регулярно выступает на местных, региональных и международных конференциях, например на Tech-Ed.Обладатель награды MVP в области подключенных систем. С ним можно связаться через его блог pluralsight.com/community/blogs/matt.