Для того, чтобы понять, о чем идет речь, рекомендую ознакомиться с первой частью.
Я остановился на таком варианте кода-обработчика формы:
Обычный вариант - как, к сожалению, поступают очень много где: создаем некий класс Feedback или FeedbackService в папочке Business, и в него фигачим статические методы для работы с отзывами (AddNew, Update и пр.). То есть, что дядя Фаулер назвал "Сценарием транзакций". Проблемы вытекают из слова "статические". Внутри данного метода получить доступ к, например, базе, можно либо сделав к ней доступ тоже статический (что затруднит тестирование бизнес-логики), либо использовать DI-контейнер как ServiceLocator - что несколько снижает проблему тестирования, но ServiceLocator и сам не без греха. Еще можно попытаться нагородить костыли вокруг самого Сценария транзакций, чтобы сделать его менее статическим. Но все равно это останется большой портянкой с десятками методов, которые никак не связаны друг с другом.
Дальше можно было бы плавно перейти к тому, что есть такой клевый DDD, и как с ним все круто. Но... По-моему опыту, DDD хорош для написания статей, какой он клевый, для проведение семинаров о том, какой он клевый. А вот с реальной разработкой он как-то плохо совместим.
А я предложу использовать для этого дела Команду.
В классической Команде у нас есть источник (для нас - контроллер), команда, и обработчик. Осталось определиться, что будет командой и обработчиком. Логично командой сделать ViewModel. Во-первых, она уже есть. Во-вторых, практически для каждой формы своя модель. В-третьих, она уже содержит все нужные данные для обработки запроса.
Определим интерфейс обработчика:
И так, проблему с вынесением бизнес-логики из контроллера решили. Логика спрятана в одном простом классе, ее можно просто протестировать - в обработчике нету ничего лишнего. Жесткой связи обработчика и источника нет, чего и добивались. Но выглядит код в контроллере как-то.. не очень.
Что можно сделать? Шаг первый, улучшаем наш FormHandler
Сказано - сделано! Приведу код уже "взрослого" FormActionResult'а, с несколькими фишками, что приросли за годы использования.
В следующей статье пройдемся по GET-запросам, логике работы с базой, ужасным вьюшкам и еще много чему.
Я остановился на таком варианте кода-обработчика формы:
[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-запросам, логике работы с базой, ужасным вьюшкам и еще много чему.
Мы довольно давно используем этот подход и уже есть обкатанные решения Infrastructure.Web/Forms и пример реализации Forms/Handlers.
ОтветитьУдалитьЯ помню такой подход начался с подачи Jimmy Bogard несколько лет назад Cleaning up POSTs in ASP.NET MVC.
Тема хорошая и требует освещения, спасибо вам за статьи.
Я не буду, конечно, утверждать, что я автор идеи, но подсмотрел я ее где-то в другом месте - на какой-то конференции. Глянул код - не знаю, как вы без валидации живете там :)
УдалитьИнтересный подход. Но что делать, если для редиректа, требуется результат работы хендлера?
ОтветитьУдалитьНапример: при создании нового экземпляра FeedbackModel нужно выполнить переход:
RedirectToAction("Details", {id=newId})?
В начале листинга класса FormActionResult есть комментарий под ваш вопрос :)
УдалитьСтатьи очень интересны, автор не томи с продолжением :)
ОтветитьУдалить