Model View Dispatcher (cqrs over mvc)

в 12:53, , рубрики: ASP

image
Доброго всем времени суток, в этой статье хочу осветить ещё один компонент из библиотеки Incoding Framework.
Model View Dispatcher (MVD) — позволяет избавится от избыточного кода (а именно asp.net mvc controller) и упростить навигацию по проекту, уменьшив количество абстракций между клиентским и серверным кодом.

На хабре имеются несколько статей о IML и CQRS, которые входят в состав framework

  1. Знакомство с IML
  2. Incoding CQRS
    примечание: статья была опубликована, но в то время были проблемы с аккаунтом на хабре и она прошла не замеченной
  3. IML vs AngularJs
    примечание: статья подверглась критике (объективной и субъективной)
  4. Ответ на IMl vs AngularJs
    примечание: нельзя назвать успехом, но уже немного лучше

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

Зачем?

Рассмотрим сценарий применения asp.net mvc + cqrs

  • Controller
    public ActionResult Details(GetUserDetailsQuery query)
    {
        var model = dispatcher.Query(query);
        return Json(model);
    }
    

    примечание: action отвечает за binding и передачу query в Dispatcher для возврата полученных данных в виде Json

  • View
    $.get('@Url.Action("Details","Controller")',callback)
    

примечание: не хватает листинга Query для полной картины, но становится очевидно избыточность Action

Диета

Чтобы посадить controller на «диету» можно воспользоватся паттерном Mediator (как один из вариантов), который опирается на Interface и Generic, но это только позволяет упростить и объединить код, но не решить проблему полностью, потому что все равно приходится писать однотипные Controller/Action.

Model View Dispatcher (MVD) — позволяет выполнять Command/Query в «обход» asp.net mvc. Для демонстрации перепишем предыдущую задачу, но с MVD

$.get('@Url.Dispatcher().Query(new GetUserDetailsQuery()).AsJson()',callback)

Подсчитаем бонусы

  • Не нужен Action
  • Проще навигация, потому что теперь действия на странице отражают картину (go to delcaration без посредников) на серверного кода
  • Строгая типизация при построении URL (visual studio intelisence, refactor utilities)

Нет MVC ???

Чтобы получить ответ, посмотрим как подключить MVD к проекту:

Создать DispatcherController (начиная с версии 1.1 устанавливается через nuget), который унаследовать от DispatcherControllerBase

public class DispatcherController : DispatcherControllerBase
{        
    public DispatcherController()
   : base(typeof(T).Assembly)
    {    }    
}

примечание: конструктор принимает Assembly в котором объявлены Command/Query

DispatcherControllerBase содержит следующие методы (Actions):

Query(string incType, string incGeneric, bool? incValidate)
Render(string incView, string incType, string incGeneric)
Push(string incType, string incGeneric)
Composite(string incTypes)
QueryToFile(string incType, string incGeneric, string incContentType, string incFileDownloadName)

пример url, после вызова которого будет выполнен Push command

Url.Action("Push", "Dispatcher", new  { incType = typeof(Command).Name } )

Альтернативный способ через DSL (domain specific language)

Url.Dispatcher().Push(new Command())

примечание: дело не только в лаконичности синтаксиса (хотя это важно), но также в абстракции от деталей (имена параметров и т.д.)

Обратить внимание

  • MVD использует IDispatcher, который зарегистрирован в IoCFactory (пример со StructureMap в качестве провайдера)
  • В качестве ActionResult возвращается IncodingResult, который формирует JSON
    { success: true/false , data:something/null , redirectTo:url/null }

Вывод: текущая реализация MVD опирается на asp.net mvc, по факту просто делает обобщенный (не generic) Controller, но можно использовать httphandler или другой http обработчик в качестве платформы.

Что умеет ?

MVD покрывает большинство сценариев, которые встречаются при веб-разработке на платформе asp.net mvc:
примечание: исходный код примеров на GitHub

  • Push
    Url.Dispatcher().Push(new AddUserCommand
                                              {
                                   Id = "59140B31-8BB2-49BA-AE52-368680D5418A",
                                   Name = "Vlad"
                                              })
    

    примечание: вопросы валидирования далее

  • Push generic
    Url.Dispatcher().Push(new AddEntityCommand<T>())
    

  • Сomposite
    Url.Dispatcher()
         .Push(new AddUserCommand { Id = "1", Name = "Name" })
         .Push(new ApproveUserCommand { UserId = "2" })
    

  • Query as json
    Url.Dispatcher()
         .Query(new GetCurrentDtQuery())
         .AsJson()
    

  • Query generic
    Url.Dispatcher()
         .Query(new GetTypeNameQuery<T>())
         .AsJson()
    

  • Query as view
    Url.Dispatcher()
         .Query(new GetUserQuery())
         .AsView("~/Views/Home/User.cshtml")
    

    примечание: путь к View строится от корневой (в asp.net mvc относительно Controller) директории сайта, что позволяет строить любую структуру папок.

  • Query as file
    <a href="@Url.Dispatcher().Query(new GetFileQuery()).AsFile(incFileDownloadName: "framework")">Download</a>
    

    примечание: требуется реализация QueryBase<byte[]> для Query

  • Model as view
    Url.Dispatcher()
         .Model(new GetUserQuery.Response
                   {
                  Id = "2",
                  Name = "Incoding Framework"
                  })
         .AsView("~/Views/Home/User.cshtml")
    

  • View
    Url.Dispatcher().AsView("~/Views/Home/Template.cshtml")
    

Сценарии

MVD состоит из DispatcherController (серверная часть), который используя инфраструктуру CQRS выполняет Command/Query и Url.Dispatcher (адрес строится на сервере, но далее используется на клиенте) для построения url, но не является самостоятельным компонентом, поэтому примеры будут в контексте с IML.

  • Post
    @(Html.When(JqueryBind.Change)
          .AjaxPost(Url.Dispatcher().Push<AddAcoGroupCommand>(new {Value = Selector.Jquery.Self() } ))
          .OnSuccess(dsl => dsl.Utilities.Window.Alert("Success"))
          .AsHtmlAttributes()
          .ToCheckBox(true))
    

    примечание: в качестве параметра используется анонимный объект, но проверка на соответствие (если Value отсутствует в AddAcoGroupCommand будет exception) полей будет.

  • Post form
    @model AddAcoGroupCommand
    <form action="@Url.Dispatcher().Push(new AddAcoGroupCommand())">
        @Html.HiddenFor(r=>r.Id)
        <input type="submit"/>
    </form>
    

  • Render View
    @(Html.When(JqueryBind.InitIncoding)
          .AjaxGet(Url.Dispatcher().AsView("~/Views/Patient/BenefitListControl.cshtml"))
          .OnSuccess(dsl => dsl.Self().Core().Insert.Html())
          .AsHtmlAttributes()
          .ToDiv())
    

    примечание: удобно для поиска template

    var urlTmpl  = Url.Dispatcher().AsView("~/Views/Medication/MedicationTmpl.cshtml");
    dsl.Self().Core().Insert.WithTemplateByUrl(urlTemplate).Append();
    

  • Render model
    @(Html.When(JqueryBind.InitIncoding)
          .AjaxGet(Url.Dispatcher()
          .Model( new BenefitModel()
                      {
                              GroupName = Selector.Incoding.QueryString<BenefitModel>(r=>r.GroupName),
                              IsPrimary = true
                      })
          .AsView("~/Views/Patient/BenefitListControl.cshtml"))
          .OnSuccess(dsl => dsl.Self().Core().Insert.Html())
          .AsHtmlAttributes()
          .ToDiv())
    

    примечание: возможность использовать Selector (вычисляется на клиенте) при формировании url позволяет не прибегать к построению routes на клиенте.

примечание: больше примеров в Inc-todo

Action Attributes

Это мощный механизм для реализации АОП (Аспектно-ориенти́рованное программирование‎), частым сценарием где он применяется, может быть проверка авторизации, поэтому рассмотрим возможные пути решения:

  • Отметить атрибутом DispatcherController
    примечание: способ очень удобен, если Вы пишите CRM систему и все действия проходя с авторизацией
  • Dispatcher event — поскольку все Command/Query выполняются через единую точку, это позволяет реализовать аналогичное поведение как при Action attributes.
    примечание: акцент с Action сдвигается на Command/Query

Валидация

В рамках Incoding Framework есть готовая инфраструктура для валидации (в отличии от js framework тут Server/Client покрытие) и MVD интегрирован с ней.

@(using(Html.When(JqueryBind.InitIncoding)      
                         .Direct()
                         .OnSuccess(dsl => dsl.Self().Core().Form.Validation.Parse())
                         .When(JqueryBind.Submit)
                         .PreventDefault()
                         .Submit()
                         .OnError(dsl => dsl.Self().Core().Form.Validation.Refresh())
                         .AsHtmlAttributes()
                         .ToBeginForm(Url.Dispatcher().Push(new AddUserCommand()))))
{
 @Html.TextBoxFor(r=>r.Name)
 @Html.ValidationMessageFor(r=>r.Name)
}

примечание: поскольку код будет однотипный для большинства форм, то можно написать html helper
Код знаком большинству разработчиков на asp.net mvc по стандартному Html.BeginForm (можно использовать его передав iml в html attributes), но с несколькими отличиями:

  • При первом появление элемента на странице делаем разбор текущей формы на наличие валидации
  • По событию Submit останавливаем поведение по умолчанию (чтобы форма не отправилась) и используя ajax делаем post на указанный адрес (Url.Dispatcher().Push(new AddUserCommand())
  • В случае возраста ошибки (пользовательской, а не фатальной) в которой обновляем форму на основе Model State (ниже подробней)

Для понимания, как работает метод Validation.Refresh посмотрим реализацию Push в dispatcher contoller

     if (!ModelState.IsValid)
        return IncodingResult.Error(ModelState)

     try
     {
         dispatcher.Push(composite);
         return IncodingResult.Success();
      }
      catch (IncWebException exception)
      {
                foreach (var pairError in exception.Errors)
                {
                    foreach (var errorMessage in pairError.Value)
                        ModelState.AddModelError(pairError.Key, errorMessage);
                }
                return IncodingResult.Error(ModelState)
      }

примечание: реальный код несколько сложнее из-за дополнительной логики, но пользователь Incoding Framework абстрагирован от этих деталей и работает с TryPush или MVD push
В catch мы перехватываем только IncWebException (на основе которого заполняем ModelState), а остальные exception считаем провальными и оставляем их обработку на совесть global.asax.

А как было раньше ?

Ответ поможет проанализировать, чем же решение на базе Incoding Framework лучше того, что имеется в стандартном asp.net mvc

public ActionResult Add(AddUserCommand command)
{
    if (ModelState.IsValid)
        return View(command);

    return Execute(command);
}

Если ModelState содержит ошибки, возвращается View, которое строится на основе command (хорошо, если не используется Container с дополнительными списками, которые тоже придется заново строить), чтобы сохранить состояние. Такое поведение введет к следующим проблема:

  • Заново строится форма, что занимает время и увеличивает трафик
  • Возвращаемый результат является html (пусть и упакованный в json), что не позволяет повторно использовать action, как API для сторонних (мобильных) приложений

Итог

MVD является очень хорошим союзником для борьбы с однотипными Action, которые приводят к «разбуханию» кода, что особенно критично на поздних этапах проекта. MVD можно использовать без IML, но тогда теряется возможность использовать Selector в routes, что негативно скажется на типизации, из-за того, что придется в «ручную» (pure js) собирать параметры.
Конечно, могут быть сценарии с которыми MVD не способен (временно или просто не возможно реализовать) справится, но ничто не мешает написать Controller и Action для конкретных (может это всего 5% — 10%) случаев.

Получается все ради того, чтобы убрать дубляж ?

Если MVD поможет сократить код на 10-15% и ускорить разработку, то это уже очень хороший результат, но мы пошли дальше и реализовали возможность строить схему end point (идея взята у wcf endpoint)…

Исходный код, как документация ?

Диалог между разработчиком api и мобильного приложения:

  • Api — я добавил новый запрос, по адресу /GetUsers?Active=true
  • Api — а также новые поля для создания user ( Comment, City, State )
  • Api — о, чуть не забыл City это справочник и его можно получить по запросу /GetCities
  • Api — и ещё один момент, заказчик просил, чтобы State был числом, так что учти это
  • Мобильное приложение — ок, опиши в документе и назови %sitename%-api-%current-version%
    примечание: основной проблемой является %current-version%, потому что приходится поддерживать актуальность документации, после каждого изменения в коде

Тот же диалог, но с mvd end-points

  • Api — обновил код
  • Мобильное приложение — уже можно смотреть appDomain/Dispatcher/Endpoints?
  • Api — конечно, там кстати песочница где можно проверить command/query
    примечание: песочница, так же удобна и для разработчика API, так как часто разработка идет без UI (user interface) и чтобы быстро проверить на работоспособность (unit test покрывает код, но интеграционные тоже нужны) можно воспользоватся авто-сгенерированной формой

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

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

P.S. опубликована новая версия (пока beta) Incoding Framework в nuget, где появилось много нововведений и доработок. Я приведу несколько, а остальные постараюсь расписать в отдельных статьях (полный список на нашем bugtracker)

  • Упрощения условий
    Break.If(r=>r.Is(()=>Selector.Jquery.Self()).And.Is(()=>"id".ToId()== 12)) // Old
    Break.If(()=>Selector.Jquery.Self() && id.ToId() == 12) // New
    

  • Реализация EF, RavenDB, MongoDb провайдеров (одна command и разные ORM на github и inc-todo-ravendb)
  • Упрощения синтаксиса
    inDsl.Core().JQuery.Attributes.SetAttr(HtmlAttribute.Checked) // Now
    inDsl.Attr.Set(HtmlAttr.Checked) // Future
    

примечание: причина статуса beta (в конце мая уже будет стабильная версия) в том, что ещё не все наши проекты переведены на эту версию и остался ряд задач, которые планировали для этого релиза

Автор: vkopachinsky

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js