пятница, 9 января 2009 г.

Конфигурирование

Конфигурирование Listma на данный момент вызывает наибольшее количество вопросов. Поэтому, рассмотрим это вопрос подробнее.
Идеалогия фрэймворка исходит из того, что существует stateful бизнес-объект (entity) который ничего не знает о Listma и vice-versa. Следовательно, нам нужно конфигурировать Listma перед использованием.
Собственно конфигурирование состоит из двух частей:
1. Описать диаграму состояний (statechart) бизнес-объекта, состоящую из состояний и переходов.
2. Описать EntityWorkflow для объекта. Здесь мы указываем, как Listma будет взаимодействовать с бизнес-объектом: указываем тип объекта, даем ссылку на диаграму состояний, описываем маппинг на свойство/поле где объект хранит свое состояние, указываем класс-провайдер ролей, и т.д.

Конфигурирование Listma может выполняться двумя способами:
- декларативно, при помощи фигурационных xml файлов
- императивно, в runtime и в исходном коде.

Общий сценарий конфигурирования



Вся работа с Listma осуществляется через класс ListmaManager. При созданни экземпляра ListmaManager, ему явно или неявно передается экземпляр интерфейса IConfigProvider, который предоставляет доступ к конфигурационной информации. В состав фрэймворка входит реализация этого интерфейса - класс ConfigProvider.
У ListmaManager три конструктора. Первый:

public ListmaManager()
{
_configProvider = ConfigProvider.GetDefault();
}

использует синглтон ConfigProvider.GetDefault(). Как следствие этого, во-первых, в этом случае ищется конфигурационный файл с именем "listma.config" в директории AppDomain.Basedirectory. Во-вторых, ConfigProvider кэширует загружаемые из xml диаграмы сотояний (statecharts) и поэтому, все экземпляры ListmaManager созданные этим конструктором используют общий statechart кэш.

Второй конструктор

public ListmaManager(string configFileName)

принимает в качестве параметра имя файла конфигурации, создает экземпляр ConfigProvider и загружает в него данные конфигурации.

Третий конструктор

public ListmaManager(IConfigProvider configProvider)

принмает в качестве параметра экземпляр IConfigProvider. Т.е. вы можете создать свою реализацию IConfigProvider либо, сконфигурировать экземпляр ConfigProvider в коде и передать его в конструктор.

Кофигурирование через xml файлы.



Данный сценарий не представляет сложности, достаточно вызвать конструктор

public ListmaManager(string configFileName)
с именем конфигурационного файла. Поэтому мы сосредоточимся на рассмотрении формата конфигурационного файла.
В исходниках проекта, а также в инсталлятор, входит xml схема конфигурационного файла ListmaConfiguration.xsd
Корневой элемент ListmaConfiguration содержит два атрибута:
- StatechartDir - путь к папке, в которой Listma будет искать файлы с описанием диаграм состояний (statechart). Если атрибут не задан используется AppDomain.BaseDirectory.
- DefaultPermissionLevel - дефолтный UIPermissionLevel который возвращает IPermissionProvider для тех элементов UI, праа доступа к которым не описаны в диаграме состояний (statechart). Если атрибут не задан, используется значение UIPermissionLevel.Hidden.

Далее следуют элементы EntityWorkflow, каждый из которых описывает один workflow:
- EntityType(обязательный атрибут)- идентификатор типа бизнес-объекта, с которым связан данный workflow. По умолчанию в качестве идентификтора используется полное имя типа бизнес-объекта (Type.FullName). Если у вас только один workflow для каждого типа бизнес-объекта, используйте Type.FullName в качестве идентификатора типа. Если для одного типа бизнес-объекта вам требуется сконфигурировать несколько workflow, вы можете указывать здесь любой уникальный строковый идентификатор. В этом случае вам придется использовать перегруженные версии методов ListmaManager с параметром string entityType. Например:

public IWorkflowAdapter<EntityType> GetWorkflow<EntityType>(EntityType entity, string entityType)

вместо

public IWorkflowAdapter<EntityType> GetWorkflow<EntityType>(EntityType entity)

- StatechartId(обязательный атрибут) - Id диаграмы состояний данного workflow. Имя файла диаграмы должно совпадать со StatechartId (плюс расширение .xml), а сам файл должен распологаться в каталоге, указанном в атрибуте StatechartDir
- StateMap (обязательный атрибут) - имя атрибута бизнес-объекта (public свойство (get; set;) или поле), используемого для хранения состояния. Поле/свойство может иметь тип string, int, enum. В это поле будет сохранятся идентификатор текущего состояния, и это надо учитывать при создании диаграмы состояний.
- StatechartMap - имя атрибута бизнес-объекта (public свойство (get; set;) или поле) используемого для хранения StatechartId заданного при старте workflow объекта (метод ListmsManager.StartWorkflow). Данный маппинг нужен, если вы хотите обеспечить версионность workflow. При этом StatechartId будет сохранен в экземпляре бизнес-объекта и в течении всей жизни этого экземпляра будет использоваться именно этот statechart, даже если в конфигурации будет указан другой StatechartId.
- InitialState - указывает Id состояния, которое будет задано экземпляру бизнес-объекта при старте workflow. Если атрибут не задан (или имеет значение "*"), в качестве стартового будет использовано первое состояние с признаком Initial="true" в диаграме состояний.
- WorkflowFactoryClass - имя класса, реализующего интерфейс IWorkflowFactory для данного типа бизнес-объекта. Если атрибут не задан, используется ReflectionFactory и соответственно, реализация IWorkflowAdapter на reflection.
- RoleProviderClass - имя класса реализующего интерфейс IRoleProvider для данного типа бизнес-объекта. Если атрибут не задан, используется дефолтная реализация которая использует Thread.CurrentPrincipal.IsInRole для определения принадлежности к роли.

Конфигурирование в коде



Listma позволяет полностью отказаться от использования конфигурационных файлов и выполнять конфигурирование в коде.
Для того, чтобы сконфигурировать workflow для бизнес-объекта, прежде всего следует создать экземпляр EntityWorkflow. Минимально необходимый набор параметров передается в единственный public конструктор:

public EntityWorkflow(string entityType, string statechartId, string stateMap)

остальные параметры конфигурации задаются через методы класса EntityWorkflow.
Сконфигурированный EntityWorkflow регистрируем в ConfigProvider:

EntityWorkflow orderWorkflow = new EntityWorkflow(typeof(Order).FullName, "OrderWorkflow1", "State");
orderWorkflow.RegisterWorkflowFactoryType(typeof(OrderWorkflowFactory));
ConfigProvider config = new ConfigProvider();
config.RegisterEntityWorkflow(orderWorkflow);

Если более ничего не сделать, Listma будет искать файл OrderWorkflow1.xml для загрузки диаграмы состояний. Однако диаграму состояний можно создать непосредственно в коде, в модном нынче стиле fluent interface.
Мне очень не хотелось нагружать классы метаданных диаграмы состояния специфическими методами, но по счастью в C# есть extension methods. Работу по созданию диаграмы состояния в стиле fluent interface взял на себя класс StatechartBuilder.

Создание диаграмы начинается с метода расширения BuildStatechart для IConfigProvider. Метод принимает в качестве параметра экземпляр EntityWorkflow. Таким образом создаваемый Statechart берет StatechartId укаанный в EntityWorkflow и сразу регистрируется в конфигурации. В дальнейшем на этот Statechart наращивается "мясо", в том числе код обработчиков событий. Вот пример:

IConfigProvider config = new ConfigProvider();
EntityWorkflow orderWorkflow = new EntityWorkflow(typeof(Order).FullName, "OrderWorkflow1", "State");
config.BuildStatechart(orderWorkflow)
.WithState("New", "New", true)
.WithTransition("Activate", "Activate")
.ToState("Active")
.PerformedBy("Owner")
.PerformedBy("Manager")
.WithNotification("OnActivation")
.ToAddress("user@mail.com")
.ToRole("Reviewer")
.CcRole("Administrator")
.CcAddress("user2@mail.com")
.Ret()
.Ret()
.WithTransition("Close", "Close")
.ToState("Closed")
.Ret()
.DefinePermissionsFor("Number")
.ForRole("Owner", UIPermissionLevel.Write)
.ForRole("*", UIPermissionLevel.Read)
.Ret()
.DefinePermissionsFor("Address")
.ForRole("Owner", UIPermissionLevel.Write)
.ForRole("*", UIPermissionLevel.Read)
.Ret()
.Ret()
.WithState("Active", "Active", false).Ret()
.WithState("Closed", "Closed", false).Ret()
.WithNotifyTemplate("OnActivation", "Order {0} has been activated.", "Order {0} has been activated.");

Здесь, для сущности "Order", которая хранит свое состояние в свойстве "State", мы создали workflow с именем "OrderWorkflow1". Для этого workflow мы создали диаграму состояний с тремя возможными состояниями "New", "Active", "Closed". Мы описали переход из состояния "New" в состояние "Active". Указали что этот переход могут выполнять только роли "Owner" и "Manager". Также мы описали нотификационное сообщение для этого перехода, и указали получателей этого сообщения. Далее мы указали, что в состоянии "New" только пользователь с ролью "Owner" может редактировать свойства Order.Number и Order.Address, а для остальных пользователей эти свойства должны быть read-only.
Здесь, также, заслуживают пояснение методы Ret(). Поскольку структура дерева Statechart весьма развесистая, по ходу строительства приходится менять контекст: сначал строим сотояние, потом - переход, потом - нотификации перехода. Для возврата к предыдущему контексту и используется методы Ret(). Таким образом, в интелисенсе у вас всегда отображаются только "нужные" методы, а не все без разбора. Кому интересно, смотрите реализацию класса StatechartBuilder.

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

List<string> Log = new List<string>();
IConfigProvider config = new ConfigProvider();
EntityWorkflow workflow = new EntityWorkflow(typeof(Order).FullName, "OrderWorkflow1", "State");
config.RegisterEntityWorkflow(workflow);
config.BuildStatechart(workflow)
.WithState("Draft", "Draft", true)
.ExitHandledBy<Order, TestContext>((o, c) => Log.Add(o.State.ToString() + " exit"))
.WithTransition("Do", "Do").ToState("Archive")
.HandledBy<Order, TestContext>(o => { Log.Add("Prevalidate order " + o.Number); },
(o, c) =>
{
Console.WriteLine("Context is '{0}', Order state {1}", c.Text, o.State);
c.Text += " has been done";
},
(o, s) => { Log.Add("State is changed to " + s); return true; })
.WithNotification("Notification1")
.HandledBy<Order, TestContext>((role, o, ctx) =>
{
return new string[] { role + "@mail.com" };
},
(message, tempalte, o, ctx) =>
{
message.Subject = tempalte.Subject;
message.Body = tempalte.Body;
})
.ToRole("Role1")
.CcRole("Role2")
.Ret()
.Ret()
.Ret()
.WithState("Archive", "Archive", false)
.EnterHandledBy<Order, TestContext>((o, c) => Log.Add(o.State.ToString() + " enter"))
.Ret()
.WithNotifyTemplate("Notification1", "Subject", "Body");


Здесь, непосредственно в описании диаграммы, мы указали код, который будет выполняться при выходе объектов Order из состояния "Draft", код который будет выполняться при переходе в состояние "Archive", и код обработчика для нотификации при этом переходе.

Итак, сконфигурировать Listma можно как в коде, так и в xml файлах.
Конфигурирование в коде избавляет нас от множества ошибок, а также очень удобно для юнит тестов. Однако, если вам надо изменить диуграму состояний вам придется перекомпилировать приложение.

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

Выпущен build 2

Выпущен build 2. Инсталлер здесь.
What's new:
- добавлены xml комментарии для всех public классов и интерфейсов фрэймворка
- исправлен инсталлятор (bug #5).

вторник, 6 января 2009 г.

Реальный пример (продолжение)

Начало здесь

Итак, мы сконфигурировали framework, создали диаграмы состояний для двух типов Issue - "Task" и "Bug", и разместили на форме редактирования контрол, который отображает возможные в данный момент переходы в виде списка кнопок.
Теперь нам надо обработать нажатия этих кнопок.

Обработка событий изменения состояния.



Нажатие кнопки на форме редактирования Issue означает, что пользователь хочет изменить состояние Issue.

Приведенный скриншот соответствует вот такому фрагменту диаграмы состояний:

<State Id="Opened" Title="Opened" Initial="false">
<Transition Id="Fix" Title="Fix" TargetState="Resolved">
<Performers>
<Performer Role="Performer" />
</Performers>
</Transition>
<Transition Id="FaD" Title="Functions as designed" TargetState="Resolved">
<Performers>
<Performer Role="Performer" />
</Performers>
</Transition>
<Transition Id="Duplicate" Title="Duplicate" TargetState="Resolved">
<Performers>
<Performer Role="Performer" />
</Performers>
</Transition>
<Transition Id="WontFix" Title="Wont fix" TargetState="Resolved">
<Performers>
<Performer Role="Performer" />
</Performers>
</Transition>
<Transition Id="DontReproduce" Title="Don't reproduce" TargetState="Resolved">
<Performers>
<Performer Role="Performer" />
</Performers>
</Transition>
</State>

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

protected void WfActions1_DoCommand(object source, CommandEventArgs e)
{
IssueDetailsView.UpdateItem(true);
string issueId = this.Request.QueryString["Id"];
string actionId = (string)e.CommandArgument;
if (string.IsNullOrEmpty(issueId) || string.IsNullOrEmpty(actionId)) return;
IssueService.DoAction(Int32.Parse(issueId), actionId);
IssueDetailsView.DataBind();
WfActions1.DataBind();
IssueService.SetPermissions(Int32.Parse(issueId), this);
}

Основная работа этого метода, извлечь Id Issue и Id перехода, передать их методу бизнес логики IssueService.DoAction, и затем, обновить представления.
Код IssueService.DoAction тоже очень прост:
 
public static void DoAction(int issueId, string actionId)
{
if (issueId == 0) return;
using (DBContext ctx = new DBContext())
{
Issue issue = ctx.Issue.Include("Type").Where(i => i.Id == issueId).First();
ListmaManager listma = new ListmaManager(GetListmaConfiguration());
IWorkflowAdapter<Issue> wf = listma.GetWorkflow(issue, issue.Type.Type);
listma.DoStep(wf, actionId, ctx);
ctx.SaveChanges(true);
}
}

Собственно, изменение состояния объекта производит метод ListmaManager.DoStep().
Однако возникает вопрос, зачем нам пять штук переходов из состояния "Opened" в состояние "Resolved", которые ничем не отличаются, кроме названия?
Дело в том, что каждый из этих пяти переходов, должен устанавливать свое значение в поле Issue.Result, поскольку баг может быть закрыт исполнителем с пятью разными результатами. Для реализации этой бизнес логики мы воспользуемся обработчиками переходов диаграмы состояний (transition handlers).

Transition Handlers



Для каждого перехода (Transition) в диаграме состояний может быть назначен обработчик перехода. Это должен быть класс, реализующий generic интерфейс IHandler, или расширенный интерфейс ITransitionHandler.

public interface IHandler<T,C>
{
void Execute(T entity, C context);
}

public interface ITransitionHandler<T,C> : IHandler<T,C>
{
void PreValidate(T entity);
bool ConfirmStateChange(T entity, string targetState);
}

В большинстве случаев достаточно реализации IHandler. Метод Execute вызывается непосредственно во время выполнения ListmaManager.DoStep(), если на соответствующий переход назначен обработчик. Внутри метода Execute мы имеем типизированный доступ через параметры к самому изменяемому объекту и к дополнительному параметру - пользовательскому контексту.
Интерфейс ITransitionHandler расширяет базовый интерфейс IHandler двумя методами. PreValidate вызывается перед началом перехода и позволяет проверить валидность состояния объекта. В случае невалидности объекта метод должен поднять исключение. Другой метод - ConfirmStateChange вызывается после Execute и непосредственно перед сменой состояния объекта. Если ConfirmStateChange возвращает false то смены состояния не произойдет.
Примечание. Пердставте себе workflow согласования документа, при котором несколько согласователей должны выполнить действие "Согласовать", но документ переходит в состояние "Согласовано", только когда последний из списка согласователей выполнит свое действие. Метод ITransitionHandler.ConfirmStateChange предназначен именно для таких ситуаций. Он позволяет создавать условные переходы в диаграме состояний.
Имя класса, обработчика перехода указывается в атрибуте Handler элемента Transition в xml диаграмы состояний.

<Transition Id="Fix" Title="Fix" TargetState="Resolved" Handler="BugTracker.workflow.FixHandler, BugTracker">
<Performers>
<Performer Role="Performer" />
</Performers>
</Transition>

Поскольку класс инстанциируется через reflection, он должен иметь конструктор без переметров. Если Statechart создается в коде при помощи StatechartBuilder-а, то там существует возможность задать обработчики переходов в виде анонимных методов или лямбд.
Обработчики переходов в нашем примере очень просты:

public class BugHandlerBase : IHandler<Issue, DBContext>
{
virtual protected string Result { get { return String.Empty; } }

#region IHandler<Issue, DBContext> Members

public void Execute(Issue entity, DBContext context)
{
entity.Result = Result;
}

#endregion
}

public class FixHandler : BugHandlerBase
{
override protected string Result { get { return "Fixed"; } }
}

И лишь обработчик перехода "Assign" реализует расширенный интерфейс ITransitionHandler, метод PreValidate которого проверяет заполнено ли свойство Issue.AssignedTo :

public class AssignHandler : BugHandlerBase, ITransitionHandler<Issue, DBContext>
{
#region ITransitionHandler<Issue,DBContext> Members
public void PreValidate(Issue entity)
{
if (!entity.AssignedToReference.IsLoaded) entity.AssignedToReference.Load();
if(entity.AssignedTo == null) throw new ApplicationException("'Assigned To' property must be set.");
}

public bool ConfirmStateChange(Issue entity, string targetState)
{
return true;
}
#endregion
}



Реализация UIPermissions для web формы



Как мы уже знаем, в диаграме состояния объекта можно описать права доступа к элементам UI для каждого состояния объекта на основе ролей пользователей.
Так же мы уже знаем, что для реализации ролевого доступа мы должны реализовать интерфейс IRoleProvider и его реализацию мы уже рассмотрели ранее.
Непосредственно для построения системы контроля доступа к UI элементам служат два взаимосвязанных интерфейса:

public interface ISecurable
{
void AcceptPermissions(IPermissionProvider provider);
}

public interface IPermissionProvider
{
UIPermissionLevel Demand(string elementName);
}

Реализовывать нам надо только один из них - ISecurable. Второй - IPermissionProvider нам предоставляет метод Listma.GetPermissionProvider.
В шаблоне MVC интерфейс ISecurable должен реализовывать View (представление). Суть реализации состоит в том, что в методе AcceptPermissions представление должно пробежать по всем своим элементам и для каждого из них запросить уровень доступа через вызов IPermissionProvider.Demand(elementName). Все достаточно просто.

В нашем примере реализацию ISecurable логично возложить на форму редактирования Issue. Реализацию не назовешь тривиальной. Как я уже говорил, все приложение BugTracker построено на стандартных ASP.NET компонентах с помощью визарда и мышки. Нельзя сказать, что это упростило мне работу. Так форма редактирования Issue построена на DetailsView, и этот компонент не очень дружелюбно настроен к тем, кто хочет добиться от него чего либо большего, чем стандартные возможности. В результате для свойств со стандартным редактированием (BoundField) используется один подход, а для тех свойств, где используются шаблоны (TemplateField) используется биндинг из ASP.NET кода, на специально созданную коллекцию состояний полей "StateList"

public partial class IssueForm : System.Web.UI.Page, ISecurable
{
#region ISecurable Members

void ISecurable.AcceptPermissions(IPermissionProvider provider)
{
StateList = new Dictionary<string, State>();
for(int i=0;i<IssueDetailsView.Fields.Count;i++)
{
DataControlField f = IssueDetailsView.Fields[i];
UIPermissionLevel level = provider.Demand(f.ToString());
State state = new State();
switch (level)
{
case UIPermissionLevel.Hidden:
state.Enabled = false;
state.Visible = false;
break;
case UIPermissionLevel.Read:
state.Enabled = false;
state.Visible = true;
break;
case UIPermissionLevel.Write:
state.Enabled = true;
state.Visible = true;
break;
}
if (f is BoundField)
SetState(state, (BoundField)f);
else
StateList.Add(f.ToString(), state);
}
}

private static void SetState(State state, BoundField bf)
{
bf.ReadOnly = !state.Enabled;
bf.Visible = state.Visible;
}

public Dictionary<string, State> StateList;

#endregion
}
public class State
{
public bool Enabled;
public bool Visible;
}

Возможны были и другие варианты реализации, но этот показался мне наиболее "прозрачным". Следует также заметить, что для варианта с собственными ascx контролами или для Winform реализация ISecurable будет значительно проще.
Собственно активизация прав доступа на элементы управления производится в методе формы Page_Load вызовом:

IssueService.SetPermissions(Int32.Parse(id), this);

где метод бизнес логики IssueService.SetPermissions тоже достаточно прост:

public static void SetPermissions(int issueId, ISecurable view)
{
if (issueId == 0) return;
using (DBContext ctx = new DBContext())
{
Issue issue = ctx.Issue.Include("Type").Where(i => i.Id == issueId).First();
ListmaManager listma = new ListmaManager(GetListmaConfiguration());
IWorkflowAdapter<Issue> wf = listma.GetWorkflow(issue, issue.Type.Type);
view.AcceptPermissions(listma.GetPermissionProvider(wf, ctx));
}
}

Теперь, изменяя описания в BugWorkflow.xml и TaskWorkflow.xml мы можем управлять доступностью полей формы редактирования Issue в зависимости от ролей пользователя, текщего состояния и типа Issue.
Нужно упомянуть о роли атрибута DefaultPermissionLevel="Write" в конфигурационном файле Listma. Этот атрибут оперделяет, какое значение UIPermissionLevel будет возвращать метод IPermissionProvider.Demand для тех UI элементов, чьи права доступа не описаны в диаграме состояний. По умолчанию действует значение DefaultPermissionLevel="Hidden", т.е. запрещающее правило - все не описанные элементы должны быть скрыты. Однако для нашего примера мы установили "разрешающее правило".

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

понедельник, 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
- описывать диаграмы состояний объектов, включающие перечень возможных состояний объекта, и возможные переходы между состояниями
- определять бизнес логику, выполняющуюся при изменении состояния объектов
- определять доступность переходов на основе ролей пользователей
- определять шаблоны оповещения при изменении состояния объекта и правила адресации на основе ролей пользователей
- переводить объекты из одного состояния в другое на основе описанных правил
- определять права доступа к аттрибутам объекта в зависимости от состояния и роли пользователя

Begin

Что вы делаете, когда вся логика разрабатываемого класса крутится вокруг его состояния? К примеру, разрабатываем мы web магазин. Есть у нас сущность Order (заказ), ее создает «покупатель», затем он ставится в очередь на обработку, затем «оператор» формирует заказ и передает его «курьеру» для доставки, «курьер» доставляет заказ и делает отметку о доставке. Покупатель может редактировать все поля заказа, пока не передаст его на исполнение. После этого покупатель не может редактировать заказ, но может отозвать его, но только если заказ еще не передан для доставки. Оператор не может редактировать заказ, но может оповестить покупателя о задержке в связи с отсутствием товара на складе. Курьер может только проставлять отметку о доставке и только на тех заказах, что переданы для доставки ему. Ну и т.д. (много деталей опущено).

Довольно типичная картина, не правда ли? Действия доступные пользователям зависят от их роли и текущего состояния сущности, причем все эти особенности и детали способны утомить еще при чтении требований, не говоря уж о реализации. И в тоже время они являются весьма важными с точки зрения заказчика. А при реализации они размазываются тонким ровным слоем по всей бизнес логике и по UI в придачу. Ситуацию усугубляет то что, все эти требования очень волатильны, то есть склонны к частым и непредсказуемым изменениям. И вот он – живой кошмар любого разработчика перед нами во всей красе.
Однако с подобными задачами довольно просто можно справиться на основе workflow подходов, и в частности, с помошью конечного автомата или Finite State Machine.
Основная идея состоит в том, чтобы описать диаграмму состояний сущности (в нашем случае заказа), и допустимых переходов, при этом связав их с ролями пользователей.
Обычно, говоря о методе конечных автоматов, подразумевают создание класса, реализующего конкретную диаграмму. Но в нашем случае интереснее использовать иной подход, который менее распространен. Нам интереснее создать класс, способный исполнять любую диаграмму состояний, по воздействию внешних событий. При этом список переходов, доступных в данном состоянии для данного пользователя, на уровне UI представляется в виде набора доступных действий. А выбор любого из этих действий, вызывает выполнение соответствующего перехода в диаграмме состояний, и выполнение связанной с ним бизнес логики.

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

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

И в тоже время он не должен:
- зависеть от способов хранения бизнес-сущностей
- предъявлять какие либо требования к реализации классов бизнес-сущностей
- зависеть от UI библиотек (ASP.NET, WinForms)
- зависеть от провайдеров role-based security
- требовать наличия собственной БД для хранения своих настроек и состояния

Ничего готового на платформе .Net не обнаружилось. Windows Workflow не подошел на эту роль по причине своей монструозности (посмотрите список чего «не должен» делать движок и вам все станет понятно). Поэтому появилась мысль сделать свой движок, обобщив в нем свой многолетний опыт в данной области.
И вот в первом приближении такой движок готов. Называется он Listma, что значит Linking State Machine, или Подключаемая машина состояний, что вполне отражает его суть.
Listma - это проект с открытым исходным кодом. Хостится он будет на Google Code.

Все необходимые ссылки можно увидеть справа на сайдбаре.