среда, 31 декабря 2008 г.

Дао Scrum

Много бумаги исписано и и мегабайтов перекачано в процессе, когда одни пытаются понять, что же такое Scrum, а другие пытаются это объяснить. Главное достоинство Scrum в этом смысле заключается в том, что для того, чтобы начать его применять не нужно многого – бери готовые несложные правила, минимум артефактов, и колесики закрутились. Но понимание глубокой сути этого процесса наступает далеко не сразу.

Как известно, центром любого scrum-проекта является product backlog. Это место, куда product owner записывает свои пожелания, оценивая их значимость и вместе с командой планирует итерации. Казалось бы, что может быть проще? Но чем же этот подход оказался лучше других? Чем он отличается, скажем, от случая, когда заказчик просто заводит тикеты, например, в JIRA? Что делает этот проект столь эффективным?

Для того, чтобы это понять, достаточно взглянуть на перевод термина “backlog” (ну, или в толковый словарь для носителей языка):

backlog

  1. долг, задолженность
  2. невыполненные заказы
  3. резервы (товаров, материалов и т. п.)

На мой взгляд, значение этого термина – квинтэссенция всего процесса. Попробуем разобрать его по пунктам.

Backlog – это не список задач для разработчиков

Нет, конечно, в конечном счете разработчик работает со списком конкретных задач. Но изначально, с точки зрения product owner, список конкретных задач – это скучные подробности. В первую очередь из перевода следует, что backlog – это не список задач и даже не список user stories, которые нужно выполнить, а список недостатков системы. Под недостатками подразумеваются недостающая функциональность, недостаточное качество реализации, недостаточное соответствие ожиданиям бизнеса и так далее. Тут видно замечательное свойство термина “недостаток”, которое заключается в том, что оно обобщает артефакты нескольких подпроцессов: фича, улучшение, дефет.

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

У такого подхода, среди прочих достоинств, есть одно важное преимущество: он построен на позитивном мышлении творцов, стремящихся в своей работе к совершенству, подогревая творческий потенциал участников процесса. Ведь даже Микеланджело был приверженцем такого принципа: на вопрос о том, как он создает столь совершенные скульптуры, он ответил “Я просто беру резец и отсекаю от мрамора все лишнее”. Правда похоже? :)

Подразумевается, что приложение существует на этапе планирования

Хорошо известный постулат любого agile-проесса напрямую следует из предыдущего пункта. Ведь прежде чем определить недостатки системы, да еще и определить их степень важности, нужно иметь некую текущую версию, глядя на которую и можно сказать, чего ей не достает. Нужен тот самый кусок мрамора. Ведь сложно искать недостатки в белизне чистого листа бумаги. Поэтому и существуют правило о доступе product-owner-а к актуальной рабочей версии приложения пополам с особым акцентом на коммуникации между заказчиком и разработчикам.

Выживают важнейшие требования

Получается, что процесс управления недостатками подчеркивает эволюционную природу Scrum. Планирование каждого спринта – это, по сути, естественный отбор задач, где критерием отбора является степень ее важности для приложения на данном этапе эволюции. А механизм отбора универсален – борьба за ресурсы: мы просто ограничиваем продолжительность спринта. Ну и важно спланировать спринт так, чтобы по его окончанию мы получили полноценный экземпляр приложения, который должен быть как минимум не хуже предыдущего.

К чему это все?

По сути, главное отличие Scrum от других методологий заключается в том, что он дает четкий и однозначный ответ на вопрос о том, какими критериями следует руководствоваться при планировании работ, помогая разогнать проект с низкого старта. Управление недостатками – удачная метафора. Ведь не зря перед началом любых работ важно определить цель, которую необходимо достичь. А если воспользоваться этой метафорой, то задача определения цели значительно упростится.

Всех с Новым годом! :)

понедельник, 15 декабря 2008 г.

DSL, валидация и jQuery

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

Введение

Итак, напомню, что мы реализовали на данный момент:

  1. Объектная модель DSL валидации;
  2. Удобный механизм конструирования синтаксического дерева;
  3. Интерпретатор, который транслирует синтаксическую структуру дерева в логику валидации.

Для демонстрационных целей рассмотрим два альтернативных подхода - один с использованием серверных валидаторов ASP.NET, а второй будет чисто клиентским. Таким образом, мы увидим, как разделение логики DSL на декларативную и интерпретационную части помогает нам расширять его функциональность.

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

Затем мы разработаем "компилятор", который и будет превращать код, написанный на нашем DSL в логику валидации, которая будет встраиваться в форму по продуманному заранее сценарию.

Исходный код валидатора и модификации в DSL

В качестве подопытного кролика на сей раз выберем класс Person:

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string Email { get; set; }
    public string Comments { get; set; }
}

Интересным тут является наличие поля Email, для которого должно срабатывать условие соответствия шаблону. Поэтому модифицируем структуру констрейнтов, приведенную в предыдущем примере:

Email Constraint

Далее это позволит нам добавить другие типы констрейнтов, логика которых может быть реализована с использованием регулярных выражений.

Таким образом, пусть код валидатора будет таким:

var v = DefineValidator.For<Person>()

   .WhereProperty(p => p.FirstName).SatisfiedAs(Should.NotBeNullOrEmpty)
        .WithReason("Введите имя").AsError()
   .WhereProperty(p => p.FirstName).SatisfiedAs(Should.NotBeLongerThan(10))
        .WithReason("Слишком длинное имя")

   .WhereProperty(p => p.LastName).SatisfiedAs(Should.NotBeNullOrEmpty)
        .WithReason("Введите фамилию").AsError()
   .WhereProperty(p => p.LastName).SatisfiedAs(Should.NotBeLongerThan(10))
        .WithReason("Слишком длинная фамилия")

   .WhereProperty(p => p.Email).SatisfiedAs(Should.MatchEmailTemplate)
        .WithReason("Введите правильный емейл");

Динамическое добавление серверных валидаторов

Для начала приведем код формы, которую мы будем расширять логикой валидации:

<form runat="server">
    Имя:<br />
    <cc:ValidableTextBox ID="FirstName" runat="server" Type="Product"></cc:ValidableTextBox><br />
    Фамилия:<br />
    <cc:ValidableTextBox ID="LastName" runat="server" Type="Product"></cc:ValidableTextBox><br />
    Емейл:<br />
    <cc:ValidableTextBox ID="Email" runat="server" Type="Product"></cc:ValidableTextBox><br />
    Комментарии:<br />
    <cc:ValidableTextBox ID="Comments" runat="server" Type="Product"></cc:ValidableTextBox><br />
    <br />
    <asp:Button runat="server" Text="Submit" />
</form>

В приведенном фрагменте вместо стандартного контрола TextBox мы использовали его расширенную версию ValidableTextBox, в котором появилось поле Type. Для чего это нужно? Дело в том, что мы должны связать контрол на форме с конкретным свойством конкретного класса. В простейшем случае имя свойства можно использовать в качестве идентификатора для контрола, а свойство Type будет использоваться для указания типа, к которому это свойство принадлежит.

В WebForms валидаторы - это обычные серверные контролы, поэтому добавление валидаторов на форму - это обычная манипуляция коллекцией контролов:

protected void Page_Init(object sender, EventArgs e)
{
    const string maxLengthPattern = @"[\s\S]{{0,{0}}}";
    const string emailPattern = @"\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*";

    AddValidator<Person>(p => p.FirstName,
        new RequiredFieldValidator(),
        "Введите имя");

    AddValidator<Person>(p => p.FirstName,
        new RegularExpressionValidator { ValidationExpression = string.Format(maxLengthPattern, 10) },
        "Слишком длинное имя");

    AddValidator<Person>(p => p.LastName,
        new RequiredFieldValidator(),
        "Введите фамилию");

    AddValidator<Person>(p => p.LastName,
        new RegularExpressionValidator { ValidationExpression = string.Format(maxLengthPattern, 10) },
        "Слишком длинная фамилия");

    AddValidator<Person>(p => p.Email,
        new RegularExpressionValidator { ValidationExpression = emailPattern },
        "Введите правильный емейл");
}

void AddValidator<T>(Expression<Func<T, object>> property, BaseValidator validator, string message)
{
    var fildName = ((MemberExpression)property.Body).Member.Name;

    var validableControl = FindControl(fildName) as IValidableControl;

    if (validableControl != null)
    {
        validator.ErrorMessage = message;
        validator.Display = ValidatorDisplay.Dynamic;
        validator.ControlToValidate = ((Control) validableControl).ID;

        var container = ((Control)validableControl).Parent.Controls;
        container.AddAt(container.IndexOf((Control) validableControl) + 1, validator);
    }
}

В ходе экспериментов с динамическим добавлением контролов мы увидели, что констрейнт StringMaxLengthConstraint разумно реализовать на базе RegularExpressionConstraint, потому что ограничение по длине поля с использованием серверных валидаторов в WebForms реализуется с помощью RegularExpressionValidator.

Показательным является то, что перекроив логику работы StringMaxLengthConstraint мы никак не повлияли на синтаксис нашего DSL.

Динамическая генерация javascript-кода для валидации

Раз уж речь пошла о том, что второй подход у нас будет ориентирован на использование ASP.NET MVC, то логично будет развить эту мысль и сказать, что код клиентской валидации мы будем писать, используя jQuery. И раз уж мы решили использовать jQuery, то стоит взглянуть на плагин Validation. Главным плюсом этого плагина является то, что вся логика описывается в декларативном стиле и может быть локализована в одном месте.

Код нашей формы будет выглядеть так:

<form action="ValidateHtmlForm.aspx" method="post" id="PersonForm">
    Имя:<br />
    <input type="text" name="Person$FirstName" /><br />
    Фамилия:<br />
    <input type="text" name="Person$LastName" /><br />
    Емейл:<br />
    <input type="text" name="Person$Email" /><br />
    Комментарии:<br />
    <input type="text" name="Person$Comments" /><br />
    <br />
    <input type="submit" value="Submit" />
</form>

По аналогии с серверными контролами, нам нужно связать поле ввода со свойством нужного нам типа. Для этого можно просто дать правильные имена полям, составив их из имени типа и названия свойства.

И в этом случае код валидации будет таким:

$(document).ready ( 
    function() {
        $("#PersonForm").validate(
            {
                rules: {
                    Person$FirstName: {
                        required: true,
                        maxlength: 10
                    },
                    Person$LastName: {
                        required: true,
                        maxlength: 10
                    },
                    Person$Email: {
                        email: true
                    }
                },
                messages: {
                    Person$FirstName: {
                        required: "Введите имя",
                        maxlength: "Слишком длинное имя"
                    },
                    Person$LastName: {
                        required: "Введите фамилию",
                        maxlength: "Слишком длинная фамилия"
                    },
                    Person$Email: {
                        email: "Введите правильный емейл"
                    }
                }
            }
        )
    }
);

Как видим, в отличии от серверных контролов для проверки длины поля и его соответствия шаблону используются готовые спецификаторы maxLength и email. Далее мы увидим, как констрейнты в нашем DSL спроектированны таким образом, что они могут без труда интерпретироваться и как RegularExpressionValidator с конкретным паттерном, и как спецификаторы maxlength и email.

Автоматизация генерации кода

Стандартным решением для пододбных задач являтся использование паттерн Visitor. Именно реализацию этого паттерна все желающие могут найти в недрах исходников LINQ в виде абстрактного intenal-класса ExpressionVisitor. Итак, напомним, в нашем примере синтаксическая структура DSL описывается иерархией констрейнтов. Поэтому разработаем абстрактный Visitor с реализациями метода Visit для каждого из типов констрейнтов и добавим две реализации для каждого из типов валидации:

2_Visitors

"Кирпичиком" наших валидаторов является инетрфейс IRule. Модифицируем его так, чтобы он выполнял роль Element для паттерна Visitor:

3_ValidatorElement 

При этом реализация метода Accept для каждого из констрейнтов будет выглядеть почти одинаково:

public class StringNotNullOrEmptyConstraint : IConstraint
{
    ....
    public void Accept(IValidatorVisitor visitor)
    {
        visitor.Visit(this);
    }
}

Т.е. единственная польза от этого метода - полиморфная диспетчеризация вызовов.

Точкой входа для всей конструкции является метод VisitValidator. Этот метод получает на вход список валидаторов, которые необходимо "посетить", перебирая их, вызывает метод Accept. А сами констрейнты "возвращают" вызовы в Visitor через метод Visit, как было показано выше.

Реализация метода VisitValidator для WebFormValidatorVisitor:

public void VisitValidator(IEnumerable<IRulesGroup> rulesGroups)
{
    foreach (var validator in rulesGroups)
        foreach (var rule in validator.Rules)
            rule.Accept(this);
}

Обернув логику связывания в некие классы-хелперы, на примере JQueryValidatorVisitor код использования будет выглядеть так:

<head runat="server">
    
    <script type="text/javascript" src="jquery-1.2.6.js"></script>
    <script type="text/javascript" src="jquery.validate.js"></script>

    <%= ValidateForm("PersonForm") %>
    
</head>

Реализация метода ValidateForm для случая с использованием JQueryValidatorVisitor:

public string ValidateForm(string fortmId)
{
    var script = new StringBuilder();

    IValidatorVisitor visitor = new JQueryValidatorVisitor(script, fortmId);
    visitor.VisitValidator(CreateValidators());

    return string.Format("<script type=\"text/javascript\">\n{0}</script>", script);
}

Методы Visit в свою очередь обрабатывают каждый конкретный констрейнт, "накапливая" и возвращая результат инерпретации клиентскому коду. Например, вот так вот выглядит код одного из методов класса WebFormValidatorVisitor:

public void Visit(StringNotNullOrEmptyConstraint constraint)
{
    var validator = new RequiredFieldValidator
                        {
                            ErrorMessage = _currentRule.Message,
                            Display = ValidatorDisplay.Dynamic,
                            ControlToValidate = _controlToValidate.ID
                        };
    var container = _controlToValidate.Parent.Controls;
    container.AddAt(_controlIndex + 1, validator);
}

Заключение

В последних двух постах мы увидели лишь верхушку огромного айсберга, под названием DSL. Эта тема сейчас активно разрабатывается многими крупными игроками на рынке средств разработки. Главное направление этих изысканий - упрощение разработки и внедрения DSL в приложения. Например, Microsoft на последней PDC анонсировала интересный продукт, под названием Oslo. Хороший обзор написал Мартин Фаулер. Кроме этого, старина Мартин работает над книгой по DSL. Как обычно, это будет каталог паттернов, с которым уже можно познакомиться у него на сайте. А еще, совсем недавно JetBrains выпустили бета-релиз своего нового продукта Meta Programming System, который решает схожие задачи с Microsoft Oslo. И что-то мне подсказывает, впереди нас ждет еще много интересного.

Как всегда, полную реализацию можно изучить, скачав рабочий пример.

среда, 22 октября 2008 г.

Domain Specific Language в своем приложении - это просто

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

Один из подходов, который упрощает задачи разработки бизнес-логики в таких условиях является использование специальных проблемно-ориентированных языков (DSL). Теория таких языков - это самостоятельная большая тема для изучения. Я же хочу на примере шаг за шагом показать все типичные этапы разработки DSL для валидации данных. И это будет действительно просто :)

Описание предметной области

Задача валидации данных хорошо известна всем всем разработчикам. Будь то данные, которые пользователи вводят на формах, сохраняют в базу, или отправляют на внешние сервисы - все они должны удовлетворять каким-то специальным правилам, быть правильно отформатированными, быть обязательными и т.д.

Данные проверяются разными способами по одинаковым правилам в разных частях приложения. Кроме того, различным способом обрабатываются и ошибки. А в других случаях они просто предотвращаются.

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

Рабочий пример

Определим в нашем приложении класс Product следующим образом:

public class Product
{
   public int Id { get; set; }
   public string Title { get; set; }
   public string Description { get; set; }
}

Экземпляры этого класса будут сохраняться в базу данных в таблицу Product:

CREATE TABLE Product
(
   [Id] INT IDENTITY PRIMARY KEY,
   [Title] NVARCHAR(5) NOT NULL,
   [Description] NVARCHAR(20) NOT NULL
)

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

С учетом всех этих требований, реализуем метод контроллера формы, который будет проверять входные данные и сохранять их в базу таким образом:

public void Save(Product product)
{
   // Проверяем соблюдене правил

   if (string.IsNullOrEmpty(product.Title))
       ThrowValidationException("Название не может быть пустым");

   if (product.Title.Length > 5)
       ThrowValidationException("Слишком длинное название");

   if (string.IsNullOrEmpty(product.Description))
       ThrowValidationException("Описание не может быть пустым");

   if (product.Description.Length > 10 && !Confirm("Описание слишком длинное и при публикации будет обрезано до 10 симвалов. Вы согласны?"))
       return;

   // Если все правила удовлетворяются, сохраняем в базу данных
   SaveToDatabase(product);   
}

Кроме этого в коде формы для нужных полей установлены валидаторы:

Название:
<asp:TextBox ID="ProductTitle" runat="server" MaxLength="5" />
<asp:RequiredFieldValidator ID="ProductTitleRequiredValidator" runat="server"
   ControlToValidate="ProductTitle" ErrorMessage="Название не может быть пустым" />
<br />
Описание:
<asp:TextBox ID="ProductDescription" runat="server" MaxLength="10" />
<asp:RequiredFieldValidator ID="ProductDescriptionRequiredField" runat="server"
   ControlToValidate="ProductTitle" ErrorMessage="Описание не может быть пустым" />

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

Унификация логики валидации. Внутренний DSL

Целью наших изысканий является приведение кода контроллера к такому виду:

public void Save(Product product)
{
   IValidator validator = ValidationFactory.CreateValidatorFor<Product>();

   if(validator.IsValid(product))
       SaveToDatabase(product);   
}

Интерфейс IValidator предоставляет функциональность для конструирования логики из неких сущностей-терминов и затем выполняет саму валидацию:

Эта схема является грамматикой нашего будущего DSL. Нужно отметить, что в общем случае имеет смысл разделить логику синтаксиса DSL и его интерпретации. В данном примере речь идет о методах GetBrokenRules, IsValid и SatisfiedBy. Аналогом подобного разделения является пример использования класса System.Linq.Expressions.Expression для описания лямбда-функций в виде абстрактного синтаксического дерева. Это позволяет реализовывать LINQ-провадеры для доступа к разнообразным источникам данных.

Как мы видим на схеме, сама по себе она достаточно бессмысленна, пока у нас не появятся реализации всех этих интерфейсов:

А это уже и есть сам DSL. Комбинируя экземпляры этих классов по правилам на предыдущей схеме, мы сможем описать логику валидации для нужных нам типов.

Для реализации IConstraint мы могли бы воспользоваться паттерном Specification для созания сложных констрейнтов. Например, вместо добавления класса RangeConstraint можно было бы собрать его из ранее добавленых GreaterThanConstraint и LessThanConstraint, связав их через AndConstraint.

Итак, используя наш DSL мы можем создавать валидаторы таким образом:

var validator = new TypeValidator<Product>();

validator.AddRule(new Rule(
   new PropertyValueConstraint<Product>(
       p => p.Title, new StringNotNullOrEmptyConstraint()))
           { Message = "Заголовок не может быть пустым" });

validator.AddRule(new Rule(
   new PropertyValueConstraint<Product>(
       p => p.Title, new StringMaxLengthConstraint(5)))
           { Message = "Заголовок слишком длинный" });

validator.AddRule(new Rule(
   new PropertyValueConstraint<Product>(
       p => p.Description, new StringNotNullOrEmptyConstraint()))
           { Message = "Описание не может быть пустым" });

validator.AddRule(new Rule(
   new PropertyValueConstraint<Product>(
       p => p.Description, new StringMaxLengthConstraint(10)))
           { Message = "Описание слишком длинное", Severity = Severity.Warning });

Уже сейчас мы можем реализовать фабрику ValidatorFactory, которая будет создавать экземпляры валидаторов для нужных типов, или, используя паттерн Visitor можно компилировать структуру валидаторов в код проверки пользовательских данных на форме.

Такой тип DSL называется внутренним: язык, имеет четкую структуру, связанную с данной предметной областью, механизм интерпретации и он реализован внутри основного языка программирования с использованием его синтаксиса.

Улучшение синтаксиса

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

Очень удобным и быстрым способом улучшения синтаксиса является использование "текучего интерфейса" (fluent interface).

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

var validator = DefineValidator.For<Product>()
   .WhereProperty(p => p.Title).SatisfiedAs(Should.NotBeNullOrEmpty)
       .WithReason("Заголовок не может быть пустым").AsError()
   .WhereProperty(p => p.Title).SatisfiedAs(Should.NotBeLongerThan(5))
       .WithReason("Заголовок слишком длинный")
   .WhereProperty(p => p.Description).SatisfiedAs(Should.NotBeNullOrEmpty)
       .WithReason("Описание не может быть пустым")
   .WhereProperty(p => p.Description).SatisfiedAs(Should.NotBeLongerThan(10))
       .WithReason("Описание слишком длинное").AsWarning();

Важным моментом тут является то, что обновленный синтаксис максимально приближен к естественному языку и нам практически ничего не стоит разработать внешний DSL.

Реализация внешнего DSL.

Внешним называется DSL, который имеет свой собственный синтаксис, для него реализован парсер, свой интерпретатор или компилятор. Программы на таких языках можно встраивать в другие языки. Классическим примером такого языка является язык регулярных выражений.

Перед тем, как начать разработку внешнего DSL, попробуем трансформировать код создания валидатора, приведенный выше в синтаксис, который был бы удобен нам, если бы у нас не было синтаксических правил основного языка:

validator for Product
  property Title should not be null or empty
     with reason "Заголовок не может быть пустым"
  property Title should not be longer than 5
     with reason "Заголовок слишком длинный"
  property Description should not be null or empty
     with reason "Описание не может быть пустым"
  property Description should not be longer than 10
     with reason "Описание слишком длинное" as warning

Такое приведение к естественному языку позволяет нам подключить к работе над проектом людей, весьма далеких от программирования: аналитиков, экспертов по предметной области, заказчиков. Последний пункт особенно важен в случае agile-разработки, когда заказчик является полноценным членом команды. Или же если в проекте применяется Domain Driven Design, то паттерн Ubiquitous Language можно реализовать в виде некого DSL.

Тут стоит вернутся к предыдущему разделу и еще раз подчеркнуть, что реализация компилятора для такого DSL может быть реализована простым методом замены, благодаря введенным синтаксическим улучшениям.

В своей статье Oren Eini описывает различные способы реализации DSL в среде CLR, приводя примеры и делая вывод, что для этих целей от отдает предпочтение языку Boo. Я же в качестве альтернативы приведу пример реализации такого компилятора подстановками на языке Nemerle:

namespace Rules.Dsl
{
   [assembly: OperatorAttribute ("Rules.Dsl", "validator_for", true, 180, 181)]
   macro validator_for(typeName)
   {
       <[ DefineValidator.For.[$typeName]() ]>
   }

   [assembly: OperatorAttribute ("Rules.Dsl", "where_property", false, 160, 161)]
   macro where_property(validatorRef, propertyName)
   {
       <[ $validatorRef.WhereProperty(x => x.$(propertyName.ToString() : dyn) ) ]>
   }

   [assembly: OperatorAttribute ("Rules.Dsl", "should", false, 150, 151)]
   macro should(propertyElementRef, constraintRef)
   {
       <[ $propertyElementRef.SatisfiedAs($constraintRef) ]>
   }

   [assembly: OperatorAttribute ("Rules.Dsl", "with_reason", false, 140, 141)]
   macro with_reason(validatorRef, message)
   {
       <[ $validatorRef.WithReason($message) ]>
   }

   [assembly: OperatorAttribute ("Rules.Dsl", "as_error", true, 140, 141)]
   macro as_error(validatorRef)
   {
       <[ $validatorRef.AsError() ]>
   }

   [assembly: OperatorAttribute ("Rules.Dsl", "as_warning", true, 140, 141)]
   macro as_warning(validatorRef)
   {
       <[ $validatorRef.AsWarning() ]>
   }

   macro not_be_null_or_empty()
   syntax("not_be_null_or_empty")
   {
       <[ Should.NotBeNullOrEmpty ]>
   }

   macro not_be_longer_than(maxLength)
   {
       <[ Should.NotBeLongerThan($maxLength) ]>
   }
}

Код валидатора для класса Product на Nemerle с использованием этих макросов будет выглядеть так:

def validator =
   validator_for Product
       where_property Title should not_be_null_or_empty
           with_reason "Заголовок не может быть пустым" as_error
       where_property Title should not_be_longer_than(5)
           with_reason "Заголовок слишком длинный" as_error
       where_property Description should not_be_null_or_empty
           with_reason "Описание не может быть пустым" as_error
       where_property Description should not_be_longer_than(10)
           with_reason "Описание слишком длинное" as_warning;

Синтаксис немного отличается от спроектированного, но он так же выразителен :)

Что дальше?

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

В приведеном в самом начале статьи примере "дефектного" кода мы определили три места, где дублируются данные о правилах валидации. И до сих пор мы говорили о решении проблемы дублирования только на уровне кода самого приложения (контроллеры и формы), но напрочь забыли о базе данных.

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

Или же если у нас структура базы данных генерируется на основании описанной модели предметной области, то мы так же можем использовать код валидаторов для определения типов и размерностей полей в таблицах.

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

Для всех интересующихся доступен исходный код примера.

UPDATE: О том, как встраивать этот DSL в html-форму читаем следующий пост.

воскресенье, 28 сентября 2008 г.

Хороший дизайн должен быть SOLID: TOP-5 архитектурных принципов

Что такое хороший дизайн? По каким критериям его оценивать, и каких правил придерживаться при разработке? Как обеспечить достаточный уровень гибкости, связанности, управляемости, стабильности и понятности кода? Роберт Мартин составил список, состоящий всего из пяти правил хорошего проектирования, которые известны, как принципы SOLID.

Достичь такой лаконичности удалось, использовав небольшую хитрость: дело в том, что термин SOLID - это аббревиатура, которая в свою очередь состоит из аббревиатур, за каждой из которых прячется целый класс паттернов. Ниже мы рассмотрим каждый из них.

SRP: Single Responsibility Principle (принцип единственной обязанности)

Не должно существовать более одного мотива для изменения данного класса.

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

Рассмотрим пример. Пусть у нас есть класс, реализующий некоторую функциональность, связанную с банковским счетом:

class Account : ActiveRecord
{
    public Guid Id{ get{ ... } }
    public string Number { get{ ... } }
    public decimal CurrentBallance { get { ... } }
    public void Deposit(decimal amount){ ... }
    public void Withdraw(decimal amount){ ... }   
    public void Transfer(decimal amount, Account recipient){ ... }
    public TaxTable CalculateTaxes(int year){ ... }
}

Как видно, класс несет ответственность за:

  1. Персистентность;
  2. Логику управление балансом;
  3. Логику расчета налогов.

Все эти "миссии" и являются теми самыми мотивами, которые влияют на жизненный цикл класса. Проведя рефакторинг, можно получить следующий код:

class Account
{
    public string Number { get{ ... } }
    public decimal CurrentBallance { get { ... } }
    public void Deposit(decimal amount){ ... }
    public void Withdraw(decimal amount){ ... }
    public void Transfer(decimal amount, Account recipient){ ... }
}

class AccountRepository
{
    public Account GetByNumber(string number){ ... }
    public void Save(Account acc){ ... }
}

class TaxCalculator
{
    public TaxTable CalculateTaxes(Account acc, int year){ ... }
}

А сложность в применении данного принципа заключается в том, что прежде всего нужно научиться правильно чувствовать границы его использования. Ведь даже в приведенном примере мы превратили паттерн Active Record в антипаттерн, разом перечеркнув все примеры его успешного применения.

OCP: Open/Closed Principle (принцип открытия/закрытия)

Объекты проектирования (классы, функции, модули и т.д.) должны быть открыты для расширения, но закрыты для модификации.

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

Рассмотрим простой пример. Пусть у нас в системе есть некий класс, отвечающий за просмотр логов:

class LogViewer
{
    public IEnumerable<Transaction> GetByDate(DateTime dateTime){ ... }
}

В один прекрасный день возникла необходимость реализовать возможность выборки транзакций по имени пользователя. Класс может быть модифицирован следующим образом:

class LogViewer
{
    public IEnumerable<Transaction> GetByDate(DateTime dateTime){ ... }
    public IEnumerable<Transaction> GetByUser(string name){ ... }
    public IEnumerable<Transaction> GetByDateAndUser(DateTime dateTime, string name){ ... }
}

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

class LogViewer
{
    public IEnumerable<Transaction> GetTransaction(GetSpecification spec){ ... }
}

abstract class GetSpecification
{
    public GetSpecification CombineWith(GetSpecification nextSpec){ ... }
    // ...
}

class GetByDateSpecification : GetSpecification
{
    // ...
}

class GetByUserSpecification : GetSpecification
{
    // ...
}

// Пример использования
class Client
{
    public void ShowLog()
    {
        var viewer = new LogViewer();
        var transactions = viewer.GetTransaction(
            new GetByDateSpecification()
            .CombineWith(new GetByUserSpecification()));
    }
}

Итак, можно сказать, что объект, спроектированный по принципу открытия/закрытия обладает следующими атрибутами:

  1. "Открыт для расширения": поведение может быть расширено путем добавления новых объектов, реализующих новые аспекты поведения;
  2. "Закрыт для модификации": в результате расширения поведения исходный или двоичный код объекта не может быть изменен.

LSP: Liskov Substitution Principle (принцип замещения Лисков)

Функции, которые используют ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом.

Впервые этот принцип был упомянут Барбарой Лисков в 1987 году на научной конференции, посвященной объектно-ориентированному программированию.

Этот принцип является важнейшим критерием для оценки качества принимаемых решений при построении иерархий наследования. Сформулировать его можно в виде простого правила: тип S будет подтипом Т тогда и только тогда, когда каждому объекту oS типа S соответствует некий объект oT типа T таким образом, что для всех программ P, реализованных в терминах T, поведение P не будет меняться, если oT заменить на oS.

Классическим примером нарушения этого принципа является построение иерархии такого рода:

class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }

    public int CalculateRectangleArea()
    {
        return Width*Height;
    }
}

class Square : Rectangle
{
    public override int Height
    {
        get{ return base.Height; }
        set
        {
            base.Height = value;
            base.Width = value;
        }
    }

    public override int Width
    {
        get{ return base.Width; }
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }
}

class Program
{
    private static Rectangle CreateRecatgle()
    {
        return new Square();
    }

    static void Main()
    {
        Rectangle r = CreateRecatgle();
        r.Width = 3;
        r.Height = 2;

        Assert.AreEqual(6, r.CalculateRectangleArea());
    }
}

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

Задавая себе эти вопросы и находя ответы, можно спроектировать систему, которая действительно будет удовлетворять принципу замещения Лисков.

ISP: Interface Segregation Principle (принцип изоляции интерфейса)

Клиент не должен вынужденно зависеть от элементов интерфейса, которые он не использует.

Другими словами этот принцип можно сформулировать так: зависимость между классами должна быть ограничена как можно более узким интерфейсом.

Пример нарушения этого принципа:

abstract class ServiceClient
{
    public string ServiceUri{ get; set; }
    public abstract void SendData(object data);
    public abstract void Flush();
}

class HttpServiceClient : ServiceClient
{
    public override void SendData(object data)
    {
        var channel = OpenChannel(ServiceUri);
        channel.Send(data);
    }

    public override void Flush()
    {
        // Метод ничего не делает, но присутствует в классе
    }
}

class BufferingHttpServiceClient : ServiceClient
{
    public override void SendData(object data)
    {
        Buffer.Write(data);
    }

    public override void Flush()
    {
        var channel = OpenChannel(ServiceUri);
        channel.Send(Buffer.GetAll());
    }
}

Решение этой проблемы заключается в проектировании грамотной иерархии интерфейсов для уменьшения такой зависимости:

>abstract class ServiceClient
{
    public string ServiceUri{ get; set; }
    public abstract void SendData(object data);
}

abstract class BufferingServiceClient : ServiceClient
{
    public abstract void Flush();
}

class HttpServiceClient : ServiceClient
{
    public override void SendData(object data){ ... }
}

class BufferingHttpServiceClient : BufferingServiceClient
{
    public override void SendData(object data){ ... }
    public override void Flush(){ ... }
}

Еще одним признаком потенциального нарушения этого принципа является наличие громоздких интерфейсов. Попробуйте реализовать MembershipProvider для ASP.NET, унаследовавшись от стандартного базового класса и вы поймете о чем речь :)

DIP: Dependency Inversion Principle (принцип обращения зависимости)

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

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

Поэтому можно сделать вывод, что основная причина, по которой проекты так быстро "стареют" и, как правило, даже умирают, заключается в том, что у разработчиков нет возможности безболезненно менять код каких-то компонентов без боязни нарушить работу других. И большинство проблем проектирования, которые выявляются на ранних этапах так и не решаются, накапливаясь лавинообразно.

Дизайн таких систем можно охарактеризовать следующими признаками:

  • Жесткость - изменение одной части кода затрагивает слишком много других частей;
  • Хрупкость - даже незначительное изменение в коде может привести к совершенно неожиданным проблемам;
  • Неподвижность - никакая из частей приложения не может быть легко выделена и повторно использована.

Принцип обращения зависимости - это очень мощный инструмент, который в сочетании с другими SOLID-принципами позволяет разрабатывать дизайн систем так же легко, как если бы он собирался из конструктора LEGO.

Как обычно, для начала рассмотрим проблемный пример кода. Пусть нам нужно разработать класс OrderProcessor, которые выполняет одну простую вещь: рассчитывает стоимость заказа, учитывая возможную скидку и добавляя налоги, в зависимости от страны, в которой выполняется заказ. В первом приближении у нас может получиться такой код:

public class OrderProcessor
{
    public decimal CalculateTotal(Order order)
    {
        decimal itemTotal = order.GetItemTotal();
        decimal discountAmount = DiscountCalculator.CalculateDiscount(order);

        decimal taxAmount = 0.0M;

        if (order.Country == "US")
            taxAmount = FindTaxAmount(order);
        else if (order.Country == "UK")
            taxAmount = FindVatAmount(order);

        decimal total = itemTotal - discountAmount + taxAmount;

        return total;
    }

    private decimal FindVatAmount(Order order)
    {
        return 10.0M;
    }

    private decimal FindTaxAmount(Order order)
    {
        return 12.0M;
    }
}

Возвращаясь к принципу единственной обязанности, перечислим все обязанности, которые выполняет класс OrderProcessor:

  • Знает, как вычислить сумму заказа;
  • Знает, как и каким калькулятором вычислить сумму скидки;
  • Знает, что означают коды стран;
  • Знает, каким образом вычислить сумму налога для той или иной страны;
  • Знает формулу, по которой из всех слагаемых вычисляется стоимость заказа.

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

// Интерфейсы стратегий
public interface IDiscountCalculator
{
    decimal CalculateDiscount(Order order);
}

public interface ITaxStrategy
{
    decimal FindTaxAmount(Order order);
}

// Реализация стратегий
public class DiscountCalculatorAdapter : IDiscountCalculator
{
    public decimal CalculateDiscount(Order order)
    {
        return DiscountCalculator.CalculateDiscount(order);
    }
}

public class USTaxStrategy : ITaxStrategy
{
    public decimal FindTaxAmount(Order order){ ... }
}

public class UKTaxStrategy : ITaxStrategy
{
    public decimal FindTaxAmount(Order order){ ... }
}

// Облегченный код
public class OrderProcessor
{
    private readonly IDiscountCalculator _discountCalculator;
    private readonly ITaxStrategy _taxStrategy;

    public OrderProcessor(IDiscountCalculator discountCalculator, 
                          ITaxStrategy taxStrategy)
    {
        _taxStrategy = taxStrategy;
        _discountCalculator = discountCalculator;
    }

    public decimal CalculateTotal(Order order)
    {
        decimal itemTotal = order.GetItemTotal();
        decimal discountAmount = _discountCalculator.CalculateDiscount(order);

        decimal taxAmount = _taxStrategy.FindTaxAmount(order);

        decimal total = itemTotal - discountAmount + taxAmount;

        return total;
    }
}

Принцип обращения зависимостей лежит в основе архитектур многих каркасов приложений. Для автоматизации процесса управления зависимостями разработано множество утилит. Мартин Фаулер в своей статье рассмотрел всевозможные паттерны реализации механизма работы этого принципа.

Заключение

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

Отдельного внимания заслуживает подход к разработке через тестирование (TDD) и его связь с SOLID-принципами. Для многих именно незнание этих принципов является той самой непреодолимой стеной, которая мешает начать использовать TDD. Но во многом, это мнение является ошибочным, потому как разработка юнит-тестов сама по себе не является сложной задачей и начать писать тесты может каждый на любом этапе своего развития. Главное начать. И уже потом, в процессе поиска оптимальных способов разработки через тестирование будет достигнуто глубокое понимание всех тонкостей качественного проектирования.

среда, 10 сентября 2008 г.

Expression Trees и оптимизация Reflection

В последней версии .NET Framework среди новых возможностей было добавлено средства метапрограммирования под названием Expression Trees. На базе этой технологии, а именно основываясь на том принципе, что выражения на "обычном" языке программирования могут автоматически преобразовываться в синтаксические деревья, была разработана технология LINQ.

Но в этом посте речь пойдет о другой области применения возможности динамически собирать expression trees и компилировать их в работоспособный код. И эта область - оптимизация Reflection.

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

Но динамические Expression Trees предоставляют нам еще достаточно элегантный способ оптимизации. Суть его заключается в том, что для доступа к свойствам экземпляров известного класса мы будем генерировать соответствующий строго типизированный код в виде лямбда-функции, которая будет обращаться к ним напрямую и которую мы будем кэшировать для последующего повторного использования.

Сам по себе метод создания нужной лямбда-функции достаточно прост:

private static Func<object, object> CreateGetter(object entity, string propertyName)
{
    var param = Expression.Parameter(typeof (object), "e");
    Expression body = Expression.PropertyOrField(Expression.TypeAs(param, entity.GetType()), propertyName);
    var getterExpression = Expression.Lambda<Func<object, object>>(body, param);
    return getterExpression.Compile();
}

Если поставить в этом методе точку останова и посмотреть на строковое представление переменной getterExpression, то мы увидим, во что оно будет скомпилировано:

getterExpression

Обернем всю логику доступа к свойству класса в некий ReflectionHelper, который в дальнейшем можно будет расширить методами для вызова методов, инициализации свойств и т.д. Этот класс будет реализовывать метод GetPropertyValue следующим образом:

readonly Dictionary<PropertyGetterKey, Func<object, object>> propertyGetters = new Dictionary<PropertyGetterKey, Func<object, object>>();

public object GetPropertyValue(object entity, string propertyName)
{
    Func<object, object> getter;

    var key = new PropertyGetterKey {Type = entity.GetType(), PropertyName = propertyName};

    if (propertyGetters.ContainsKey(key))
        getter = propertyGetters[key];
    else
    {
        getter = CreateGetter(entity, propertyName);
        propertyGetters.Add(key, getter);
    }

    return getter(entity);
}

Для проверки того, насколько эта логика эффективна, разработаем небольшой тест:

var entities = new List<Class1>();

for (var i = 0; i < 20; i++)
    entities.Add(new Class1 { Property1 = "Value" + i });

foreach (var entity in entities)
{
    var start = DateTime.Now.Millisecond;
    var val = ReflectionHelper.Instance.GetPropertyValue(entity, "Property1");
    Console.WriteLine("{0} - {1}", val, (DateTime.Now.Millisecond - start));
}

Ну и результаты говорят сами за себя:

screenshot002

Как видим, такой способ оптимизации более чем жизнеспособен :)

воскресенье, 7 сентября 2008 г.

Интеграция данных: REST + LINQ = ADO.NET Data Services

Одним из интересных аспектов SOA является интеграция данных. Выдержка из Wikipedia:

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

Необходимость в таком виде интеграции возникла в одном из текущих проектов. Со стороны разработчика хотелось получить максимально прозрачный API, не зависящий от способа реализации и предоставляющий удобный и максимально "родной" механизм работы с данными.

Введение

Главный вопрос, на который пришлось найти ответ - это технология открытия доступа к удаленной базе данных. Выбор пал на технологию REST. Вкратце, она позволяет нам получать нужные данные, формируя запросы в виде GET-параметров к сервису. Дополнительно к этому хотелось бы получить транслятор с LINQ к REST, используя LINQ как раз как тот самый унифицированный и "родной" механизм, который позволит связать воедино данные из любых источников, для которых реализована соответствующая библиотека Linq2***.

Поиски соответствующей библиотеки были недолгими: не так давно Microsoft выпустило в составе .NET 3.5 SP1 свой проект Astoria, назвав его ADO.NET Data Services. С одной стороны эта библиотека позволяет легко создавать сервисы данных, открывающие доступ к данным на сервере посредством протокола REST. А с другой стороны, предоставляет удобные средства интеграции этих сервисов с клиентскими приложениями, оборачивая всю рутину по доступу к сервису в автосгенерированные прокси-классы. Ну и конечно же эти клиентские прокси-классы реализуют LINQ-транслятор. Кроме того, сервисы данных реализуются на базе стека WCF, предоставляя широкие возможности по тонкой низкоуровневой настройке.

Реализация сервиса

Для начала добавим в веб-проект сервис данных:

Add New Item - ADO.NET Data Service

Исходный код сервиса будет таким:

public class WebDataService : DataService< /* TODO: put your data source class name here */ >
{
    // This method is called only once to initialize service-wide policies.
    public static void InitializeService(IDataServiceConfiguration config)
    {
        // TODO: set rules to indicate which entity sets and service operations are visible, updatable, etc.
        // Examples:
        // config.SetEntitySetAccessRule("MyEntityset", EntitySetRights.AllRead);
        // config.SetServiceOperationAccessRule("MyServiceOperation", ServiceOperationRights.All);
    }
}

Если вы обратитесь к спецификации класса DataService<T>, то увидите, что к типу Т не предоставляются такие требования, как реализация какого-либо инетрфейса. Все дело в том, что необходимые требования невозможно описать в виде какой-либо простой модели, которая бы не усложнила разработку этих сервисов. Но требования, тем не менее, есть:

  1. Сервис данных может опубликовывать все IQueryable-свойства данного источника данных.
    Наш источник данных может выглядеть таким образом:
    public class DomainDataContext
    {
        private readonly Supplier[] _suppliers;
        private readonly Product[] _products;
    
        public DomainDataContext()
        {
            // Инициализация _suppliers и _products тестовыми данными
        }
    
        public IQueryable<Supplier> Suppliers
        {
            get { return _suppliers.AsQueryable(); }
        }
    
        public IQueryable<Product> Products
        {
            get { return _products.AsQueryable(); }
        }
    }
  2. Классы-сущности должны быть "идентифицируемыми". 
    Это правило в данном случае означает, что в классах обязательно должны быть свойства, заканчивающиеся на ID:
    public class Product
    {
        public string ProductID { get; set; }
        public string SupplierID { get; set; }
        public string ProductName { get; set; }
    }
    
    public class Supplier
    {
        public string SupplierID { get; set; }
        public string CompanyName { get; set; }
        public string ContactName { get; set; }
        public string Phone { get; set; }
    } 

И это не удивительно, что в качестве источников данных можно использовать модели Linq2Sql и ADO.NET Entity Framework.

Посмотрев на наш сервис в браузере, мы увидим такой XML:

<?xml version="1.0" encoding="utf-8" standalone="yes" ?> 
<service xml:base="http://localhost:7593/WebDataService.svc/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app" xmlns="http://www.w3.org/2007/app">
   <workspace>
      <atom:title>Default</atom:title> 
   </workspace>
</service>

Никаких упоминаний о наших Products и Suppliers. Мы забыли сконфигурировать наш сервис, указав доступ к коллекциям объектов и операциям. Но так как наш сервис не предоставляет никаких методов для манипуляции данными, нам нужно сконфигурировать только доступ к коллекциям в источнике данных:

public static void InitializeService(IDataServiceConfiguration config)
{
    config.SetEntitySetAccessRule("*", EntitySetRights.AllRead);
}

Открываем сервис в браузере и видим наши коллекции:

<?xml version="1.0" encoding="windows-1251" standalone="yes"?>
<service xml:base="http://localhost:7593/WebDataService.svc/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:app="http://www.w3.org/2007/app" xmlns="http://www.w3.org/2007/app">
  <workspace>
    <atom:title>Default</atom:title>
    <collection href="Suppliers">
      <atom:title>Suppliers</atom:title>
    </collection>
    <collection href="Products">
      <atom:title>Products</atom:title>
    </collection>
  </workspace>
</service>

Итак, наш сервис готов к использованию.

Реализация клиента

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

В качестве примера реализуем логику поиска поставщиков по названию товара. Локально поиск будет проводиться по базе Northwind с использованием Linq2Sql, а потом результат локального поиска будет объединяться с поиском через сервис данных.

Локальная схема данных будет выглядеть следующим образом:

Local data schema

Поисковый запрос к локальной базе достаточно тривиален:

var localData = new LocalDataDataContext();

var localSuppliers = from p in localData.Products
                     join s in localData.Suppliers on p.SupplierID equals s.SupplierID
                     where p.ProductName == productName
                     select
                         new 
                         {
                             Company = s.CompanyName,
                             PhoneNumber = s.Phone
                         };

Попробуем сделать то же самое для поиска по удаленному сервису данных:

var serviceData = new DomainDataContext(new Uri("http://localhost:7593/WebDataService.svc"));

var dataServiceSuppliers = from p in serviceData.Products
                           join s in serviceData.Suppliers on p.SupplierID equals s.SupplierID
                           where p.ProductName == productName
                           select
                               new
                                   {
                                       Company = s.CompanyName,
                                       PhoneNumber = s.Phone
                                   };

При попытке получить данные по этому запросу мы получим исключение "The method 'Join' is not supported.". По сути, это Законы дырявых асбстракций в действии :) Все дело в том, что не вся функциональность Linq однозначно транслируется в REST-запросы. REST-сервисы предоставляют лишь несколько параметров запросов: expand, orderby, skip, top, filter. Как видно, никаких inner join и даже агрегатов, типа банального count тут нет.

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

var serviceData = new DomainDataContext(new Uri("http://localhost:7593/WebDataService.svc"));

var matchedProducts = from p in serviceData.Products
                                        where p.ProductName == productName
                                        select p;

Следующий фрагмент кода динамически соберет условие для параметра filter так, чтобы получилось что-то в таком духе:

Func<ServiceReference1.Supplier, bool> filterCondition =
    s => s.SupplierID == "001" || s.SupplierID == "002";

Такой вариант фильтра однозначно транслируется в строку для параметра запроса filter.

var param = Expression.Parameter(typeof(ServiceReference1.Supplier), "s");

Func<string, IEnumerable<string>, Expression> buildOrElseExpression = null;

Func<string, Expression> buildEqualExpression =
    val => Expression.Equal(Expression.Property(param, "SupplierID"), Expression.Constant(val));

Func<IEnumerable<string>, Expression> buildTailExpression = tail => tail.Count() == 1
                                       ? buildEqualExpression(tail.First())
                                       : buildOrElseExpression(tail.First(), tail.Skip(1));

buildOrElseExpression =
    (head, tail) => Expression.OrElse(
                        buildEqualExpression(head),
                        buildTailExpression(tail));

// Вычитываем данные из сервиса и получаем отключенный список идентификаторов поставщиков
var serviceSupplierIDs = matchedProducts.ToArray().Select(p => p.SupplierID);
var filterCondition =
    Expression.Lambda<Func<ServiceReference1.Supplier, bool>>(
    buildTailExpression(serviceSupplierIDs), param);

// Получаем отключенную копию результатов поиска для дальнейших манипуляций
var serviceSuppliers = serviceData.Suppliers.
    Where(filterCondition).
    ToArray().
    Select(s => new
                    {
                        Company = s.CompanyName,
                        PhoneNumber = s.Phone
                    });

 

Ну и наконец-то финальное объединение:

// Перед объединением получаем отключенную копию данных.
// В противном случае Linq2Sql попытается транслировать этот метод в sql-инструкцию UNION
var allSuppliers = localSuppliers.ToArray().Union(serviceSuppliers);

Выводы

Конечно же, ADO.NET Data Services - это решение многих проблем интеграции данных внутри системы. Но использование этой системы накладывает и ряд требований:

  1. Модель данных для таких сервисов должна быть подготовлена специальным образом так, чтобы избежать множества повторяющихся простых запросов из-за ограничения протокола REST. В общем случае, эта подготовка сведется к денормализации исходной модели данных, к укрупнению классов сущностей и введению всяких агрегатных полей с данными из дочерних сущностей.
  2. Для вычисления агрегатных функций (таких, как count(), sum() и т.д.) нужно будет реализовать специальные методы вне контекста сервисов данных.
  3. Все операции проекции должны проводиться на отсоединенной выборке. Таким образом, с учетом требований в п.1 такими запросами не стоит злоупотреблять по причиные избыточности данных, передаваемых через транспортный уровень. Для решения этой проблемы можно было бы расширить нашу модель одинаковыми сущностями с различными уровнями детализации.

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

Исходный код тестового приложения

четверг, 24 июля 2008 г.

NHibernate и валидация

Замечательный проект затеял Dario Quintana в следующей версии библиотеки дополнений NHibernate Contrib:

NHV-logo-white-background

Его предназначение - декларирование и централизованная проверка правил валидации где-нибудь поближе к модели домена.

Например, правила можно описывать в виде атрибутов:

public class User
{
 public virtual int Id
 {
     get { return id; }
 }

 [NotEmpty, NotNull]
 public virtual string UserName
 {
     get { return userName; }
     set { userName = value; }
 }

 [Email]
 public virtual string Email
 {
     get { return email; }
     set { email = value; }
 }

 [Past]
 public DateTime CreatedDate
 {
   get { return createdDate; }
   set { createdDate = value; }
 }

 [Min(18, Message="You are to young!")]
 public int Age
 {
   get { return age; }
   set { age = value; }
 }

 [CreditCardNumber]
 public string CreditCardNumber
 {
   get { return creditCardNumber; }
   set { creditCardNumber = value; }
 }

 ///...
}

Или же, если вам нужна возможность гибкой настройки этих правил, скажем, для согласования с констрейнтами в базе данных, то правила можно определить в файле конфигурации:

<nhv-mapping xmlns="urn:nhibernate-validator-1.0">
 <class name="NHibernate.Validator.Demo.Winforms.Model.Customer, NHibernate.Validator.Demo.Winforms">   
   <property name="FirstName">
     <not-empty/>
     <not-null/>
   </property>   
 
   <property name="Email">
     <email/>
   </property>

   <property name="Zip">
     <pattern  regex="^[A-Z0-9-]+$" message="Examples of valid matches: 234G-34DA | 3432-DF23"/>
     <pattern  regex="^....-....$" message="Must match ....-...."/>
   </property>   
  
 </class> 
</nhv-mapping>

Ну и пример того, как происходит процесс валидации:

Для начала создаем экземпляр валидатора:

NHVConfiguration nhvc = new NHVConfiguration();
nhvc.Properties[Environment.ApplyToDDL] = "false";
nhvc.Properties[Environment.AutoregisterListeners] = "true";
nhvc.Properties[Environment.ValidatorMode] = "UseAttribute";
nhvc.Mappings.Add(new MappingConfiguration("NHibernate.ValidatorDemo.Model", null));

ValidatorEngine validator = new ValidatorEngine();
validator.Configure(nhvc);

Ну и потом пользуемся им:

public void Create([DataBind("user")] User user)
{
 InvalidValue[] errors = validator.Validate(user);

 if (errors.Length > 0)
 {
   Flash["errors"] = errors;
   RedirectToAction("index");
 }
 else
 {
   repository.Create(user);
 }
}

Процесс валидации можно попробовать сделать более прозрачным, например, используя фильтры в ASP.NET MVC, или реализовав какие-то аспекты прямо на уровне модели домена.

P.S. Пост написан мо мотивам этого поста.

понедельник, 7 июля 2008 г.

Распределение сессий ASP.NET с помощью memcached

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

В общем случае, главной проблемой, мешающей клонированию приложения или сервиса на многие сервера с сохранением общей функциональности является использование состояний клиента. Другими словами - использование сессий. Эта проблема связана с чисто программной реализацией сессий поверх несессионного протокола HTTP, когда идентификатор сеанса отправляется в cookies каждому клиенту, а потом по нему сервер, обслуживающий запросы "достает" нужные данные. И по умолчанию, данные сессий хранятся локально в памяти или файловой системе сервера. А в случае, когда приложение "живет" на нескольких серверах, то идентификатор сеанса каждый раз будет обрабатываться разными серверами и сессии утратят свой смысл.

В качестве решения этой проблемы в ASP.NET предлагается использовать особый режимы StateServer и SQLServer, когда состояние будет храниться на удаленном сервере, либо вообще в базе SQL Server. У первого способа главным недостатком является то, что сам по себе сервер состояний не масштабируется вообще. В случае же использования режима SQLServer и размещении базы данных состояний на отдельном кластере, мы получим масштабируемую систему, но в этом случае сильно потеряем в производительности. Конечно же, когда идет о масштабируемости, локальной производительностью можно пренебречь, посчитав, что ее можно увеличить за счет включения лишних боксов в кластер. Но что делать начинающим стартаперам, которые пока еще считают деньги? Как получить систему, относительно быструю, как в случае со StateServer, и масштабируемую, как в режиме работы с SQL Server?

Ответ прост - использование систем распределенной памяти вобще и memcached в частности, как распространенный, проверенный, простой и бесплатный продукт. Познакомиться с ним можно в статье Обзор memcaced. А в этом посте мы попробуем решить с его помощью проблемы с сессиями в ASP.NET.

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

  1. Так как среда обитания наших приложений - это Windows, то скачиваем отсюда или отсюда бинарники, скомпилированые для win32. Распаковываем его куда-нибудь и запустим единственный exe-файл. Сервер memcached запущен :) Весь процесс настройки производится через параметры запуска. Ознакомиться с ними можно запустив сервер с параметром -h.
  2. Теперь нам нужно чем-то подключаться к этому сервису. И в качестве клиента предлагается использовать enyim.com Memcached Client, и его можно использовать уже в составе готовых Memcached Providers, что максимально упрощает использование memcached. Скачав и разархивировав билиотеку куда-нибудь, добавляем в наш проект ссылки на файлы Enyim.Caching.dll, MemcachedProviders.dll и log4net.dll.
  3. В коде приложения необходимо найти все классы, экземпляры которых помещаются в сессию и отметить их атрибутом Serializable.
  4. Осталось дело за малым - сконфигурировать наше приложение должным образом:
    • В разделе configSections регистрируем секции:
      <sectionGroup name="enyim.com">
      <section name="memcached" type="Enyim.Caching.Configuration.MemcachedClientSection, Enyim.Caching" />
      </sectionGroup>
      <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler,log4net"/>
    • Добавляем секцию настройке клиента memcached:
      <enyim.com>
      <memcached>
        <servers>
          <add address="127.0.0.1" port="11211" />
        </servers>
        <socketPool minPoolSize="10" maxPoolSize="100" connectionTimeout="00:00:10" deadTimeout="00:02:00" />
      </memcached>
      </enyim.com>
      Ключевым элементом здесь является секция servers, в которой перечисляются все memcached серверы, обслуживающие приложение. Самым распространенным вариантом использования является установка экземпляра memcached на каждый из серверов, на котором работает приложение.
    • А теперь настраиваем работу сессий:
      <sessionState cookieless="true" regenerateExpiredSessionId="true" mode="Custom" customProvider="MemcachedSessionProvider">
      <providers>
        <add name="MemcachedSessionProvider" type="MemcachedProviders.Session.SessionStateProvider, MemcachedProviders"
              connectionStringName="SqlSessionServices"
              dbType="SQL"
              writeExceptionsToEventLog="false" />
      </providers>
      </sessionState>
      Интересной здесь является строка dbType="SQL" - она указывает, какого типа СУБД будет использоваться для бэкапов данных сессии. Для отключения режима бэкапа данных нужно просто указать dbType="None".
    • Заключительным этапом будет настройка параметров подключения к базе данных для бэкапов. Для этого в секцию connecitonStrings нужно добавить такую запись:
      <add name="SqlSessionServices" connectionString="..."/>
      (троеточие следует заменить на реальную строку подключения).
    • Опционально можно добавить найстройки для log4net.

Осталось запустить приложение.

При обращении к сессии, провайдер обращается к клиенту memcached, который в свою очередь посылает асинхронные запросы на данные всем memcached-серверам, используюя идентификатор сессии, как ключ к данным. И вот здесь происходит самое инетресное: если это не первый запрос к сессии и какие-то данные там уже размещены, но предыдущий запрос обслуживал другой сервер, то клиент, а за ним и провайдер получат данные от другого сервера. А если же предыдущий запрос обслуживался текущим сервером, то он получит данные от от процесса, работающего на этом же сервере, чтобы будет гораздо быстрее, чем если бы он получал их по сети. Таким образом, в случае, если нагрузка равномерно распределяется между серверами, то гарантированное попадание в локальный сервис будет примерно в каждом втором случае. А если же сюда добавить условие, что как браузеры, так и сетевое оборудование оптимизирует работу с соединениями, "придерживая" их в пулле и используя так называемые sticky connections, то количество попаданий будет стремиться к 100%.

В случае, если ни один из сервисов не вернет данных по запросу, провайдер обратится в базу данных. Этот же сценарий будет срабатывать в случае, если ни один из memcached-сервисов не будет запущен. Можно сказать, что в этом случае сессии будут работать так же, как в режиме SQLServer.

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

Практической выгодой от использования этого сервиса является то, что теперь сессией можно пользоваться без оглядки на то, что приложения будут перегружены и все данные пропадут, или же, что это будет ударом по производительности. В качестве типичного варианта использования можно предложить хранить в сессии данные по корзине в инетрнет-магазине.

Недостатоком этой конфигурации является то, что она не работает в случае, когда не доступен SQL Server. Если указать режим бэкапа dbType="None", то в случае недоступности memcached-сервисов будет использоваться стандартный режим работы сессий, когда все данные хранятся в памяти рабочего процесса. Очень хотелось бы, чтобы провайдер стал умнее и стал использовать стандартный режим в случае, если оба хранилища не доступны. Но код провайдера доступен, логика его работы более чем проста и добавление этой функциональности не является проблемой.


скачать пример