вторник, 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. Осталась не рассмотренной возможность описания в диаграме состояния шаблонов нотификации для переходов. Но в примере она еще не реализована, поэтому о ней - позже.

6 комментариев:

  1. Сергей, здравствуйте.
    А планируется ли создавать провайдер конфигурации, чтобы, например, конфиги можно было задавать в альтернативных источниках, отличных от xml - БД, Stream-ы, Fluent Interface и так далее?

    ОтветитьУдалить
  2. Отчего планируется? Он с самого начала был, IConfigProvider называется. Правда для таких целей его возможно придется подрихтовать :) Но это вопрос обсуждаемый.
    Кстати, fluent interface поддерживается для формирования диаграмы состояния в runtime.

    ОтветитьУдалить
  3. Ага, сначала написал комментарий вам, потом увидел в исходниках и в примере - как всегда :D

    Имелось в виду, можно ли на лету как вы выразились "рихтовать" конфигурацию - как минимум, без перезапуска приложения? ну и конфигурацию хранить в соответствующем источнике?

    ОтветитьУдалить
  4. Здравствуйте, Сергей!
    Спасибо за фреймворк, интересная тема, наш проект как раз достиг состояния "ад разработчика" и задачи решаемые фреймворком очень актуальны.

    Напишу что бы я добавил во фреймворк:
    1) возможность привязки к переходу нескольких обработчиков
    2) привязка обработчиков к состоянию (пример - назначение обработчика на все переходы в/из указанного состояния)
    3) еще было бы удобно разделить обработчики на три типа: PreValidate, Execute и ConfirmStateChange. В дальнейшем, при создании UI к фреймворку, такое разделение облегчит понимание логики переходов

    Павел

    ОтветитьУдалить
  5. Сергей, верно ли что сущность должна сама обеспечивать хранение аттрибутов, описывающих текущее состояние? Если это так, то выходит, что не возможно реализовать журнал состояний без того, чтобы сущность "знала" об деталях реализации хранения состояния.

    Что если выделить аттрибуты текущего состояния (н.п.: AssignedTo) в отдельный под-тип сущности. Причем сделать этот под-тип полиморфным относительно возможных состояний workflow.. Конечно, придется обеспечить некий API для работы с объектом текущего состояния, равно как и доступ к истории переходов. Возможно также некий абстрагируемый механизм хранения состояний, отличный от того, который применяется для самих сущностей..

    Я попытался реализовать это в своем движке (структурная диаграмма: [http://code.google.com/p/n2contrib/] ). Теперь пытаюсь адаптировать свои наработки под Listma, но вот с этим журналом состояний не знаю как и быть..

    ОтветитьУдалить
  6. Верно, то что Listma ничего не знает о хранении состояния (персистентности) сущности. Впрочем сама сущность тоже может ничего не знать об этом.
    Если завязываться на персистентность, то это сразу усложняет реализацию (1), привносит лишнюю связность (2) и нарушает SRP (3).
    Вот в Simple State Machine(
    http://www.codeplex.com/SimpleStateMachine) есть завязка на персистентность. Можешь посмотреть.

    Что касается журнала состояний. Реализовать его не сложно, например, через введение базового класса обработчика переходов. Тут большую роль играет то, для чего он нужен. Для простого аудита - будет одна реализация. Для возможности отката действий - другая.

    ОтветитьУдалить