суббота, 4 января 2014 г.

Asp.Net Mvc. Худеем контроллеры. Обработка форм, ч. 1

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


Дана форма для сохранения отзывов. На ней три поля: имя, почта и текст. Контроллер:
public class RootController : Controller
{
 public ISession DbSession { get; set; }

 public ActionResult Form()
 {
  return View();
 }

 [HttpPost]
 public ActionResult Form( FeedbackModel model )
 {
  var feedback = new Feedback
  {
   Name = model.Name,
   Text = model.Text,
   Email = model.Email
  };

  this.DbSession.Save( feedback );
  this.DbSession.Flush();

  return View( "ThankYouForFeedback" );
 }
}
ISession - это сессия NHibernate. Использую я в основном только его, если есть выбор - хотя EF разваивается, но до возможностей NH ему еще далеко.

Feedback - сущность для сохранения в базу:

public class Feedback
{
 public virtual int Id { get; set; }
 public virtual string Name { get; set; }
 public virtual string Email { get; set; }
 public virtual string Text { get; set; }
}
FeedbackModel - ViewModel для нашей формы:
public class FeedbackModel
{
 [Required( ErrorMessage = "Заполните имя" )]
 public virtual string Name { get; set; }

 [Required( ErrorMessage = "Заполните почту" )]
 public virtual string Email { get; set; }

 [Required( ErrorMessage = "Заполните текст отзыва" )]
 public virtual string Text { get; set; }
}
На нее навешаны стандартные атрибуты для работы механизма валидации MVC.
View - стандартная вьюшка для отображения формы
@model Forms.ViewModels.FeedbackModel

@using( Html.BeginForm() )
{
 <p>
  Имя: @Html.TextBoxFor( x => x.Name )
  @Html.ValidationMessageFor( x => x.Name )
 </p>
 <p>
  Почта: @Html.TextBoxFor( x => x.Email )
  @Html.ValidationMessageFor( x => x.Email )
 </p>
 <p>
  Отзыв:@Html.TextAreaFor( x => x.Text )
  @Html.ValidationMessageFor( x => x.Text )
 </p>
 <p><input type="submit" value="Отправить"/></p>
}
Частенько, на этом месте оканчивается урок. Что с ним не так? Если не считать, что код просто не ок (подробнее - дальше), то проблема в отсутствии серверной валидации. Стоит отключить скрипты или сформировать запрос вручную, и сервер примет что угодно. Да, в данном случае ничего страшного не случится - запрос просто упадет на моменте записи изменений в базу, но в реальности обычно все обстоит сложнее. Добавляем:
[HttpPost]
public ActionResult Form( FeedbackModel model )
{
 if( !this.ModelState.IsValid )
  return this.View();

 var feedback = new Feedback
 {
  Name = model.Name,
  Text = model.Text,
  Email = model.Email
 };

 this.DbSession.Save( feedback );
 this.DbSession.Flush();

 return this.View( "ThankYouForFeedback" );
}
Проблему валидации решили. Что дальше? А дальше POST-redirect-GET.
Это вторая вещь, про которую обычно не упоминают в уроках - обработку POST-запросов просто нельзя завершать так, как показано. Нельзя отправлять данные для отображения браузером в ответ на POST-запрос. То есть физически это можно, никто не запрещает, но:
  • назначение POST-запроса - изменить данные на сервер. Для отображения существует GET-запрос;
  • попытка нажать F5 на такой странице покажет бесячий попап - "Вы действительно хотите отправить данные повторно?". И, если нажать "Да", то произойдет повторная отправка формы, и повторная ее обработка. Обычно это вызывает проблемы;
  • так же браузер запишет этот запрос в историю. А это означает, что, нажав кнопку "Назад", мы попадаем в предыдущий пункт с F5.
Короче говоря - отправка страницы в ответ на POST-запрос ломает ожидаемое поведение веб-приложения. Решаем проблему:
[HttpPost]
public ActionResult Form( FeedbackModel model )
{
 if( !this.ModelState.IsValid )
  return RedirectToAction( "Form" );

 var feedback = new Feedback
 {
  Name = model.Name,
  Text = model.Text,
  Email = model.Email
 };

 this.DbSession.Save( feedback );
 this.DbSession.Flush();

 return RedirectToAction( "ThankYouForFeedback" );
}
Сделали. Теперь все хорошо с user expirience, но теперь наша валидация работает криво. Ошибки валидации, возникшие при байндинге модели, хранятся в ModelState. Дальше, при рендеринге формы заново, хэлпер ValidationMessageFor проверяет ModelState на наличие ключа с именем поля, и выводит текст, который под этим ключем есть. А наш редирект подчищает данные в ModelState.
Пытаемся сохранить данные. Так как их надо передать для следующего запроса и после этого подчистить, то идеальным местом хранения будет TempData. Изменяем обработчик формы:
if( !this.ModelState.IsValid )
{
 this.TempData["ModelState"] = this.ModelState;
 return RedirectToAction( "Form" );
}
И достаем данные при отображении:
public ActionResult Form()
{
 if( this.TempData.ContainsKey("ModelState") )
 {
  ModelState.Merge( (ModelStateDictionary)this.TempData["ModelState"] );
 }
 return View();
}
Проблему решили, однако встает вопрос - и что, вот так вот каждый раз писать, когда рисуем форму да делаем ее обработчик? Пробуем обойти неприятность. Первое - склеивание ModelState. Идеальный вариант - завести ActionFilter, который будет делать все нам нужное до вызова экшна:
public class MergeModelStateAttribute : ActionFilterAttribute
{
 public override void OnActionExecuted( ActionExecutedContext filterContext )
 {
  //если по каким-то причинам после "плохого" POST-а не будет отображена вьюшка
  //и сохраненный ModelState не подчистится, то при последующем POST-е данный фильтр склеит ModelState-ы
  //и мы получим неожиданный эффект. По этому делаем склеивание только на GETы
  if( filterContext.HttpContext.Request.RequestType != "GET" )
   return;

  var modelState = filterContext.Controller.ViewData.ModelState;
  var tempData = filterContext.Controller.TempData;

  //хранить данные о валидации лучше под ключем, специфичном для пары контроллер-экшн, чтобы
  //они внезапно не вылезли в другой форме
  var key = GetModelStateKey( filterContext.Controller.ControllerContext );

  if( tempData[key] != null )
  {
   modelState.Merge( (ModelStateDictionary)filterContext.Controller.TempData[key] );
  }
 }

 public static string GetModelStateKey( ControllerContext context )
 {
  return "modelState" 
   + context.RouteData.Values["controller"]
   + context.RouteData.Values["action"];
 }
}
Ну и не забываем зарегистрировать его как глобальный. После этого код из экшна на отображение можно удалить.
Осталась обработка формы. Обойдемся простой реализацией, с помощью шаблонного метода:
protected ActionResult FormHandler( Action handler, ActionResult successResult )
{
 if( !this.ModelState.IsValid )
 {
  this.TempData[MergeModelStateAttribute.GetModelStateKey( this.ControllerContext )] = this.ModelState;
  return  new RedirectResult( this.Request.RawUrl );
 }

 handler();

 return successResult;
}
Используется это так:
[HttpPost]
public ActionResult Form( FeedbackModel model )
{
 return this.FormHandler( () =>
 {
  var feedback = new Feedback
  {
   Name = model.Name,
   Text = model.Text,
   Email = model.Email
  };

  this.DbSession.Save( feedback );
  this.DbSession.Flush();
 }, RedirectToAction( "ThankYouForFeedback" ) );
}

Итог

Мы довели до ума стандартные примеры обрабокти форм в MVC, исправив проблему Post-Redirect-Get. И это практически никак не повлияло на написание дальнейшего кода, почти все работает прозрачно.
Что дальше? Непосредственно в обработке POST-запросов осталась проблема наличия логики обработки данных в самом контроллере. Во-первых, так ее сложнее переиспользовать. Во-вторых, 4-5 обработок формы в контроллере, и у нас появляется монстр на несколько сотен строк кода, при чем с предельно низким сцеплением (cohesion). Ну и в-третьих - эта логика смешана с инфраструктурной логикой валидации и перенаправления пользователя в другие места, что усложняет написание тестов.
Но это в следующей части.

4 комментария:

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

    ОтветитьУдалить
    Ответы
    1. В идеальном мире, где есть вся клиентская валидация - нет. Тут вы правы, это будет очень редкий случай.
      Но на моей практике было достаточно случаев, когда клиентской валидации просто нет, потому что делать ее невыгодно. Например, форма восстановления пароля - пользователь вводит почту, тыкает кнопку - на сервер проверяется, есть ли такой адрес, и происходит ошибка валидации, если нет. .
      Или, например, вывод денег со счета - проверка, что введенная сумма меньше, чем есть на счете.
      И да, во всех случаях этих можно прикрутить клиентскую валидацию,и уйти от проблемы так, но зачем? Формы легкая; благодаря ModelState данные формы будут сохранены; благодяря PRG отправка данных пройдет без видимых артефактов - пользователь даже не заметит, что произошла перегрузка страницы. А мы потратили время на что-то другое.
      Во второй части показано, как это добро еще интегрируется с самим обработчиком формы, и там PRG вообще работает незаметно - почему бы не использовать плюшку, если она работает сама и бесплатна :)

      Удалить
    2. А еще очень удобно, т.к. часто мы закидываем в модель(или ViewBag) разные дополнительные штуки(например списки для dropdown), и тогда нам достаточно написать этот код только в экшене для get-a

      Удалить
    3. Да, про сценарии где нецелесообразна/невозможна клиентская валидация я не подумал - вы правы

      Удалить