Pull to refresh

Приводя в порядок POSTs в ASP.NET MVC

Reading time 8 min
Views 6.6K
Original author: Jimmy Bogard
В топике описывается один из профессиональных приёмов разработки ASP.NET MVC приложений, который позволяет значительно сократить количество повторяющегося кода в обработчиках POST действий форм. Несмотря, на то, что я узнал о нём ещё во времена первого издания ASP.NET MVC In Action и первой mvcConf, стиль изложения Jimmy Bogard мне показался очень простым и я решил опубликовать вольный перевод для тех, кто ещё не использует этот подход на практике.


Многие люди спрашивают, почему в AutoMapper так мало встроенных возможностей для обратного отображения (DTOs -> персистентные объектные модели). Дело в том, что уже существующие возможности сильно ограничивают доменные модели, заставляя их быть анемичными, поэтому мы просто нашли другой способ борьбы со сложностью в наших POST запросах.

Посмотрите на средний/большой ASP.NET MVC сайт, на сложность или размер, и вы заметите, что в реализации проявляются некоторые шаблоны. Вы увидите большую разницу между тем, как выглядят ваши GET и POST действия (actions). Это ожидаемо, т.к. GETs – это запросы (Queries), а POSTs – это команды (Commands) (если вы реализовали их правильно, то это именно так). Вы не обязательно увидите соотношение 1:1 для тегов формы и POST действий, т.к. форма также может быть использована для отправки запросов (например, поисковая форма).

Для GET действий, по моему мнению, проблема уже решена. GET действия создают ViewModel и посылают её виду (view) и используют при этом любое число оптимизаций/абстракций (AutoMapper, привязка модели, проекции на соглашениях и т.д.).

POSTs – это совершенно другой зверь. Вектора сложности в изменяющейся информации и получение/валидация команд полностью ортогональны GETs, что заставляет нас выбросить все наши предыдущие решения. Обычно мы видим что-то типа этого:
[HttpPost]
public ActionResult Edit(ConferenceEditModel form)
{
    if (!ModelState.IsValid)
    {
        return View(form);
    }

    var conf = _repository.GetById(form.Id);

    conf.ChangeName(form.Name);

    foreach (var attendeeEditModel in form.Attendees)
    {
        var attendee = conf.GetAttendee(attendeeEditModel.Id);

        attendee.ChangeName(attendeeEditModel.FirstName, attendeeEditModel.LastName);
        attendee.Email = attendeeEditModel.Email;
    }

    return this.RedirectToAction(c => c.Index(null), "Default");
}

* This source code was highlighted with Source Code Highlighter.

То, что мы видим снова и снова и снова опять – это шаблон похожий на:
[HttpPost]
public ActionResult Edit(SomeEditModel form)
{
    if (IsNotValid)
    {
        return ShowAView(form);
    }

    DoActualWork();

    return RedirectToSuccessPage();
}


* This source code was highlighted with Source Code Highlighter.

Где всё, что отмечено красным, меняется от POST действия к POST действию.

Так почему мы должны беспокоиться об этих действиях? Почему бы не сформировать общий путь выполнения по тому, что мы видим здесь? Вот несколько причин, с которыми мы столкнулись:
  • POST действиям требуют зависимости отличные от GETs и дихотомия между этими видами ведёт к распухания контроллеров (controllers);
  • Желание вносить изменения / улучшения для ВСЕХ POST действий централизовано, как пример, добавление логирования, валидации, авторизации, уведомлений о событиях и т.д;
  • Проблемы тщательно перемешаны. ВЫПОЛНЕНИЕ работы смешивается с УПРАВЛЕНИЕМ тем, как эта работа должна быть сделана. Иногда это ужасно.

В качестве обходного решения мы использовали комбинацию методик:
  • Собственный результат действия (action result) для управления общим потоком исполнения;
  • Разделение “выполнения работы” и общего потока исполнения.

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

Определение общего потока исполнения


Прежде чем зайти слишком далеко по пути создания результата действия, давайте рассмотрим общий шаблон выше. Некоторые вещи должны быть определены в действии контроллера, но другие могут быть случайными. Например, «DoActualWork» блок может быть определён на основании полученной формы. Мы никогда не будем иметь 2 различных способа обработки действия формы, так что давайте определим интерфейс для обработки такой формы:
public interface IFormHandler<T>
{
    void Handle(T form);
}

* This source code was highlighted with Source Code Highlighter.

Всё достаточно просто, класс, который представляет собой "Action(T)" или реализацию паттерна Команда. В самом деле, если вы знакомы с сообщениями, он выглядит так же, как обработчик сообщений. Форма – это сообщение и обработчик знает, что делать с таким сообщением.

Абстракция выше представляет то, что нам нужно сделать для блока «DoActualWork», а остальное можно перетащить в общий результат действия:
public class FormActionResult<T> : ActionResult
{
    public ViewResult Failure { get; private set; }
    public ActionResult Success { get; private set; }
    public T Form { get; private set; }

    public FormActionResult(T form, ActionResult success, ViewResult failure)
    {
        Form = form;
        Success = success;
        Failure = failure;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (!context.Controller.ViewData.ModelState.IsValid)
        {
            Failure.ExecuteResult(context);

            return;
        }

        var handler = ObjectFactory.GetInstance<IFormHandler<T>>();

        handler.Handle(Form);

        Success.ExecuteResult(context);
    }
}

* This source code was highlighted with Source Code Highlighter.

Мы рассмотрели основной конвейер выполнения (execution pipeline), и обнаружили куски, которые меняются. Примечательно, что это ActionResult для выполнения в случае успешного исхода и ActionResult – в случае неудачного исхода. Конкретный обработчик формы для выполнения уже определены на основе типа форм, поэтому мы используем любой популярный IoC контейнер, чтобы найти конкретный обработчик формы для выполнения (StructureMap в моем случае). Скажем StructureMap, чтобы он нашёл реализаций IFormHandler на основе реализаций, это всего лишь одна строка кода:
Scan(scanner =>
{
    scanner.TheCallingAssembly();
    scanner.ConnectImplementationsToTypesClosing(typeof(IFormHandler<>));
});

* This source code was highlighted with Source Code Highlighter.

Теперь перетащим блок «DoActualWork» внутрь класса, который занимается только обработкой формы, а не отслеживанием UI траффика:
public class ConferenceEditModelFormHandler
    : IFormHandler<ConferenceEditModel>
{
    private readonly IConferenceRepository _repository;

    public ConferenceEditModelFormHandler(
        IConferenceRepository repository)
    {
        _repository = repository;
    }

    public void Handle(ConferenceEditModel form)
    {
        Conference conf = _repository.GetById(form.Id);

        conf.ChangeName(form.Name);

        foreach (var attendeeEditModel in GetAttendeeForms(form))
        {
            Attendee attendee = conf.GetAttendee(attendeeEditModel.Id);

            attendee.ChangeName(attendeeEditModel.FirstName,
                                attendeeEditModel.LastName);
            attendee.Email = attendeeEditModel.Email;
        }
    }

    private ConferenceEditModel.AttendeeEditModel[] GetAttendeeForms(ConferenceEditModel form)
    {
        return form.Attendees ??
              new ConferenceEditModel.AttendeeEditModel[0];
    }
}

* This source code was highlighted with Source Code Highlighter.

Сейчас этот класс рассчитан только на успешную обработку формы. А именно, возвращаясь к моему объекту домена и изменяя его соответственно. Т.к. у меня поведенческая модель домена, вы не увидите возможность «обратного отображения». Это сделано намеренно.

Что действительно интересно, так это то, как мы изолировали все эти проблемы и, что они теперь больше не зависят от конкретного ASP.NET результата действия. На данный момент мы эффективно отделили проблемы выполнения работы от непосредственной работы.

Применительно к нашему контроллеру


Теперь, когда мы разработали наш результат действия, осталась последний проблема – применение этого результата действия к нашему действию контроллера. Как большинство людей, мы часто вставляем прослойку в иерархии классов контроллеров, чтобы иметь возможность применять вспомогательные методы во всех наших контроллерах. В этом классе мы добавим вспомогательный метод для построения нашего пользовательского результата действия:
public class DefaultController : Controller
{
    protected FormActionResult<TForm> Form<TForm>(
        TForm form,
        ActionResult success)
    {
        var failure = View(form);

        return new FormActionResult<TForm>(form, success, failure);
    }

* This source code was highlighted with Source Code Highlighter.

Он просто оборачивает некоторые пути по умолчанию, которые мы часто определяем. К примеру, ошибка обработки почти всегда показывает вид, с которого мы только что пришли. Наконец, мы можем изменить наше исходное POST действие контроллера:
public class ConferenceController : DefaultController
{
    [HttpPost]
    public ActionResult Edit(ConferenceEditModel form)
    {
        var successResult =
            this.RedirectToAction(c => c.Index(null), "Default");

        return Form(form, successResult);
    }

* This source code was highlighted with Source Code Highlighter.

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

Еще один интересный побочный эффект состоит в том, что мы сейчас создаём модульные/интеграционные тесты для обработчика формы, но не для действия контроллера. А что там проверять то? У нас нет стимула, писать тест, т.к. там слишком мало логики.

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

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

Articles