Когда .NET разработчик слышит слова «В проект нужно добавить workflow», то первым приходит в голову идея взять Windows Workflow Foundation.
В 2010 году мы выбрали WF в качестве движка документооборота.
Аргументы просты:
- Бесплатно;
- Встроено в Visual Studio;
- В интернете много информации об использовании WF.
За полтора года (с августа 2010 по февраль 2012) использования WF мы столкнулись с массой разнообразных проблем при реализации требований клиента. В конечном итоге мы были вынуждены отказаться от Windows Workflow Foundation и сделать свою реализацию State Machine.
В этой статье я расскажу об основных проблемах, с которыми мы сталкивались, и как решали (или не решали).
Введение
На мой взгляд, есть две статьи, которые неплохо описывают применение WF в Document Approval System.
Для WWF 3: «Document approval workflow system»;
Для WWF 4: «Обзор Windows Workflow Foundation на примере построения системы электронного документооборота».
Описывают неплохо, но описывают только вершину айсберга.
Если кратко, в этих статьях рассказывается о том, как:
- Нарисовать схему;
- Как двигать документ по маршруту;
- Как указывать условия движения.
Реализация даже этих простых операций требует весьма существенных трудозатрат и не обходится без костылей и плясок с бубном. Плясали с этим бубном и мы.
В отличие от коллеги из Luxoft, я набрался смелости и выложил нашу реализацию модуля workflow на WWF 3.5 «as is» в публичный доступ.
URL: Budget.Server
По ссылке опубликованы исходники серверной части.
Серверная часть отвечает за документооборот и интеграцию с внешними системами.
Схемы workflow находятся в проекте Budget2.Workflow (Мы использовали WWF 3.5, но те же проблемы остались в WWF 4).
API для работы с workflow в файле: Budget.ServerAPIServicesWorkflowAPI.cs
Итак, поехали.
Как мы боролись с Workflow Foundation
Вы подключили WF к проекту, научились двигать документ по маршруту, указали условия смены статуса. О том, как это сделать, написано в статьях, которые я привел выше.
Дальше начинается самое интересное…
Получение списка доступных команд для пользователя
WF не поддерживает Commands и Actors (автор документа, начальник автора, контролер, менеджер).
Это нужно реализовывать самостоятельно. Причем, если в версии WWF 4 можно получить список Bookmarks, то в версии 3.5 этого сделать было нельзя и приходилось для каждого состояния список команд хранить отдельно.
Процитирую автора из вышеобозначенной статьи:
Кроме того, к каждой Общей активности отдельно сохраняется некоторый набор метаданных: привилегии для запуска, типы документов, по которым разрешено запускать активность, Dynamic LINQ – выражение к документу для проверки возможности запуска и другие.
Для каждой активности отдельно нужно указать набор метаданных, по которым потом нужно проверять доступ.
Всё правильно, мы точно так же делали.
Один раз это можно сделать, поддерживать это в актуальном состоянии затруднительно.
Получение списка входящих документов
Это требование мы реализовывать уже после того, как схема была реализована в WF.
Проблема была до банальности проста: мы могли определить, может ли пользователь на текущем этапе согласовать конкретный документ, но мы не могли получить список всех пользователей, которые могут согласовать документ на данном этапе. В системе порядка 300-400 пользователей, перебором проблему было не решить.
Это вынудило нас написать фильтр, который делал выборку документов, доступных для согласования текущем пользователем в зависимости от ролей пользователя, его места в иерархии подразделений, атрибутов документа и прочих параметров.
private string GetFilter()
{
List<Guid> deputyIds =
DeputyEmployeeRepository.GetReplaceableEmployeeIdentityIds(DocumentType.BillDemand,EmployeeRepository.CurrentEmployee,
true);
string idsString = StringUtil.GetString(deputyIds);
string opSubfilter =
string.Format(
"SELECT dbd.{0} FROM {1} dbd INNER JOIN {2} ON dbd.{0} = {3} INNER JOIN {4} ON {5} = {6} LEFT OUTER JOIN {7} ON dbd.{0} = {8} AND {9} = {10} WHERE {11} IS NULL",
BillDemandTableBase.SelectColumn_Id,
BillDemandTableBase.DEFAULT_NAME,
BillDemandDistributionTableBase.DEFAULT_NAME,
BillDemandDistributionTableBase.FilterColumn_BillDemandDistribution_BillDemandId,
DemandTableBase.DEFAULT_NAME,
BillDemandDistributionTableBase.FilterColumn_BillDemandDistribution_DemandId,
DemandTableBase.FilterColumn_Demand_Id,
WorkflowSightingTableBase.DEFAULT_NAME,
WorkflowSightingTableBase.FilterColumn_WorkflowSighting_EntityId,
DemandTableBase.FilterColumn_Demand_ExecutorStructId,
WorkflowSightingTableBase.FilterColumn_WorkflowSighting_ItemId,
WorkflowSightingTableBase.FilterColumn_WorkflowSighting_Id
);
string limitSubfilter =
string.Format(
"SELECT {0} FROM {1} WHERE {2} = {3} AND {4} = {5} AND {6} IS NULL AND {7} IN ({8}) ",
WorkflowSightingTableBase.SelectColumn_Id, WorkflowSightingTableBase.DEFAULT_NAME,
BillDemandTableBase.FilterColumn_BillDemand_Id,
WorkflowSightingTableBase.FilterColumn_WorkflowSighting_EntityId,
WorkflowSightingTableBase.SelectColumn_StateId,
BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
WorkflowSightingTableBase.SelectColumn_SightingTime,
WorkflowSightingTableBase.SelectColumn_SighterId, idsString);
string filter =
string.Format(
"({0} IN ({1},{2}) AND {3} IN ({4})) OR ( EXISTS ({5}) )",
BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int) BillDemandStateEnum.Draft,
(int) BillDemandStateEnum.PostingAccounting,
BillDemandTableBase.FilterColumn_BillDemand_AuthorId,
idsString,
limitSubfilter);
if (SecurityHelper.IsPrincipalsInRole(deputyIds, SecurityHelper.ControllerRoleId))
filter += string.Format(" OR {0} = {1}", BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int) BillDemandStateEnum.UPKZControllerSighting);
if (SecurityHelper.IsPrincipalsInRole(deputyIds, SecurityHelper.CuratorRoleId))
filter += string.Format(" OR {0} = {1}", BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int) BillDemandStateEnum.UPKZCuratorSighting);
if (SecurityHelper.IsPrincipalsInRole(deputyIds, SecurityHelper.UPKZHeadRoleId))
filter += string.Format(" OR {0} = {1}", BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int) BillDemandStateEnum.UPKZHeadSighting);
if (SecurityHelper.IsPrincipalsAccountant(deputyIds, BudgetPart))
{
if (CommonSettings.CheckAccountingInFilial)
{
filter += string.Format(" OR ({0} = {1} AND {2} = '{3}')",
BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int) BillDemandStateEnum.InAccounting,
BillDemandTableBase.FilterColumn_BillDemand_FilialId,
EmployeeRepository.CurrentEmployeeFilialId);
}
else
{
filter += string.Format(" OR ({0} = {1})",
BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int)BillDemandStateEnum.InAccounting
);
}
}
List<Guid> deputyDivisionHeads = SecurityHelper.GetPrincipalsDivisionHead(deputyIds, BudgetPart);
if (deputyDivisionHeads.Count > 0)
{
string currentEmployeeChildrenStructs = EmployeeRepository.CurrentEmployeeChildrenStructs.Replace("(", "").Replace(")", "");
string deputyDevisionHeadString = StringUtil.GetString(deputyDivisionHeads);
filter +=
string.Format(
" OR ({0} = {1} AND EXISTS (SELECT vp.Id FROM [dbo].[vStructDivisionParentsAndThis] vp INNER JOIN {2} e ON vp.ParentId = e.{3} WHERE vp.Id = {4} AND e.{5} IN ({6})))",
BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int)BillDemandStateEnum.HeadInitiatorSighting,
EmployeeRepository.DEFAULT_NAME,
EmployeeRepository.SelectColumn_StructDivisionId,
BillDemandTableBase.FilterColumn_AuthorStructDivision_Id,
EmployeeRepository.SelectColumn_SecurityTrusteeId,
deputyDevisionHeadString
);
filter +=
string.Format(
" OR ({0} = {1} AND {2} > 0 AND EXISTS ({3} AND {4} IN ({5}) AND dbd.{6} = {7}))",
BillDemandTableBase.FilterColumn_BillDemand_BillDemandStateId,
(int) BillDemandStateEnum.LimitManagerSighting,
BillDemandTableBase.FilterColumn_BillDemand_BudgetPartId, opSubfilter,
DemandTableBase.FilterColumn_Demand_ExecutorStructId,
currentEmployeeChildrenStructs,
BillDemandTableBase.SelectColumn_Id,
BillDemandTableBase.FilterColumn_BillDemand_Id);
}
return filter;
}
На эти фильтры у нас ушло 2 недели.
Версионирование схем
Встроенных механизмом обновления схемы процесса в Windows Workflow Foundation 3.5 нет.
В WF 4 ситуация не изменилась — Version handling in Workflow Foundation 4.
Если процесс запущен, то обновить схему марш просто так не получиться. Для обновления схемы нужно иметь старую схему и немножко потанцевать с бубном. Плясали примерно неделю-две, но сделали более или менее работающий механизм обновления схем. Теперь наш проект регулярно пополнялся DDL с наименованиями Workflow.xxx.dll, где xxx — это номер старой версии.
История согласования… с перечислениями будущих этапов
Реализация истории согласования вещь тривиальная. Нужно сохранять в табличку информацию кто, когда и на какую кнопку нажал. Но простая история согласования клиента не устраивала.
Клиент захотел, чтобы система показывала список оставшихся для согласования этапов (будущие этапы) и для каждого такого этапа через запятую перечислять список пользователей, которые могут документ согласовать.
На этом танцы бубном вокруг WF нам надоели. Стали думать, как бы нам расстаться с...WF.
К слову сказать, сейчас мы эту задачку решаем на раз-два: в нашем продукте есть специальный режим — Pre-Execution mode. Который позволяет сделать холостой прогон по маршруту и сформировать будущие этапы и потенциальных согласователей.
«Дайте нам дизайнер»
Дать клиенту дизайнер от WF, по понятным причинам, мы не могли. Не помню как, но как-то убедили клиента, что не стоит на данном этапе этого делать.
Динамическое добавление состояний в схему
Через год клиент захотел, чтобы в маршрут документа по некоторым условиям добавлялись новые состояния из специального справочника.
Мы не смогли найти ни одного примера, где показывался бы механизм генерации схемы процесса. Поэтому не стали даже пытаться это сделать. Попросили клиента подождать пару месяцев, пока мы будем мигрировать с WF на нашу разработку. Клиент отнеся с пониманием. За что большое ему спасибо.
Если кто-то реализовывал подобный кейс на WF, поделитесь примером, очень интересно на это посмотреть.
Поддержка
После успешного внедрения развитие системы не остановилось. Новые требования поступали регулярно.
Мы вносили изменения с схему маршрута и после каждого обновления нам приходило баги из серии:
- Почему пользователь не видит документ, который должен согласовать?
- Почему пользователь видит документ, но не может согласовать?
- Почему пользователь согласует документ, а у него ошибка вылетает?
Это типичная ситуация для случаев, где логика дублируется (часть условий в WF, часть в метаданных, часть в SQL фильтре для входящих).
К этому добавилось то, что WF выдавал непонятные ошибки, которые однозначно нельзя было интерпретировать. Несколько раз приходилось выезжать на площадку к клиенту.
Подведем итоги
Если вы делаете информационную систему, где есть функционал согласования, то с вероятность 99% вы столкнетесь с большинством из перечисленных выше случаев. Реализовывать это на WF может позволить себе не каждая компания. Не каждый заказчик за это будет готов платить.
Для себя мы сделали выбор — написали свой движок Workflow Engine .NET и успешно его применяем в своих проектах.
В нем мы учли наш опыт реализации систем класса — Document Approval System.
Автор: DmitryMelnikov