« Все записи

Улучшенная обработка форм в ASP.NET MVC

Не подзабросил я свой блог, просто времени в последнее время не хватает. Постараюсь реабилитироваться. В последнее время переводил только "маленькие чудеса C#/.NET", а про любимую MVC здесь ничего не писал, хотя читал и изучал много всего. Сегодня перевод статьи, которую пропустить ни в коем случае не мог, да и вам не советую. Из блога Бена Фостера.

Вчера я начал работу над новым проектом ASP.NET MVC, коорое будет построено с использованием "лучших практик" (программирование с интерфейсами для ослабления связности и тестируемости, использование внедрения зависимостей, создание моделей для конкретных представлений и т.д.).

Я думаю, что эти методы знакомы многим ASP.NET MVC разработчикам, которые, вероятно, согласятся, что они делают нашу жизнь легче.

Существует также много практик, котрые мы "принимаем" к использованию часто потому, что мы видим их во множестве примеров в Интернете. Цель моего приложения (код можно скачать здесь) - определить и предложить решения проблем, которые присущи множеству приложений ASP.NET MVC (включая и мои собственные).

Сегодня я хочу обсудить, как мы обращаемся с формами в ASP.NET MVC. Этот пост строится в основном на одной из статей Джимми Богарда с небольшими отличиями.

В большинстве приложений ASP.NET MVC в CRUD стиле, обычно есть 3 типа действий контроллера

  1. Действия запросов (GET)
  2. Действия форм (GET)
  3. Действия форм (POST)

Действия запросов довольно просты. Мы загружаем наши данные откуда-то, строим модель представления (View Model) и передаем ее в наше представление:

public ActionResult List() {
    var customers = customerService.GetCustomers();
    var model = new CustomerListModel(customers);

    return View(model);
}

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

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

Давайте посмотрим на пример:

[HttpGet]
public ActionResult Edit(Guid id)
{
    var customer = customerService.GetCustomerById(id);
    var countries = geoService.GetCountries();

    if (customer == null)
    {
        return RedirectToAction("list");
    }

    var model = new CustomerEditModel(customer, countries);
    return View(model);
}

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

public CustomerEditModel(Customer customer, IEnumerable<Country> countries)
{
    Id = customer.Id;
    FirstName = customer.FirstName;
    LastName = customer.LastName;

    SetCountries(countries);
}

public void SetCountries(IEnumerable<Country> countries)
{
    Countries = new SelectList(countries.Select(s => s.Name), Country);
}

Затем в нашем представлении мы рендерим наш выпадающий список следующим образом:

<div class="editor-field">
    @Html.DropDownListFor(model => model.Country, Model.Countries)
</div>

Достаточно стандартная вешь.

Теперь давайте перейдем к действиям форм (POST). Интересно, сколько раз вы видели или писали такой код:

[HttpPost]
public ActionResult Edit(CustomerEditModel model)
{
    if (!ModelState.IsValid)
    {
        model.SetCountries(geoService.GetCountries());
        return View(model);
    }

    var customer = customerService.GetCustomerById(model.Id);
    customer.FirstName = model.FirstName;
    customer.LastName = model.LastName;
    customer.Country = model.Country;

    customerService.UpdateCustomer(customer);

    return RedirectToAction("edit", new { id = model.Id });
}

Сначала проверим, что признак состояния модели ModelState является действительным. Если это не так, то мы вернем то же представление, удостоверяясь, что мы заполнили все "приятельские" данные, которые нам нужны для наших выпадающих списков и т.д. В другом случае мы можем обработать полученные данные формы перед перенаправлением куда-либо. Это прекрасно работает прямо так, почему нужно что-то менять?

Ну, во-первых, давайте посмотрим на большее приложение, которое имеет около 30 подобных форм. Мы пишем один и тот же код снова и снова:

Проверить ModelState.IsValid
Если false, возвратить ActionResult
Если true, обработать форму затем возвратить ActionResult

Я не люблю писать повторяющийся код. Прежде чем ступить на лучший путь обработки этих шагов, давайте сначала рассмотрим вопрос заполнения "приятельских" данных формы. Это невероятно раздражает тем более, что вам придется сделать это 4 раза для типичного сценария "Создание/Редактирование" (в обоих GET и POST).

Мое решение этой проблемы заключается в создании "обогатителей" (Enrichers) модели представления. Как следует из названия, они "обогащают" модель представления либо путем добавления дополнительной информации либо путем выполнения разного рода обработки. Они разработаны специально для пост-обработки заполненной модели.

Все обогатители реализуют интерфейс IViewModelEnricher:

public interface IViewModelEnricher<TViewModel>
{
    void Enrich(TViewModel viewModel);
}

Давайте посмотрим на его реализацию, наш CustomerEditModelEnricher:

public class CustomerEditModelEnricher : IViewModelEnricher<CustomerEditModel>
{
    private readonly IGeoService geoService;

    public CustomerEditModelEnricher(IGeoService geoService) {
        this.geoService = geoService;
    }

    public void Enrich(CustomerEditModel viewModel) {
        if (viewModel == null)
            return;

        var countries = new SelectList(geoService.GetCountries().Select(c => c.Name), viewModel.Country);
        viewModel.Countries = countries;
    }
}

Здесь мы "обогащаем" нашу CustomerEditModel списком стран (SelectList), полученным от нашего геосервиса. Зависимость конструктора будет разрешаться автоматически с использованием IoC контейнера.

Теперь нам нужен способ, чтобы вернуть "обогащенный" результат представления. Мы делаем это с помощью специального результата представления с именем (вы догадались) "EnrichedViewResult":

public class EnrichedViewResult<T> : ViewResult
{
    public EnrichedViewResult(string viewName, ViewDataDictionary viewData)
    {
        this.ViewName = viewName;
        this.ViewData = viewData;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (this.Model != null)
        {
            var enricher = DependencyResolver.Current.GetService<IViewModelEnricher<T>>();
            if (enricher != null)
            {
                enricher.Enrich((T)this.Model);
            }
        }

        base.ExecuteResult(context);
    }
}

Когда наш конкретный результат выполняется, мы запрашиваем у нашего IoC контейнера (через DependencyResolver) обогатитель данного типа и "пропускаем" нашу модель через него.

Так как же нам найти правильный обогатитель? Вот где пригодится мощь StructureMap:

x.Scan(scan =>
        {
            scan.TheCallingAssembly();
            scan.WithDefaultConventions();
            scan.ConnectImplementationsToTypesClosing(typeof(IViewModelEnricher<>));
        });

Метод "ConnectImplementationsToTypesClosing(type)" делает именно то, о чем говорит его название. Он ищет любые реализации IViewModelEnricher и регистрирует их.

Наконец, у нас есть некоторые вспомогательные методы в нашем базовом контроллере для возвращения EnrichedViewResult:

public EnrichedViewResult<T> EnrichedView<T>(T model) {
    return EnrichedView(null, model);
}

public EnrichedViewResult<T> EnrichedView<T>(string viewName, T model){
    if (model != null) {
        ViewData.Model = model;
    }
    return new EnrichedViewResult<T>(viewName, ViewData);
}

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

[HttpGet]
public ActionResult Edit(Guid id)
{
    var customer = customerService.GetCustomerById(id);

    if (customer == null)
    {
        return RedirectToAction("list");
    }

    var model = new CustomerEditModel(customer);
    return EnrichedView(model);
}

[HttpPost]
[UnitOfWork]
public ActionResult Edit(CustomerEditModel model)
{
    if (!ModelState.IsValid)
    {
        return EnrichedView(model);
    }

    var customer = customerService.GetCustomerById(model.Id);
    customer.FirstName = model.FirstName;
    customer.LastName = model.LastName;
    customer.Country = model.Country;

    customerService.UpdateCustomer(customer);

    return RedirectToAction("edit", new { id = model.Id });
}

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

public class FormActionResult<T> : ActionResult
{
    private readonly T form;
    public Action<T> Handler { get; set; }
    public Func<T, ActionResult> SuccessResult;
    public Func<T, ActionResult> FailureResult;

    public FormActionResult(T form) {
        this.form = form;
    }

    public FormActionResult(T form, Action<T> handler, Func<T, ActionResult> successResult, 
        Func<T, ActionResult> failureResult)
    {
        this.form = form;
        Handler = handler;
        SuccessResult = successResult;
        FailureResult = failureResult;
    }

    public override void ExecuteResult(ControllerContext context)
    {
        var viewData = context.Controller.ViewData;

        if (!viewData.ModelState.IsValid) // 1 проверяем ModelState
        {
            FailureResult(form).ExecuteResult(context);
        }
        else
        {
            // 2 запускаем обработчик
            Handler(form);

            // 3 возвращаем success result
            SuccessResult(form).ExecuteResult(context);
        }
    }
}

Глядя на приведенный выше результат действия, вы можете видеть, что он охватывает три шага, о которых говорилось ранее. С помощью нескольких методов расширения (посмотрите исходный код) мы можем выразить наши намерения ясно. Наши метод Edit POST теперь выглядит так:

[HttpPost]
[UnitOfWork]
public ActionResult Edit(CustomerEditModel model) {
    return Handle(model)
        .With(form =>
        {
            var customer = customerService.GetCustomerById(form.Id);

            if (customer == null)
                return;

            customer.FirstName = form.FirstName;
            customer.LastName = form.LastName;
            customer.Country = form.Country;

            customerService.UpdateCustomer(customer);
        })
        .OnSuccess(form => RedirectToAction("edit", new { id = form.Id }));
}

Метод "Handle" нашего базового контроллера уже определяет результат отказа (failure) по умолчанию (возвращает обогащенное представление), в противном случае вы можете просто вызвать "OnFailure". Полную версию можно увидеть ниже:

return Handle(model)
    .With(form =>
    {
        // Do something
    })
    .OnSuccess(form => RedirectToAction("edit", new { id = form.Id }))
    .OnFailure(form => RedirectToAction("failed");

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

Другое большое отличие в том, что мы объявляем наши обработчики последовательно. Это мои личные предпочтения, и я могу даже добавить поддержку для обоих. Я обнаружил, что использование отдельных обработчиков было немного излишним, тем более, что мы намерены следовать "командному" подходу в нашей архитектуре. Отдельный обработчик, который просто вызывал Bus.Send(command) был излишним. У нас также было несколько контроллеров, где мы использовали оба метода, поэтому мы регулярно переключались между контроллерами и обработчиками. Совет: выберите метод, который работает для вас и придерживайтесь его. :)

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

Это странно, что такие вещи не встроены в фреймворк как общие паттерны. Следующая задача состоит в том, как обрабатывать сложные бизнес-проверки.

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

Тем не менее, после повторной оценки этого процесса и чтения материалов о CQRS и командных системах, теперь я рассматриваю это как отдельную "ответственность". Если мы хотим обрабатывать команды (либо непосредственно, либо переданные по шине) мы должны убедиться в их действительности до их обработки.

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

Progg it

comments powered by Disqus