Как выглядят POST-запросы? В большинстве случаев - это отправка формы на сервер с целью добавить или изменить какие-то данные.
Непосредственное на сервер изменение данных обычно предваряется авторизацией и валидацией, и оканчивается отправкой какого-то ответа.
Далее типичный пример из уроков для начинающих, анализ его проблем и исправление.
Дана форма для сохранения отзывов. На ней три поля: имя, почта и текст. Контроллер:
Feedback - сущность для сохранения в базу:
View - стандартная вьюшка для отображения формы
Это вторая вещь, про которую обычно не упоминают в уроках - обработку POST-запросов просто нельзя завершать так, как показано. Нельзя отправлять данные для отображения браузером в ответ на POST-запрос. То есть физически это можно, никто не запрещает, но:
Пытаемся сохранить данные. Так как их надо передать для следующего запроса и после этого подчистить, то идеальным местом хранения будет TempData. Изменяем обработчик формы:
Осталась обработка формы. Обойдемся простой реализацией, с помощью шаблонного метода:
Что дальше? Непосредственно в обработке POST-запросов осталась проблема наличия логики обработки данных в самом контроллере. Во-первых, так ее сложнее переиспользовать. Во-вторых, 4-5 обработок формы в контроллере, и у нас появляется монстр на несколько сотен строк кода, при чем с предельно низким сцеплением (cohesion). Ну и в-третьих - эта логика смешана с инфраструктурной логикой валидации и перенаправления пользователя в другие места, что усложняет написание тестов.
Но это в следующей части.
Далее типичный пример из уроков для начинающих, анализ его проблем и исправление.
Дана форма для сохранения отзывов. На ней три поля: имя, почта и текст. Контроллер:
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.
[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). Ну и в-третьих - эта логика смешана с инфраструктурной логикой валидации и перенаправления пользователя в другие места, что усложняет написание тестов.
Но это в следующей части.
А так-ли необходимо делать Redirect при ошибке серверной валидации? Это ведь тот случай когда у клиента отключены скрипты, т. е. случай очень редкий. Наверняка в этом случае эта проблема будет самой незначительной для данного клиента. Мне кажется, что код серверной валидации в первую очередь нужен для предотвращения всяких там хаков с программной отправкой формы и т. п.
ОтветитьУдалитьВ идеальном мире, где есть вся клиентская валидация - нет. Тут вы правы, это будет очень редкий случай.
УдалитьНо на моей практике было достаточно случаев, когда клиентской валидации просто нет, потому что делать ее невыгодно. Например, форма восстановления пароля - пользователь вводит почту, тыкает кнопку - на сервер проверяется, есть ли такой адрес, и происходит ошибка валидации, если нет. .
Или, например, вывод денег со счета - проверка, что введенная сумма меньше, чем есть на счете.
И да, во всех случаях этих можно прикрутить клиентскую валидацию,и уйти от проблемы так, но зачем? Формы легкая; благодаря ModelState данные формы будут сохранены; благодяря PRG отправка данных пройдет без видимых артефактов - пользователь даже не заметит, что произошла перегрузка страницы. А мы потратили время на что-то другое.
Во второй части показано, как это добро еще интегрируется с самим обработчиком формы, и там PRG вообще работает незаметно - почему бы не использовать плюшку, если она работает сама и бесплатна :)
А еще очень удобно, т.к. часто мы закидываем в модель(или ViewBag) разные дополнительные штуки(например списки для dropdown), и тогда нам достаточно написать этот код только в экшене для get-a
УдалитьДа, про сценарии где нецелесообразна/невозможна клиентская валидация я не подумал - вы правы
Удалить