среда, 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-форму читаем следующий пост.