понедельник, 5 января 2009 г.

Реальный пример

Из предыдущего примера мы узнали, что при помощи Listma можно описать в xml диаграму состояний для бизнес-объекта, и после этого получить список доступных переходов и перевести бизнес объект в новое состояние. Вероятно, такого же эффекта можно было достичь и более скромными средствами. В чем же практическая ценность предлагаемого движка и как его использовать на практике?

Давайте рассмотрим реальный пример. Среди исходников проекта есть пример приложения BugTracker. Я создавал BugTracker, как "классическое" ASP.Net приложение, созданное при помощи "мышки и визарда", в том числе и для того, что-бы посмотреть насколько удобно (или нет) будет использовать Listma в таких вот "экстремальных" условиях.

Приложение BugTracker



Итак вернемся к нашему багтрэкеру. Он не так прост, как кажется сначала. Во-первых он позволяет вести баги и таски для нескольких проектов. Для каждого проекта может назначаться команда, а каждому члену команды - своя роль. В соответствии с ролью в команде проекта должны определяться права пользователя на те или иные действия с багом.
Во-вторых, мы хотели бы вести в багтрэкере различные типы записей. Сейчас их два: Task и Bug, но можно добавить новые типы, для этого есть специальная таблица в БД. Например, можно ввести две категории багов: Production Bug и Non production Bug. Разница между типами записей в их жизненном цикле, и это должно настраиваться в runtime. Данные же у всех записей одинаковые, и поэтому они представлены в приложении одной сущностью: Issue.
В-третьих, в разных проектах могут быть разные роли, и у каждой роли должен быть свой набор доступных действий при работе с Issue. Все это тоже должно настраиваться в рантайме.
Итак, мы имеем приложение с вот такой моделью домена:

(Да, да, вы не ошиблись, это Entity Fremework)
Все необходимые web формы мы создали при помощи стандартных ASP.NET компонентов и EntityDataSource-в.
Теперь нам необходимо, чтобы на форме редактирования Issue, в зависимости от Issue.Type, а также от роли текущего пользователя в проекте и от текущего состояния Issue.State, на форме должны отображаться различные кнопки-действя, а также должны становиться доступными или недоступными те или иные поля. Причем все это должно легко и непринужденно настраиваться в runtime, желательно без пересборки приложения. Вот он, здравствуй, ад разработчика!

Составляем диаграмы состояния для Task и Bug



Впрочем, у нас есть Listma. С чего начнем? Начнем с описания двух диаграм состояний, одна для Issue.Type == "Task", вторая для Issue.Type == "Bug". Диаграма состояний для Task будет включать в себя состояния: New, Assigned, Opened, Postponed, Rejected, Done. Диаграма состояний Bug включает состояния: New, Assigned, Opened, Resolved, Closed. Полностью диаграмы можно посмотреть в папке workflow проекта (файлы BugWorkflow.xml и TaskWorkflow.xml). Для каждого состояния описаны доступные переходы. Например:

<State Id="New" Title="New" Initial="true">
<Transition Id="Assign" Title="Assign" TargetState="Assigned" >
<Performers>
<Performer Role="Manager" />
<Performer Role="Owner" />
</Performers>
</Transition>
</State>

Элемент <Performers> задает роли, которым доступен данный переход. Тут следует заметить, что помимо командных ролей, мы вводим две контекстные роли: Owner - пользователь создавший запись (Issue.Owner) и Performer - пользователь назначенный исполнять задачу или баг (Issue.AssignedTo). Как это все работает, мы разберемся позже.
Сейчас для нас важно понять, что перечень элементов Transition в диаграме состояний должен превратиться в набор кнопок-действий на форме редактирования Issue. Впоследствии, просто изменяя xml файл, мы сможем менять поведение нашего багтрэкера.

Другой важный кусок xml диаграмы состояний, это описания доступа к UI элементам для каждого состояния:

<State Id="New" Title="New" Initial="true">
<UIPermissions>
<UIElement Name="Id">
<Permission Role="*" Level="Read" />
</UIElement>
<UIElement Name="Type">
<Permission Role="*" Level="Read" />
<Permission Role="Owner" Level="Write" />
</UIElement>
<UIElement Name="Short">
<Permission Role="*" Level="Read" />
<Permission Role="Owner" Level="Write" />
</UIElement>
<UIElement Name="Description">
<Permission Role="*" Level="Read" />
<Permission Role="Owner" Level="Write" />
</UIElement>
<UIElement Name="State">
<Permission Role="*" Level="Read" />
</UIElement>
<UIElement Name="Result">
<Permission Role="*" Level="Read" />
</UIElement>
<UIElement Name="CreateDate">
<Permission Role="*" Level="Write" />
</UIElement>
<UIElement Name="Project">
<Permission Role="*" Level="Read" />
</UIElement>
<UIElement Name="Owner">
<Permission Role="*" Level="Read" />
<Permission Role="Owner" Level="Write" />
</UIElement>
<UIElement Name="AssignedTo">
<Permission Role="*" Level="Read" />
<Permission Role="Manager" Level="Write" />
</UIElement>
</UIPermissions>
</State>

Для каждого элемента (в нашем случае их имена совпадают с именами свойств класса Issue, но это не принципиально) описан перечень ролей и уровень доступа для каждой из них. Уровень доступа определяется енумом:

public enum UIPermissionLevel
{
Hidden = 0,
Read = 1,
Write = 2
}

В Listma нет готовых механизмов для разграничения доступа к полям Winform или ASP webform, но есть все необходимое чтобы такой механизм реализовать для любого типа представления. Сейчас мы рассмотрели, как декларируются права доступа. Пример того, как реализовать механизм проверки ролей для ASP.Net мы рассмотрим позже.

Конфигурируем framework



После того, как созданы xml диаграмы состояний, необходимо сконфигурировать framework (файл /workflow/workflow.config):

<?xml version="1.0" encoding="utf-8" ?>
<ListmaConfiguration xmlns="urn:Listma:configuration"
StatechartDir="workflow"
DefaultPermissionLevel="Write">
<EntityWorkflow EntityType="Bug"
StatechartId="BugWorkflow"
StateMap="State" />
<EntityWorkflow EntityType="Task"
StatechartId="TaskWorkflow"
StateMap="State" />
</ListmaConfiguration>

Атрибут "StatechartDir" указывает путь к каталогу в котором LIstma ищет файлы диаграм состояния.
Элементы "EntityWorkflow" описывают два workflow для разных "EntityType" (несмотря на то, что оба будут применяться к одному и тому же типу - Issue).
Атрибуты "StatechartId" указывают идентификаторы диаграм состояний, которые должны совпадать с именами xml файлов этих диаграм.
Атрибуты "StateMap" определяют поле класса Issue, которое используется для хранения состояния.
Итак диаграмы состояний определены, framework сконфигурирован. Следующая задача разместить на форме Issue список доступных пользователю команд.

UserControl "Список действий"

Для размещения на форме Issue списка команд, которые доступны текущему пользователю в данном состоянии Issue, мы сделаем UserControl - WfActions.ascx. Контрол очень прост, представляет собой Repeater:

<asp:Repeater ID="Repeater1" runat="server" DataSourceID="WfActionsDataSource"
onitemcommand="Repeater1_ItemCommand">
<HeaderTemplate>
<div>Available actions:</div>
</HeaderTemplate>
<ItemTemplate>
<table>
<tr><td>
<asp:Button ID="ActionButton" runat="server" Width="160 px" CssClass="button"
CommandName="WfAction"
CommandArgument='<%# DataBinder.Eval(Container.DataItem, "Id" ) %>'
Text='<%# DataBinder.Eval(Container.DataItem, "Title" ) %>'
/>
</td></tr>
</table>
</ItemTemplate>
</asp:Repeater>

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

01 public static class IssueService
02 {
03 public static TransitionInfo[] GetActions(int issueId)
04 {
05 if (issueId == 0) return new TransitionInfo[] { };
06 using (DBContext ctx = new DBContext())
07 {
08 Issue issue = ctx.Issue.Include("Type").Where(i => i.Id == issueId).First();
09 ListmaManager listma = new ListmaManager(GetListmaConfiguration());
10 IWorkflowAdapter wf = listma.GetWorkflow(issue, issue.Type.Type);
11 return listma.GetAvailableTransitions(wf, ctx);
12 }
13 }
14 ...
15 }

Этот код рассмотрим подробнее. В шестой строке создаем EF контекст. В восьмой - получаем экземпляр Issue. В строке 9 мы создаем экземпляр ListmaManager, через который ведется вся работа с фрэймворком. Далее нам надо обернуть экземпляр Issue в IWorkflowAdapter<> с которым умеет работать Listma, или иными словами - получить экземпляр workflow. Для этого мы используем метод listma.GetWorkflow(issue, issue.Type.Type). Вторым параметром string entityType мы передаем issue.Type.Type. Здесь следует вспомнить, что в конфигурации Listma мы указали в качестве EntityType значения "Task" и "Bug", которые совпадают со значениями IssueType хранящимеся в базе. Таким образом, в зависимости от типа Issue, мы будем получать различные workflow, связанные с различными диаграмами состояний.
В строке 11 мы получаем список доступных переходов для данного Issue вызовом метода listma.GetAvailableTransitions(wf, ctx). Вторым параметром, как видим, передается контекст вызова. В данном случае это EF контекст. Где он нам может понадобиться, мы увидем далее. Впрочем, для случаев, когда контекст вызова нам не важен, есть вариант GetAvailableTransitions с одним параметром.
Полученный контрол WfActions.ascx вставляем на форму Issue.aspx:

Включаем - не работает. Наш метод IssueService.GetActions(int) всегда возвращает пустой массив, не зависимо от состояния Issue и текущего пользователя. Дело в том, что мы забыли реализовать одну малую деталь. В xml диаграмах мы описали для каких ролей какие переходы доступны. Но как в runtime определить текущие роли пользователя, тем более в контексте конкретного Issue?
Для этого мы должны реализовать свой IRoleProvider и назначить его для шаших workflow.

IRoleProvider



Listma распологает встроенной реализацией IRoleProvider. Для определения принадлежности пользователя к толи, он использует Thread.CurrentPrincipal.IsInRole(). Естественно, что этот провайдер ничего не знает о тех ролях, которые мы определили в своем приложении. Нам необходимо реализовать своего провайдера:

public class IssueRoleProvider : IRoleProvider
{
#region IRoleProvider Members

public bool IsInRole(string roleName, Issue entity, DBContext context)
{
string userName = System.Threading.Thread.CurrentPrincipal.Identity.Name;
bool result = false;
if(string.IsNullOrEmpty(userName)) return result;
switch (roleName)
{
case "Owner":
if (!entity.OwnerReference.IsLoaded) entity.OwnerReference.Load();
result = userName == entity.Owner.Name;
break;
case "Performer":
if (!entity.AssignedToReference.IsLoaded) entity.AssignedToReference.Load();
if (entity.AssignedTo != null) result = userName == entity.AssignedTo.Name;
break;
default:
if (!entity.ProjectReference.IsLoaded) entity.ProjectReference.Load();
result = UserService.IsUserInRole(userName, roleName, entity.Project.Id, context);
break;
}
return result;
}

#endregion
}

Как видим, IRoleProvider это generic интерфейс с двумя type-параметрами. Первый - это тип класса, к которому мы подключаем workflow. Второй - это тип контекста вызова. Дело в том, что большинство методов ListmaManager имеют перегруженные варианты с параметром контекста. Этот контекст передается в методы классов-расширений, вроде нашего IssueRoleProvider. Если мы не намерены использовать контекст, нам все равно придется реализовать generic интерфейс с двумя параметрами, но тип второго параметра будет object: IRoleProvider.
Вернемся к IssueRoleProvider. В нем мы определяем имя текущего пользователя как Thread.CurrentPrincipal.Identity.Name (заметьте, имя не приходит к нам в параметрах, и как определить его - наша забота), а также принадлежность пользователя к двум контекстным ролям "Owner" и "Performer". Определение принадлежности пользователя в остальным проектным ролям мы делегируем методу бизнес логики приложения UserService.IsUserInRole().
Теперь нам надо указать Listma, что для наших workflow следует использовать IssueRoleProvider. В конфигурационном файле для этих целей служит атрибут RoleProviderClass элемента EntityWorkflow:

<EntityWorkflow EntityType="Bug"
StatechartId="BugWorkflow"
StateMap ="State"
RoleProviderClass="BugTracker.Workflow.IssueRoleProvider, BugTracker" />

После всех этих манипуляций, мы наконец получаем работающий список действий на форме Issue.
Примечание. Для того, чтобы увидеть как работает данный функционал, вам следует запустить приложение BugTracker и зарегистрироваться новым пользователем (Home -> Login -> Register). Затем, от имени этого пользователя создайте новый баг, Измените свойство Owner бага, выполните Logout и сравните, как изменяется список кнопок на форме редактирования Issue.

Продолжение следует.

Getting Started

Давайте рассмотрим использование Listma на примере.

Само название движка «Linked State Machine» подразумевает , что мы имеем бизнес-объект с состоянием, к которому нужно подключить workflow функционал.
Рассмотрим бизнес-объект «Order» (Заказ):

public class Order
{
public Order() { }
public string Number { get; set; }
public string Customer { get; set; }
public string Address { get; set; }
public string Product { get; set; }
public decimal Count { get; set; }
public decimal Price { get; set; }
public decimal Total { get { return Count * Price; } }
public OrderState State { get; set; }
public string ApproveState { get; set; }
List _history = new List();
public List History { get { return _history; } }
}


Состояние объекта Order описывается перечислением OrderState.

public enum OrderState
{
Draft,
Processing,
Canceled,
Archive
}

Обрисуем задачу, которая перед нами стоит. На первом этапе она минимальна. Мы хотим описать перечень возможных переходов между состояниями OrderStste. Этот перечень будет выглядеть так:

Draft -> Presessing
Draft -> Canceled
Processing -> Archive

Остальные переходы запрещены.

Опишем это все в виде диаграмы Listma xml statechart:

<?xml version="1.0" encoding="utf-8" ?>
<Statechart Id="OrderWorkflow1" xmlns="urn:Listma:Starechart">
<State Id="Draft" Title="Drart" Initial="true" >
<Transition Id="Send" Title="Send to processing" TargetState="Processing" />
<Transition Id="Cancel" Title="Cancel order" TargetState="Canceled" />
</State>
<State Id="Processing" Title="Processing" Initial="false" >
<Transition Id="Process" Title="Process order" TargetState="Archive" />
</State>
<State Id="Archive" Title="Archive" Initial="false" />
<State Id="Canceled" Title="Canceled" Initial="false" />
</Statechart>

На самом деле схема описания диаграмы Listma statechart намного более сложная, но нас сейчас не интересуют все остальные возможности и мы их просто опускаем.
Здесь также надо заметить, что имя xml файла, содержащего statechart должно совпадать со statechart Id.

Далее, нам надо сконфигурировать workflow для нашего бизнес-объекта Order. В чем смысл этой операции? Дело в том, что класс Order ничего не знает о Listma равно как и наоборот. Смысл конфигурирование workflow состоит в том, чтобы сообщить движку Listma необходимые сведения о бизнес объекте. Во-первых нам необходимо устаносить связь между бизнес объектом и назначенной ему диаграмой состояния (statechart). Во-вторых мы должны указать имя атирбута, который используется для хранения состояния объекта.
Конфигурации workflow для бизнес объектов хранятся в конфигурационном файле, простейший вариант которого приведен ниже:

<ListmaConfiguration xmlns="urn:Listma:configuration"
StatechartDir="">
<EntityWorkflow EntityType="Listma.Test.Order"
StatechartId="OrderWorkflow1"
InitialState="*"
StateMap ="State" />
<EntityWorkflow EntityType="OrderApproval"
StatechartId="OrderApprovalWorkflow1"
StateMap ="ApproveState" />
</ListmaConfiguration>

Для связи workflow с бизнес объектом служит атрибут «EntityType». Это может быть как полное имя класса (по умолчанию) так и призвольное имя. В данном примере бизнес-объекту назначены два workflow. Первый описывается диаграммой (StechartId) «OrderWorkflow1» и использует для хранения состояния атррибут объекта «State». Второй описывается диагораммой «OrderApprovalWorkflow1» и использует для хранения состояния атррибут объекта «ApproveState».

После того, как движок сконфигурирован, мы можем его использовать:
[Test]
public void SimpleWorkflowScenario()
{
Order order = Order.GetOrder();
ListmaManager lm = new ListmaManager();

IWorkflowAdapter w = lm.GetWorkflow(order);
// плучаем список допустимых переходов
TransitionInfo[] transitions = lm.GetAvailableTransitions(w);

Assert.AreEqual(2, transitions.Length);


// выполняем перевод объекта в новое состояние
lm.DoStep(w, "Send");

Assert.AreEqual("Processing", w.CurrentState);
}

Примечание: для того, чтобы приведенный код успешно выполнился, необходимо, чтобы приведенные выше конфигурационный файл с именем listma.config и файл statechart с именем OrderWorkflow1.xml распологались в каталоге запуска приложения. О том как указать расположение конфигурационных файлов, а также о том как конфигурировать Listma непосредственно в коде, будет рассказано позже.

Итак, что делает приведенный код?
Прежде всего для работы мы всегда используем экземпляр класса ListmaManager. Это сервис-фасад движка.
Для работы с workflow назначенным нашему бизнес-объекту мы должны получить экземпляр generic интерфейса IWorkflowAdapter, для чего в классе ListmaManager существует ряд перегруженных методов GetWorkflow().
Метод ListmaManager.GetWorkflow(T entity) используется, если при конфигурировании мы указали в качестве EntityType полное имя класса.
Если для связи объекта с workflow мы указали д\произвольое имя, то для получения IWorkflowAdapter нам следует использовать другой перегруженный метод: ListmaManager.GetWorkflow(T entity, string entityType).

Помимо методов GetWorkflow() в классе ListmaManager существует ряд перегруженных методов StartWorkflow(). Эти методы работают аналогично GetWorkflow, но отличаются тем что инициализируют начальное состояние объекта. Для этого используется значение атрибута «InitialState» из конфигурации workflow, либо, при его отсутствии (или если в нем указано «*») первое из состояний диаграмы, помеченное атрибутом Initial="true".

Метод ListmaMenedger.GetAvailableTransitions() позволяет получить список доступных переходов в соответствии с текущим состоянием объекта и ролью пользователя.

Метод ListmaManager.DoStep() выполняет перевод объекта в новое состояние. В данном примере это единственное, что он делает. На самом деле это основной метод движка, и вокруг него строится большая часть всей логики workflow. Но об этом позже

воскресенье, 4 января 2009 г.

Что такое Listma

Listma – (Linking State Machine) небольшой workflow фреймворк на основе модели конечных автоматов, предназначенный для организации бизнес логики связанной с изменением состояния объектов. Основное предназначение этого движка, организация бизнес логики контроллера при применении шаблона Model – View – Controller.

Listma позволяет описать одну или несколько диаграмм состояний для каждого класса бизнес-объектов, определять в каждый конкретный момент времени список возможных переходов в другие состояния, и выполнять перевод объекта в новое состояние по наступлению внешнего события. Также Listma позволяет декларативно управлять доступностью аттрибутов объекта, в зависимости от его состояния и роли пользователя.

Диаграммы состояний классов могут быть описаны как с использованием языка на основе Xml, так и непосредственно в коде, в стиле fluent interface. Диаграммы состояний включают в себя описание возможных состояний и переходов между ними. Для состояний может быть указан код который должен быть выполнен при входе в состояние или при выходе из него, а для переходов - код который выполняется при совершении перехода. Код может быть задан как в виде класса реализующего интерфейс, так и в виде делегата
или лямбды.
Также для переходов могут быть указаны роли пользователей, которым доступен данный переход. Причем роли можно определять для каждой конкретной диаграммы состояний. Также можно указать шаблоны и адресаты для оповещения при выполнении перехода.
Listma представляет собой открытый движок, он не привязан ни к библиотекам обеспечивающим персистентность объектов, ни к UI библиотекам, ни к MVC движкам, ни к библиотекам role-based security. В тоже время он может быть использован практически с любыми из них, при условии затраты некоторых усилий на сопряжение.

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

Другой важной чертой Listma является то, что всегда можно использовать только необходимые вам возможности. Например, если вам не нужна role-based security, вы просто ее не используете, при этом не надо ничего отключать или подключать.

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