Хабрахабр

Workflow Core — движок бизнес-процессов для .Net Core

image

Всем привет!

Net Core, которую начали коллеги из DIRECTUM, поскольку столкнулись с аналогичной задачей пару лет назад и пошли собственным путем. Мы решили поддержать тему миграции проекта, использующего Windows Workflow Foundation на .

Начнем с истории

Управлять доступом он умеет как автоматически на основании ролевой модели, так и по заявкам. Наш флагманский продукт Avanpost IDM — это система управления жизненным циклом учетных записей и правами доступа работников. На заре становления продукта у нас была достаточно простая система самообслуживания с простым пошаговым workflow, для которого движок не требовался в принципе.

image

Проанализировав требования, мы решили разработать собственный редактор процессов в формате BPMN, подходящий под наши нужды. Однако, столкнувшись с крупными клиентами, мы поняли, что требуется гораздо более гибкий инструмент, поскольку их требования к процессам согласования прав доступа стремились к правилам хорошего развесистого документооборота. Про разработку редактора с использованием React.js + SVG мы расскажем чуть позже, а сегодня обсудим тему бэкенда — workflow engine или движка бизнес-процессов.

Требования

На момент начала разработки системы у нас были следующие требования к движку:

  • Поддержка схем процессов, понятный формат, возможность трансляции из нашего формата в формат движка
  • Хранение состояния процесса
  • Поддержка версионирования процессов
  • Поддержка параллельного выполнения (веток) процесса
  • Подходящая лицензия, позволяющая использовать решение в тиражируемом коммерческом продукте
  • Поддержка горизонтального масштабирования

Net: Windows Workflow Foundation. Проанализировав рынок (на 2014 год), мы остановились на фактически безальтернативном решении для .

Windows Workflow Foundation (WWF)

WWF представляет собой технологию компании Microsoft для определения, выполнения и управления рабочими процессами.

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

Скомпилированная схема бизнес-процесса хранится в Хaml, что весьма удобно — формат описан, есть возможность сделать самописный дизайнер процессов. Процесс можно рисовать непосредственно в среде Visual Studio. А с другой — Xaml не самый удобный формат хранения описания — скомпилированная схема для более менее реального процесса получается огромной не в последнюю очередь из-за избыточности. Это с одной стороны. Разобраться в ней очень сложно, а разбираться придется.

Когда ошибка исходит и недр Wf, узнать на 100%, в чем именно была причина сбоя, удается не всегда. Но если со схемами рано или поздно можно постичь дзен и научится читать их, то вот отсутствие прозрачности работы самого движка добавляет хлопот уже во время эксплуатации системы пользователями. Часто фиксить баги приходилось по симптомам. Закрытость исходников и относительная монструозность делу не помогает.

Кто-нибудь из читателей точно скажет, что мы сами создали себе кучу проблем, а потом героически их решали. Справедливости ради тут стоит уточнить, что проблемы, описанные выше, по большей части преследовали нас из-за сильной кастомизации поверх Wf. В целом, они будут правы. Нужно было делать самописный движок с самого начала.

Но переход наших продуктов под . В сухом остатке решение заработало достаточно стабильно и успешно ушло в продакшн. Windows Workflow Foundation по состоянию на май 2019 года так и не был переведен на . Net Core вынудил нас отказаться от WWF и искать другой движок бизнес-процессов, т.к. Как мы искали новый движок — тема отдельной статьи, но в итоге мы остановились на Workflow Core. Net Core.

Workflow Core

Он разрабатывается под лицензией MIT, т.е его можно спокойно использовать в коммерческой разработке. Workflow Core — это свободно распространяемый движок бизнес-процессов.

Есть порты на другие языки (Java, Python и еще несколько). Активно делает его один человек, еще несколько периодически делают pull request.

По сути это просто некий хост для последовательного выполнения сгруппированных по каким-либо бизнес-правилам действий. Движок позиционируется как легковесный.

К сожалению, она описывает далеко не все возможности движка. У проекта есть документация в виде wiki. Поэтому Wiki вполне будет вполне достаточно для начала работы. Однако требовать полноценную документацию будет нагло — проект opensource, поддерживается одним энтузиастом.

Стандартно идут провайдеры для: «Из коробки» есть поддержка хранения состояния процессов во внешних хранилищах (persistence storage).

  • MongoDB
  • SQL Server
  • PostgreSQL
  • Sqlite
  • Amazon DynamoDB

Берем исходники любого стандартного и делаем по примеру. Написать свой провайдер не составляет проблем.

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

  • Azure Storage Leases
  • Redis
  • AWS DynamoDB
  • SQLServer (в исходниках есть, но в документации ничего не сказано)

Так и поступим. Знакомство с чем-то новым проще всего начать с примера. Пример может показаться до невозможности простым. Я опишу с самого начала построение простого процесса, попутно давая свои пояснения. Самое то для начала. Соглашусь — он простой.

Поехали.

Step (Шаг)

Весь процесс строится из последовательности шагов. Шаг — это этап процесса, на котором выполняются какие либо действия. Есть набор шагов, которые наделены логикой «из коробки»: Один шаг может выполнять много действий, может выполняется повторно, например, по некоторому событию извне.

  • WaitFor
  • If
  • While
  • ForEach
  • Delay
  • Parallel
  • Schedule
  • Recur

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

public abstract class StepBody : IStepBody

В него и нужно разместить необходимую логику. Метод Run выполняется, когда процесс заходит в шаг.

public abstract class StepBody : IStepBody { public abstract ExecutionResult Run(IStepExecutionContext context); }

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

В wf core есть свой контекст выполнения процесса, который хранит информацию о его текущем состоянии. Очевидно, что процессу нужен свой контекст — место, куда можно складывать промежуточные результаты выполнения. В дополнение ко встроенному мы можем использовать свой контекст. Получить доступ к нему можно, используя переменную context из метода Run().

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

public class ProcessContext { public int Number1 {get;set;} public int Number2 {get;set;} public string StepResult {get;set;} public ProcessContext() { Number1 = 1; Number2 = 2; } }

В переменные Number запишем числа; в переменную StepResult — результат выполнения шага.

Можно писать свой шаг: С контекстом определились.

public class CustomStep : StepBody { private readonly Ilogger _log; public int Input1 { get; set; } public int Input2 { get; set; } public string Action { get; set; } public string Result { get; set; } public CustomStep(Ilogger log) { _log = log; } public override ExecutionResult Run(IStepExecutionContext context) { Result = ”none”; if (Action ==”sum”) { Result = Number1 + Number2; } if (Action ==”dif”){ Result = Number1 - Number2; } return ExecutionResult.Next(); } }

Результат операции записывается в выходную переменную Result. Логика крайне простая: на вход приходят два числа и название операции. Если операция не определена, то результат будет none.

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

Описание процесса. Регистрация в движке.

Первый — это описание в коде — хардкод. Описать процесс можно двумя способами.

Нужно отнаследоваться от обобщенного интерфейса IWorkflow<Т>, где T — класс контекста модели. Процесс описывается через fluent interface. В нашем случае это ProcessContext.

Выглядит это так:

public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { //тут будет описание процесса } public string Id => "SomeWorkflow"; public int Version => 1; }

Поля Id и Version также необходимо заполнить. Непосредственно описание будет внутри метода Build. Это удобно, когда требуется обновить существующий процесс и при этом дать «дожить» уже существующим задачам. Wf core поддерживает версионность процессов — можно зарегистрировать n версий процесса с одинаковым идентификатором.

Опишем простой процесс:

public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { builder.StartWith<CustomStep>() .Input(step => step.Input1, data => data.Number1) .Input(step => step.Input2, data => data.Number2) .Input(step => step.Action, data => “sum”) .Output(data => data.StepResult, step => step.Result) .EndWorkflow(); } public string Id => "SomeWorkflow"; public int Version => 1; }

Значение поля шага Input1 берется из поля контекста Number1, Значение поля шага Input2 берется из поля контекста Number2, полю Action жестко указано значение «sum». Если перевести на «человеческий» язык, получится примерно так: процесс начинается с шага CustomStep. Завершить процесс. Выходные данные из поля Result записывается в поле контекста StepResult.

Согласитесь, код получился весьма читаемый, вполне можно разобраться, даже не имея особых познаний в C#.

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

public class CustomStep : StepBody { private readonly Ilogger _log; public string TextToOutput { get; set; } public CustomStep(Ilogger log) { //получили логгер из контейнера _log = log; } public override ExecutionResult Run(IStepExecutionContext context) { _log.Debug(TextToOutput); return ExecutionResult.Next(); } }

И обновим процесс:

public class SimpleWorkflow : IWorkflow<ProcessContext> { public void Build(IWorkflowBuilder<ProcessContext> builder) { builder.StartWith<CustomStep>() .Input(step => step.Input1, data => data.Number1) .Input(step => step.Input2, data => data.Number2) .Input(step => step.Action, data => “sum”) .Output(data => data.StepResult, step => step.Result) .Then<OutputStep>.Input(step => step.TextToOutput, data => data.StepResult) .EndWorkflow(); } public string Id => "SomeWorkflow"; public int Version => 2; }

На вход мы передаем переменную Result и контекста, в которую на прошлом шаге записали результат выполнения. Теперь после шага с операцией сложения следует шаг вывода результата в лог. Разве что для каких-то служебных процессов. Возьму на себя смелость утверждать, что подобное описание через код (хардкод) в реальных системах будет малополезным. Как минимум, нам не придется пересобирать проект каждый раз, когда нужно что-то поменять в процессе или добавить новый. Куда более интересно иметь возможность хранить схему отдельно. Продолжим расширять наш пример. Эту возможность wf core предоставляет посредством хранения схемы json.

Json описание процесса

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

На мой взгляд, json более нагляден чем xaml (хорошая тема для холивара в комментариях 🙂 ). Wf core поддерживает описание схемы в json. Структура файла довольно простая:

{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { /*step1*/ }, { /*step2*/ } ]
}

В Steps хранится коллекция всех шагов процесса. В поле DataType указывается полное имя класса контекста и имя сборки, в которой он описан. Заполним элемент Steps:

{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { "Id": "Eval", "StepType": "App.CustomStep, App", "NextStepId": "Output", "Inputs": { "Input1": "data.Number1", "Input2": "data.Number2" }, "Outputs": { "StepResult": "step.Result" } }, { "Id": "Output", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "data.StepResult" } } ]
}

Давайте разберем подробнее структуру описание шага через json.

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

Дальше интереснее — объекты Inputs и Outputs. StepType аналогичен полю DataType, содержит полное имя класса шага (тип, который наследуется от StepBody и реализует логику шага) и название сборки. Они задаются в виде маппинга.

В случае Inputs имя элемента json — это имя поля класса нашего шага; значение элемента — имя поля в классе — контексте процесса.

Для Outputs наоборот, имя элемента json — это имя поля в классе — контексте процесса; значение элемента — имя поля класса нашего шага.

Потому что wf core значение элемента выполняет как C# выражение (используется библиотека Dynamic Expressions). Почему поля контекста указываются именно через data.{имя_поля}, а в случае Outputstep.{имя_поля}? Это довольно полезная вещь, с ее помощью можно закладывать некоторую бизнес-логику непосредственно внутри схемы, если, конечно, архитектор одобрит такое безобразие:).

Добавим условный шаг If и обработку внешнего события. Разнообразим схему стандартными примитивами.

If

Тут начинаются сложности. Примитив If. По документации шаг описывается следующим образом: Если вы привыкли к bpmn и рисуете процессы в этой нотации, то вас ждет легкая подстава.

{ "Id": "IfStep", "StepType": "WorkflowCore.Primitives.If, WorkflowCore", "NextStepId": "nextStep", "Inputs": { "Condition": "<<expression to evaluate>>" }, "Do": [ [ { /*do1*/ }, { /*do2*/ } ] ]
}

У меня есть. Нет ощущения, что что-то тут не так? Дальше задаем список шагов внутри массива Do (действия). На вход шага задается Condition — выражение. Почему нет массива Do для False? Так, а где ветка False? Подразумевается, что ветка False — это просто проход дальше по процессу, т.е по указателю в NextStepId. На самом деле есть. Окей, тут разобрались. Первое время я постоянно путался из-за этого. Если действия по процессу в случае True нужно класть внутрь Do, это же какой «красивый» json тогда будет. Хотя нет. Все уедет вбок. А если там этих If вложенных с десяток? Есть небольшой хак. А еще говорят, что схему на xaml трудно читать. Немного выше упоминалось, что порядок шагов в коллекции значение не имеет, переход идет по указателям. Просто взять монитор пошире. Добавим еще один шаг: Это можно использовать.

{ "Id": "Jump", "StepType": "App.JumpStep, App", "NextStepId": ""
}

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

Обновим нашу схему:

{ "Id": "SomeWorkflow", "Version": 1, "DataType": "App.ProcessContext, App", "Steps": [ { "Id": "Eval", "StepType": "App.CustomStep, App", "NextStepId": "MyIfStep", "Inputs": { "Input1": "data.Number1", "Input2": "data.Number2" }, "Outputs": { "StepResult": "step.Result" } }, { "Id": "MyIfStep", "StepType": "WorkflowCore.Primitives.If, WorkflowCore", "NextStepId": "OutputEmptyResult", "Inputs": { "Condition": "!String.IsNullOrEmpty(data.StepResult)" }, "Do": [ [ { "Id": "Jump", "StepType": "App.JumpStep, App", "NextStepId": "Output" } ] ] }, { "Id": "Output", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "data.StepResult" } }, { "Id": "OutputEmptyResult", "StepType": "App.OutputStep, App", "Inputs": { "TextToOutput": "\"Empty result\"" } } ]
}

Если не пустой, то выводим результат, если пустой — то сообщение «Empty result». В шаге If проверяется, пустой ли результат выполнения шага Eval. Таким образом, мы сохранили «вертикальность» схемы. Шаг Jump переводит процесс в шаг Output, который находится вне коллекции Do. организовывать цикл. Также таким способом можно переходить по условию на n шагов назад, т.е. В bpmn, например, циклы организуются через If. В wf core есть встроенные примитивы для циклов, но они не всегда удобны.

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

WaitFor

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

Структура примитива:

{ "Id": "Wait", "StepType": "WorkflowCore.Primitives.WaitFor, WorkflowCore", "NextStepId": "NextStep", "CancelCondition": "If(cancel==true)", "Inputs": { "EventName": "\"UserAction\"", "EventKey": "\"DoSum\"", "EffectiveDate": "DateTime.Now" }
}

Немного поясню параметры.

Предоставляет возможность прервать ожидание события и пойти дальше по процессу. CancelCondition — условие прерывания ожидания. Добавляем в переменные контекста логический флаг и при получении события выставляем флаг в значение true — все шаги WaitFor завершатся. Например, если процесс одновременно ждет n разных событий (wf core поддерживает параллельное выполнение шагов), ждать прихода всех не требуется, в этом случае нам поможет CancelCondition.

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

Может пригодится в случае, если нужно опубликовать событие «в будущее». EffectiveDate — опциональное поле, добавляет событию метку времени. Чтобы оно опубликовалось сразу параметр можно оставить пустым или задать текущее время.

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

public class CustomStep : StepBody { private readonly Ilogger _log; public string TextToOutput { get; set; } public CustomStep(Ilogger log) { _log = log; } public override ExecutionResult Run(IStepExecutionContext context) { //какая-то логика return ExecutionResult.WaitForEvent("eventName", "eventKey", DateTime.Now); } }

Он принимает на вход уже упомянутые ранее параметры EventName, EventKey и EffectiveDate. Мы воспользовались стандартным методом расширения WaitForEvent(). Однако в текущем виде мы не можем различить моменты первичного входа в шаг и вход после события. После выполнения логики такого шага процесс встанет в ожидание описанного события и снова вызовет метод Run() в момент публикации события в шине движка. И в этом нам поможет флаг EventPublished. А хотелось бы как-то разделить логику до-после на уровне шага. Он находится внутри общего контекста процесса, получить его можно так:

var ifEvent=context.ExecutionPointer.EventPublished;

Опираясь на этот флаг можно спокойно разделить логику на до и после внешнего события.

Для некоторых задач это весьма неприятное ограничение. Важное уточнение — по задумке создателя движка один шаг может быть подписать только на один эвент и среагировать на него один раз. Сейчас в этой статье их описание пропустим, иначе статья никогда не закончится :). Нам даже пришлось «допиливать» движок, чтобы от этого нюанса уйти. Более сложные практики использования и примеры доработок будут освещаться в последующих статьях.

Регистрация процесса в движке. Публикация события в шину.

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

Воспользуемся стандартным методом расширения AddWorkflow(), который разместит в нашем IoC контейнере свои зависимости.

Выглядит он так:

public static IServiceCollection AddWorkflow(this IServiceCollection services, Action<WorkflowOptions> setupAction = null)

Он живет внутри DI от Microsoft (подробнее про него можно почитать тут) IServiceCollection — интерфейс — контракт коллекции описаний сервисов.

Самому их задавать не обязательно, стандартные значение вполне приемлемы для первого знакомства. WorkflowOptions — базовые настройки движка. Едем дальше.

Если процесс описывался в коде, то регистрация происходит так:

var host = _serviceProvider.GetService<IWorkflowHost>();
host.RegisterWorkflow<SomeWorkflow, ProcessContext>();

Если процесс описан через json, то его нужно регистрировать так (само собой, json описание нужно предварительно загрузить из места хранения):

var host = _serviceProvider.GetService<IWorkflowHost>();
var definitionLoader = _serviceProvider.GetService<IDefinitionLoader>();
var definition = loader.LoadDefinition({*json описание процесса*});

Далее для обоих вариантов код будет одинаков:

host.Start(); // запускаем хостинг процессов в движке
host.StartWorkflow(defenitionId, version, context); //запускаем процесс по загруженному ранее описанию
///
host.Stop(); / /останавливаем хостинг процессов в движке

То, что записано в поле Id процесса. Параметр defenitionId — идентификатор процесса. В данном случае идентификатор = SomeWorkflow.

Движок предоставляет возможность регистрировать сразу n версий процесса с одним идентификатором. Параметр version указывает, какую версию процесса запустить. Это удобно, когда требуется внести изменения в описание процесса, не ломая уже запущенные задачи — новые будут создаваться по новой версии, старые спокойно доживут на старой.

Параметр context — экземпляр контекста процесса.

Start() и host. Методы host. Если в приложении запуск процессов — прикладная задача и выполняется периодически, то следует останавливать хостинг. Stop() запускают и останавливают хостинг процессов. Если приложение имеет основным направлением выполнение различных процессов, то хостинг можно не останавливать.

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

Task PublishEvent(string eventName, string eventKey, object eventData, DateTime effectiveDate = null);

часть про WaitFor примитив). Описание его параметров было выше в статье (см.

Заключение

И реальных практик использования wf core в рабочих системах (кроме нашей) вы, скорее всего, не найдете. Мы определенно рисковали, когда приняли решение в пользу Workflow Core — opensource проекта, который активно разрабатывает один человек, да еще и с весьма бедной документацией. Конечно, выделив отдельный слой абстракций, мы подстраховались на случай неуспеха и необходимости быстро вернутся к WWF, например, или самописному решению, но все пошло вполне неплохо и неуспех не настал.

Самая главная из них — это, конечно же, поддержка . Переход на opensource движок Workflow Core решил определенное количество проблем, которые мешали нам спокойно жить на WWF. Net Core и отсутствие таковой, даже в планах, у WWF.

Работая с WWF и получая разнообразные ошибки из его недр, возможность хотя бы почитать исходники была бы очень кстати. Следом идет открытость исходников. Тут с Workflow Core полная свобода (в том числе по лицензированию — MIT). Не говоря уже о том, чтобы что-то в них поменять. Да просто возможность запустить движок в режиме отладки с точками останова уже сильно облегчает процесс. Если вдруг появляется ошибка из недр движка, просто качаем исходники из github и спокойно ищем причину ее возникновения.

Нам пришлось внести ощутимое количество изменений в ядро движка. Само собой, решив одни проблемы, Workflow Core принес уже свои, новые. Работы по «допиливанию» под себя обошлись дешевле по времени, чем разработка собственного движка с нуля. Но. Итоговое решение получилось вполне приемлемо по скорости и стабильности работы, позволило нам на текущий момент забыть про проблемы с движком и сфокусироваться на развитии бизнес-ценности продукта.

S. P. Если тема окажется интересной, то будут еще статьи по про wf core, с более глубоким анализом работы движка и решениями относительно сложных бизнес-задач.

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть