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