Обзор Windows Workflow Foundation на примере построения системы электронного документооборота [Часть 2]

от автора

Разработка системы документооборота – неподъемная задача для небольшой команды?


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

Итак, стоит задача реализовать систему электронного документооборота.


Для удобства разобью эту часть на несколько блоков.

Ведение списков документов

Данный модуль был реализован с помощью технологии ASP.NET MVC. Позволяет создавать и редактировать различные типы документов, запускать потоки работ над ними. Обозначим его здесь, чтобы можно было ссылаться в будущем, но подробно рассматривать не будем.

Будем считать, что мы имеем классы-документы, реализующие примерно следующий общий интерфейс*:

/// <summary> /// Интерфейс документа /// </summary> public interface IDocument {     /// <summary>     /// Ключ документа     /// </summary>     int DocumentId { get; set; }       /// <summary>     /// Автор документа     /// </summary>     User Author { get; set; }       /// <summary>     /// Ключ автора документа     /// </summary>     int AuthorId { get; set; } } 

*Здесь и далее для упрощения используется сильно урезанная версия production-кода.

Общие активности

Пользователи запускают активности по работе с документами (далее – Общие активности). Общие активности имеют 2 входных аргумента:

/// <summary> /// Интерфейс потока работ по документам /// </summary> public interface IDocumentWorkflow {     /// <summary>     /// Ключ документа (входной аргумент)     /// </summary>     InArgument<int> DocumentId { get; set; }      /// <summary>     /// Ключ пользователя, запустившего поток (входной аргумент)     /// </summary>     InАrgument<int> UserId { get; set; } } 

, здесь DocumentId – ключ документа, а UserId – ключ пользователя, запускающего активность.

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

Кроме того, к каждой Общей активности отдельно сохраняется некоторый набор метаданных: привилегии для запуска, типы документов, по которым разрешено запускать активность, Dynamic LINQ – выражение к документу для проверки возможности запуска и другие.

Общая активность – настраиваемая часть нашей системы – кодируется дизайнером потоков работ и сохраняется в виде XAML. Реализацию дизайнера рассмотрим ниже.

Далее приведу код по запуску активности в среде выполнения WF.

/// <summary> /// Запускает рабочий поток по документу в среде выполнения /// </summary> /// <typeparam name="T">Тип Общей активности</typeparam> /// <param name="documentId">Ключ документа</param> /// <param name="userId">Ключ пользователя</param> public static void StartDocumentWorkflow<T>(int documentId, int userId)     where T: Activity, IDocumentWorkflow, new() {     //создадим экземпляр потока работ и передадим его вместе с параметрами для запуска среде выполнения     var wfApp = new WorkflowApplication(new T(), new Dictionary<string, object>() {                  { "DocumentId", documentid},                 { "UserId", userId}});     //указываем хранилище для сохранения состояний     wfApp.InstanceStore = new SqlWorkflowInstanceStore(ApplicationData.ConnectionString);     wfApp.PersistableIdle = (e) =>     {         //в случае, если поток бездействует – сохранить и выгрузить         return PersistableIdleAction.Unload;     };     wfApp.Completed = (completeArg) =>     {         //поток работ завершен     };     wfApp.Aborted = (abortArg) =>     {         //поток прерван     };     wfApp.OnUnhandledException = (exceptionArg) =>     {         //при выполнении возникла ошибка         return UnhandledExceptionAction.Abort;     };     wfApp.Run(); } 

Тут требует пояснения один момент: для того, чтобы поток работ мог сохранить свое состояние в случае необходимости, среде выполнения с помощью Property Injection передается экземпляр класса хранилища SqlWorkflowInstanceStore.

Передача аргументов в поток работ

Итак, Общая активность запущена и работает. Но в какой-то момент ей могут понадобиться дополнительные данные от пользователей системы для своей работы. На основе этих данных в соответствии со своей логикой она выбирают ветку исполнения, изменяет свойства документа, производят отправку уведомлений, а также другие действия.

Следовательно, необходимо описать эти данные (далее – Аргументы). Здесь было 2 варианта. Описать один универсальный тип (коллекцию “ключ – значение”), который бы использовался при передаче любой информации в поток работ. Но я выбрал преимущества строгой типизации и реализовал динамически подгружаемую библиотеку пользовательских Аргументов. Классы реализуют интерфейс IAssignArgument:

/// <summary> /// Интерфейс Аргумента для передачи в Общую активность /// </summary> public interface IAssignArgument {     /// <summary>     /// Ключ пользователя, передающего Аргумент     /// </summary>     int UserId { get; set; } }  /// <summary> /// Базовый абстрактный класс Аргумента для передачи в Общую активность /// </summary> public abstract class AssignArgumentBase : IAssignArgument {     /// <summary>     /// Ключ пользователя, передающего Аргумент     /// </summary>     [DisplayName("Ключ пользователя")]     [EditorBrowsable(EditorBrowsableState.Never)]     public int UserId { get; set; } }  [DisplayName("Ознакомиться")] [Category("Общие")] public sealed class Learn : AssignArgumentBase { }  [DisplayName("Заполнить номер, дату, файл")] [Category("Общие")] public sealed class EnterNumberDateFile : AssignArgumentBase {     [Required, DisplayName("Номер документа")]     public string Number  {get; set;}      [Required, DisplayName("Дата документа")]     public DateTime Date {get; set;}      [FileId, Required, DisplayName("Файл")]     public int? FileId {get; set;} } 

, где UserId – ключ пользователя, производящего передачу Аргумента в поток работ.

Теперь перейдем к самой активности, которая ожидает получения входных данных. Для этих целей в базовой библиотеке активностей предусмотрен класс NativeActivity<T>, где T – результат работы активности – наш Аргумент. Унаследуем наш класс от NativeActivity:

[DisplayName("Действие ...")] [Category("Документы")] [Designer("DocWorkflow.Activities.Designer.GenericActivityDesigner, DocWorkflow.Activities.Designer")] public class AssignDocumentActivity<T> : NativeActivity<T>     where T : class, IAssignArgument, new() {     /// <summary>     /// Результат работы активности     /// </summary>     [DisplayName("Результат")]     public new OutArgument<T> Result     {         get { return base.Result; }         set { base.Result = value; }     }      /// <summary>     /// Ключ назначаемого документа     /// </summary>     [DisplayName("Документ")]     [RequiredArgument]     public InArgument<int> DocumentId { get; set; }      /// <summary>     /// Ключ пользователя, на которого назначается задача     /// </summary>     [DisplayName("Ключ пользователя")]     [RequiredArgument]     public InArgument<int> UserId { get; set; }      /// <summary>     /// 1-й этап (до получения входных данных):      /// - сохранение данных о назначенной на пользователя задаче, информации для восстановления потока работ;      /// - создание закладки.     /// </summary>     protected override void Execute(NativeActivityContext context)     {         var bookmarkName = Guid.NewGuid().ToString();         using (var store = new DataStore())         {             store.Add(new AssignedDocumentInfo()             {                 UserId = context.GetValue(this.UserId),                 WorkflowInstanceId = context.WorkflowInstanceId.ToString(),                 ActivityInstanceId = context.ActivityInstanceId.ToString(),                 WorkflowType = context.GetExtension<WorkflowInstInfo>().GetProxy().WorkflowDefinition.GetType().FullName,                 ArgumentType = typeof(T).FullName,                 BookmarkName = bookmarkName,                 DocumentId = context.GetValue(this.DocumentId),                 ActivityName = this.DisplayName,                 AssignedDate = DateTime.Now             });         }         context.CreateBookmark(bookmarkName, new BookmarkCallback(this.Continue));     }      /// <summary>     /// 2-й этап (при получении входных данных):      /// - удаление данных о назначенной задаче из хранилища;      /// - установка значения выходного аргумента.     /// </summary>     protected void Continue(NativeActivityContext context, Bookmark bookmark, object obj)     {         using (var store = new DataStore())         {             foreach (var item in store.AssignedDocumentInfo.Where(aa =>                 aa.WorkflowInstanceId == context.WorkflowInstanceId.ToString()                 && aa.ActivityInstanceId == context.ActivityInstanceId.ToString()                 && aa.UserId == context.GetValue(this.UserId)                 && aa.BookmarkName == bookmark.Name).ToArray())             {                 store.Remove(item);             }         }         Result.Set(context, (T)obj);     } } 

Как видно, эта активность на первом этапе сохраняет данные, необходимые для восстановления состояния в хранилище (ключ документа, ключ пользователя, на которого назначена задача, тип Общей активности, тип Аргумента, наименование действия, «закладку» для восстановления). На втором этапе получает входной Аргумент и передает его в out-параметр для использования в теле Общего потока.

Модуль работы с документами, в свою очередь, видит задачи, назначенные на пользователя, при запуске процесса восстановления потока работ динамически формирует view на основании типа Аргумента (при необходимости возможно задать custom view, конечно) получает Аргумент от пользователя и передает его для восстановления Общей активности.

Строгая типизация Аргументов (антипод – Argument[“Prop1”]) позволяет валидировать обращения к свойствам Аргумента на этапе построения активности в дизайнере.


Поток с нарушением правил приведения типов не запустится. То есть ошибки происходят на этапе компиляции, а не на этапе выполнения, буквально.

Далее приведу код по восстановлению потока работ.

public static void ResumeWorkflow<T>(int assignedDocumentInfoKey, T arg)     where T: IAssignArgument {     AssignedDocumentInfo assignedDocumentInfo = null;     using (var store = new DataStore())     {         //загружаем данные о сохраненной активности         assignedDocumentInfo = store.AssignedDocumentInfo             .Where(aa => aa.AssignedDocumentInfoId=assignedDocumentInfoKey).First();     } 	var activity = (Activity) Activator.CreateInstance(Type.GetType(assignedDocumentInfo.WorkflowType));     WorkflowApplication wfApp = new WorkflowApplication(activity);     wfApp.InstanceStore = new SqlWorkflowInstanceStore(ApplicationData.ConnectionString);     wfApp.PersistableIdle = (e) =>     {         return PersistableIdleAction.Unload;     };     //восстановим состояние Общей активности     wfApp.Load(new Guid(assignedDocumentInfo.WorkflowInstanceId));     //запускаем рабочей поток с сохраненной точки с передачей в него Аргумента     wfApp.ResumeBookmark(new Bookmark(assignedDocumentInfo.BookmarkName), arg); } 

Хотел бы разъяснить разницу между двумя видами свойств активности:

public class MyActivity<T> : CodeActivity {     public InArgument<int> UserId1 {get; set;}     public int UserId2 {get; set;} } 

В первом случае фактическое значение свойства (это также относится и к OutArgument<T> и Variable<T>) – участник процесса работы активности, может менять значение в процессе, сохранять и восстанавливать свое состояние в хранилище: элемент таблицы свойств активности в дизайнере – , атрибут активности в XAML – .

Во втором случае значение является константой – оно не меняется при работе активности. Удобно использовать для сохранения неизменяемых параметров активности в XAML: элемент таблицы свойств активности в дизайнере – , атрибут активности в XAML – .

Управление запуском дочерних активностей

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

/// <summary> /// Абстрактный базовый класс активности по обновлению одного свойства какого-либо объекта /// </summary> /// <typeparam name="T">Класс обновляемого объекта</typeparam> public abstract class ObjectSetPropertyActivity<T> : CodeActivity     where T : class {     public T Object { get; set; } }  /// <summary> /// Класс активности по обновлению одного свойства какого-либо объекта /// </summary> /// <typeparam name="T">Тип обновляемого объекта</typeparam> /// <typeparam name="TProperty">Тип обновляемого свойства</typeparam> public class ObjectSetPropertyActivity<T, TProperty> : ObjectSetPropertyActivity<T>     where T : class {     /// <summary>     /// Наименование свойства     /// </summary>     public string Property { get; set; }      /// <summary>     /// Новое значение обновляемого свойства (входной аргумент)     /// </summary>     [RequiredArgument]     public InArgument<TProperty> Value { get; set; }      protected override void Execute(CodeActivityContext context)     {         //обновление свойства объекта         typeof(T).GetProperty(Property).SetValue(Object, Value.Get(context), null);     } }    /// <summary> /// Активность, производящая обновление объекта в хранилище /// </summary> /// <typeparam name="T">Тип объекта для обновления</typeparam> /// <typeparam name="TKey">Тип ключа объекта</typeparam> public class ObjectUpdateActivity<T, TKey> : CodeActivity     where T : class {     public ObjectUpdateActivity()     {         this.Activities = new Collection<ObjectSetPropertyActivity<T>>();     }      /// <summary>     /// Ключ объекта для обновления (входной аргумент)     /// </summary>     public InArgument<TKey> Key { get; set; }      /// <summary>     /// Коллекция дочерних активностей по обновлению свойств объекта     /// </summary>     public Collection<ObjectSetPropertyActivity<T>> Activities { get; set; }      protected override void Execute(NativeActivityContext context)     {         if (this.Activities.Count > 0)         {             var store = new DocWorkflowDbContext();             var obj = store.LoadObject<T>(context.GetValue(Key));             var index = 0;             CompletionCallback callback = (context1, activityInstance) =>             {                 index++;                 //после завершения дочерней активности либо:                  //- запускаем следующую;                 //- сохраняем изменения всего объекта в хранилище.                 if (index < this.Activities.Count)                 {                     this.Activities[index].Object = obj;                     context1.ScheduleActivity(this.Activities[index], callback);                 }                 else                 {                     store.UpdateObject(obj);                     store.SaveChanges();                     store.Dispose();                 }             };             //передадим дочерней активности объект для обновления             this.Activities[index].Object = obj;             //запустим первую дочернюю активность             context.ScheduleActivity(this.Activities[index], callback);         }     } } 

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

Так эта активность может выглядеть в дизайнере.


Дизайнер потоков работ

Эта самая “вязкая” часть технологии, то есть, чтобы реализовать что-либо слегка выступающее за рамки, нам нужно «плясать и бить в бубен». тот самый

Дизайнер строится по технологии WPF.

Основными элементом тут является WorkflowDesigner – графическая область в которой происходит конструирование рабочего потока.

Каждой активности можно сопоставить свой design-объект (в VS даже есть специальный тип проекта для разработки дизайна WF-элемента – Activity Designer Library) с помощью атрибута [Designer]. То же касается и свойств активности – атрибут [Editor]. То есть, ничего нового в этой части, фактически.

Код приводить не буду, так как сложно выявить что-то одновременно выделяющееся и достаточно компактное. За то приведу несколько ссылок.

Здесь замечательный пример по реализации дизайнера. Это модель того, как созданные активности, закодированные в XAML, можно сразу же использовать как строительные элементы в новых активностях. Особенность примера в том, что изменение в исходной активности не приводит к изменению алгоритмов всех активностей, где используется исходная. К сожалению, эта “фича” нас не устраивала.

А здесь серия отличных постов по WF с примерами (в частности, можно найти образец по подмене аргументов универсального типа в дизайнере). В случае необходимости, нужно будет меньше «прыгать с бубном», но все равно придется.

Заключение

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

Большая работа была в итоге проделана? Большая. Но по зубам ли она для небольшой команды? Да, даже для команды из 3-х — 4-х человек. И в разумные сроки!

Надеюсь, информация изложенная в этом посте будет вам полезна. Если я что-то упустил, изложил недостаточно корректно, или у вас есть вопросы — добро пожаловать в комментарии!

ссылка на оригинал статьи http://habrahabr.ru/company/luxoft/blog/182746/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *