воскресенье, 5 января 2014 г.

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

Для того, чтобы понять, о чем идет речь, рекомендую ознакомиться с первой частью.
Я остановился на таком варианте кода-обработчика формы:
[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" ) );
}
Теперь надо вынести куда-то эту логику, не место ей в контроллере.


Обычный вариант - как, к сожалению, поступают очень много где: создаем некий класс Feedback или FeedbackService в папочке Business, и в него фигачим статические методы для работы с отзывами (AddNew, Update и пр.). То есть, что дядя Фаулер назвал "Сценарием транзакций". Проблемы вытекают из слова "статические". Внутри данного метода получить доступ к, например, базе, можно либо сделав к ней доступ тоже статический (что затруднит тестирование бизнес-логики), либо использовать DI-контейнер как ServiceLocator - что несколько снижает проблему тестирования, но ServiceLocator и сам не без греха. Еще можно попытаться нагородить костыли вокруг самого Сценария транзакций, чтобы сделать его менее статическим. Но все равно это останется большой портянкой с десятками методов, которые никак не связаны друг с другом.
Дальше можно было бы плавно перейти к тому, что есть такой клевый DDD, и как с ним все круто. Но... По-моему опыту, DDD хорош для написания статей, какой он клевый, для проведение семинаров о том, какой он клевый. А вот с реальной разработкой он как-то плохо совместим.
А я предложу использовать для этого дела Команду.
В классической Команде у нас есть источник (для нас - контроллер), команда, и обработчик. Осталось определиться, что будет командой и обработчиком. Логично командой сделать ViewModel. Во-первых, она уже есть. Во-вторых, практически для каждой формы своя модель. В-третьих, она уже содержит все нужные данные для обработки запроса.
Определим интерфейс обработчика:
public interface IModelHandler<TModel>
{
 object Handle( TModel model );
}
Классы, реализующие обработчик какой-то команды, будут реализовывать этот интерфейс. В нашем случае:
public class AddFeedbackHandler : IModelHandler<AddFeedbackModel>
{
 public ISession DbSession { get; set; }

 public object Handle( AddFeedbackModel model )
 {
  var feedback = new Feedback
  {
   Name = model.Name,
   Text = model.Text,
   Email = model.Email
  };

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

  return feedback;
 }
}
Осталось связать контроллер и обработчик. Какая стоит задача? Найти реализацию интерфейса IModelHandler<> от заданного аргумента, инстанциировать, пропустить через контейнер, вызвать метод Handle. Можно решать задачу в лоб - ничего сложного нет. Рефлексия в руки и готово. Оданко, гораздо проще переложить всю задачу на сам контейнер - у многих есть встроенная поддержка всего этого. Я обычно использую WindsorContainer, поэтому покажу на его примере. Регистрируем в контейнере все реализации интерфейса:
container.Register(
 AllTypes.FromAssembly( assembly )
  .BasedOn( typeof( IModelHandler<> ) )
  .WithService.FirstInterface()
  .Configure( c => c.LifestylePerWebRequest() )
 );
Контейнер сам пошуршит в сборке, найдет реализации интерфейса и зарегистрирует. Красота. Теперь создаем экземпляр и вызваем обработку (при условии, что мы уже интегрировались с DependencyResolver-ом MVC-шным):
[HttpPost]
public ActionResult Form( AddFeedbackModel model )
{
 return this.FormHandler( () =>
 {
  var handler = DependencyResolver.Current.GetService<IModelHandler<AddFeedbackModel>>();
  handler.Handle( model );
 }, RedirectToAction( "ThankYouForFeedback" ) );
}
Теперь можно тыкать в меня палкой - вот же он, ServiceLocator. Как же так? Но есть одно но - написанный выше код чисто инфраструктурный. Его не нужно тестировать. ServiceLocator внутри бизнес-логики - плохо, снаружи в инфраструктуре - пожалуйста.
И так, проблему с вынесением бизнес-логики из контроллера решили. Логика спрятана в одном простом классе, ее можно просто протестировать - в обработчике нету ничего лишнего. Жесткой связи обработчика и источника нет, чего и добивались. Но выглядит код в контроллере как-то.. не очень.
Что можно сделать? Шаг первый, улучшаем наш FormHandler
protected ActionResult FormHandler<TModel>( TModel model, ActionResult successResult )
{
 if( !this.ModelState.IsValid )
 {
  this.TempData[MergeModelStateAttribute.GetModelStateKey( this.ControllerContext )] = this.ModelState;
  return new RedirectResult( this.Request.RawUrl );
 }

 var handler = DependencyResolver.Current.GetService<IModelHandler<TModel>>();
 handler.Handle( model );

 return successResult;
}
Теперь контроллер вылядит совсем красиво, вся кухня скрыта:
[HttpPost]
public ActionResult Form( AddFeedbackModel model )
{
 return FormHandler( model, RedirectToAction( "ThankYouForFeedback" ) );
}
В принципе, уже на текущий момент все довольно хорошо. В контроллере ничего лишнего, только указание, как реагировать на запрос пользователя. Бизнес-логика инкапсулирована в отдельный класс, зависимости красиво разрешаются контейнером. Но.. Метод FormHandler меня потихоньку бесит. Почему? Потому что специально для таких случаев в MVC есть механизм ActionHandler-ов. Ведь когда вам надо вернуть вьюшку, вы не вызываете генерацию разметки в контроллере, а говорите MVC: "В ответ на запрос пользователя верни вот этот результат." То же самое с редитректом, отдачей файла и любым другим действием - вы не выполняете код действия в контроллере, вы только указываете, что надо бы выполнить.
Сказано - сделано! Приведу код уже "взрослого" FormActionResult'а, с несколькими фишками, что приросли за годы использования.
public class FormActionResult<TModel> : ActionResult
{
 //Действите, котрое отдается в случае ошибки валидации, больше не захардкожено, а передается извне тоже
 protected ActionResult FailureResult;
 protected ActionResult SuccessResult;

 //помимо возврата фиксированного результата, добавлена возможность выполнять коллбэки (object - результат работы обработчика)
 //Например, после создания отзыва надо перейти на страницу с этим отзывом. Для этого нужен его id, который будет доступен 
 //только после выполнения обработчика
 protected Func<object, ActionResult> OnSuccess;
 protected Func<object, ActionResult> OnFailure;

 private TModel _Model;

 public FormActionResult( TModel model, ActionResult failureResult, ActionResult successResult )
 {
  this.FailureResult = failureResult;
  this.SuccessResult = successResult;
  this._Model = model;
 }

 public FormActionResult( TModel model, ActionResult failureResult, 
  Func<object, ActionResult> onSuccess )
 {
  this.FailureResult = failureResult;
  this.OnSuccess = onSuccess;
  this._Model = model;
 }

 public FormActionResult( TModel model, Func<object, ActionResult> onFailure, 
  Func<object, ActionResult> onSuccess )
 {
  this.OnFailure = onFailure;
  this.OnSuccess = onSuccess;
  this._Model = model;
 }

 public override void ExecuteResult( ControllerContext context )
 {
  var modelState = context.Controller.ViewData.ModelState;
  if( !modelState.IsValid )
  {
   this.ExecuteFailureResult( context );
  }
  else
  {
   var handler = this.GetFormHandler( modelState );

   var model = handler.Handle( this._Model );

   //проверка после вызова обработчика нужна тоже, см. GetFormHandler
   if( !modelState.IsValid )
   {
    this.ExecuteFailureResult( context, model );
   }
   else
   {
    if ( this.SuccessResult == null )
    {
     this.SuccessResult = this.OnSuccess( context, model );
    }

    this.SuccessResult.ExecuteResult( context );
   }
  }
 }

 private IModelHandler<TModel> GetFormHandler( ModelStateDictionary modelState )
 {
  var handler = DependencyResolver.Current.GetService<IModelHandler<TModel>>();
  if( handler == null )
  {
   throw new ArgumentException( "Не найден обработчик для формы " + typeof( TModel ).FullName );
  }
  //в интерфейс IModelHandler Добавлено доп. свойство IValidationState<TModel> ValidationState { get; set; }
  //причина - иногда нужно провести доп. валидацию, которую сложно или неудобно сделать атрибутами
  //например, запрос на вывод денег со счета проверяет, что на счету сумма большая, чем запрошено. 
  //декларация интерфейса - ниже
  handler.ValidationState = new ModelStateWrapper<TModel>( modelState );
  return handler;
 }

 private void ExecuteFailureResult( ControllerContext context, object model = null )
 {
  if( this.FailureResult == null )
  {
   this.FailureResult = this.OnFailure( context, model );
  }
  this.FailureResult.ExecuteResult( context );
 }
}

public interface IValidationState<TModel>
{
 void AddError( string key, string value );
 void AddError( Expression<Func<TModel, object>> expr, string value );

 int Count { get; }
}
И обертки в базовом контроллере для его вызова:
protected FormActionResult<TModel> Form<TModel>( TModel model, Func<object, ActionResult> onSuccess )
{
 return new FormActionResult(
  model,
  new ReturnToFormActionResult(),
  onSuccess );
}
Как писалось в комментариях к коду - фейл результат не захардкожен больше, но по-умолчанию инициализируется логикой возврата на форму
public class ReturnToFormActionResult : ActionResult
{
 public override void ExecuteResult( ControllerContext context )
 {
  this.SaveModelState( context );
  var redirectResult = new RedirectResult( context.HttpContext.Request.RawUrl );
  redirectResult.ExecuteResult( context );
 }
  
 public void SaveModelState( ControllerContext context )
 {
  context.Controller.TempData[MergeModelStateAttribute.GetModelStateKey( context )]
   = context.Controller.ViewData.ModelState;
 }
}

Итог

Логика вынесена, тестировать просто, контроллеры - няшки.
В следующей статье пройдемся по GET-запросам, логике работы с базой, ужасным вьюшкам и еще много чему.











5 комментариев:

  1. Мы довольно давно используем этот подход и уже есть обкатанные решения Infrastructure.Web/Forms и пример реализации Forms/Handlers.

    Я помню такой подход начался с подачи Jimmy Bogard несколько лет назад Cleaning up POSTs in ASP.NET MVC.

    Тема хорошая и требует освещения, спасибо вам за статьи.

    ОтветитьУдалить
    Ответы
    1. Я не буду, конечно, утверждать, что я автор идеи, но подсмотрел я ее где-то в другом месте - на какой-то конференции. Глянул код - не знаю, как вы без валидации живете там :)

      Удалить
  2. Интересный подход. Но что делать, если для редиректа, требуется результат работы хендлера?

    Например: при создании нового экземпляра FeedbackModel нужно выполнить переход:
    RedirectToAction("Details", {id=newId})?

    ОтветитьУдалить
    Ответы
    1. В начале листинга класса FormActionResult есть комментарий под ваш вопрос :)

      Удалить
  3. Статьи очень интересны, автор не томи с продолжением :)

    ОтветитьУдалить