Главная страница   /   12.2. Приемы упрощения контроллеров (ASP.NET MVC 4 в действии

ASP.NET MVC 4 в действии

ASP.NET MVC 4 в действии

Джеффри Палермо

12.2. Приемы упрощения контроллеров

Чтобы действительно упростить методы действий, в которых находятся лишние нагромождения кода, нам понадобится использовать некоторые точки расширения в ASP.NET MVC. Мы также образно опишем, как работает наше программное обеспечение. В этом разделе мы узнаем, как упростить контроллеры с помощью следующих способов:

  • Управление общими данными представления без атрибутов фильтров
  • Наследование от ActionResult
  • Исследование чистой библиотеки с открытым кодом, которая поможет по-другому посмотреть на вещи

Управление общими данными представлений

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

Фильтры часто используются для обеспечения общих данных представления, но есть другой способ, который может обеспечить ту же функциональность, не полагаясь на атрибуты, и в то же время облегчает тестирование и позволяет использовать техники управления зависимостями. Вот действие контроллера, которое использует фильтр действия, чтобы добавить подзаголовок в ViewData.

[SubtitleData]
public ActionResult About()
{
	return View();
}

Всякий раз при вызове действия будет выполнен фильтр действия, показанный в следующем листинге.

Листинг 12-2: Пользовательский фильтр действия, который добавляет данные в словарь ViewData
public class SubtitleDataAttribute : ActionFilterAttribute
{
	public override void OnActionExecuted(ActionExecutedContext filterContext)
	{
		var subtitle = new SubtitleBuilder();
		filterContext.Controller.ViewData["subtitle"] = subtitle.Subtitle();
	}
}

Строка 1: Наследует от ActionFilterAttribute

Строка 5: Использует вспомогательный класс

Строка 6: Добавляет данные в ViewData

SubtitleDataAttribute делает доступным подзаголовок страницы, он использует SubtitleBuilder для извлечения необходимого подзаголовка и помещает его в ViewData. Атрибуты – это специальные классы, которые ограничивают разработчика в некоторых возможностях. В качестве параметров они требуют CLR-константы (например, строковые литералы, числовые литералы, и вызовы к typeof), поэтому наш фильтр действия должен отвечать за создание экземпляра любого вспомогательного класса, который ему требуется.

Так как SubtitleDataAttribute отвечает за создание экземпляров вспомогательных классов в листинге 12-2, во время компиляции у него уже существует связь с SubtitleBuilder (о чем свидетельствует ключевое слово new). Еще один недостаток фильтров действий – объем работы, связанный с их применением; вы должны применять их к каждому действию, в котором они необходимы.

Одно из решений - создать базовый контроллер и применить фильтр к нему. Тогда все контроллеры, в которых необходимы эти фильтры, можно просто наследовать от этого базового типа.

Когда мы используем наследование для решения этой проблемы, появляется новая – наследование связывает наш контроллер с базовым типом. Наследование представляет собой скомпилированное состояние, которое затрудняет изменения во время исполнения. И даже изменения во время компиляции затруднены: если базовый тип изменен, все производные должны измениться. В таких случаях, мы предпочитаем композицию наследованию.

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

Листинг 12-3: Расширение ControllerActionInvoker для предоставления пользовательских фильтров действий
public class AutoActionInvoker : ControllerActionInvoker
{
	private readonly IAutoActionFilter[] _filters;

	public AutoActionInvoker(IAutoActionFilter[] filters)
	{
		_filters = filters;
	}

	protected override FilterInfo GetFilters 
		(ControllerContext controllerContext, ActionDescriptor actionDescriptor)
	{
		FilterInfo filters = base.GetFilters(controllerContext, actionDescriptor);
		foreach (IActionFilter filter in _filters)
		{
			filters.ActionFilters.Add(filter);
		}
		return filters;
	}
}

Строка 1: Наследует от ControllerActionInvoker

Строка 3-8: Внедряет массив фильтров

Строка 13-18: Использует пользовательские и стандартные фильтры

Активатор действия контроллера примет массив пользовательских фильтров действий как параметр конструктора и применит каждый из них к действию при его вызове.

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

Листинг 12-4: Использование пользовательского активатора действия в пользовательской фабрике контроллеров
public class ControllerFactory : DefaultControllerFactory
{
	public static Func<Type, object> GetInstance = type => Activator.CreateInstance(type);

	protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType)
	{
		if (controllerType != null)
		{
			var controller = (Controller)GetInstance(controllerType);
			controller.ActionInvoker = (IActionInvoker) GetInstance(typeof(AutoActionInvoker));
			return controller;
		}
		return null;
	}
}

Строка 3: Инициализирует фабричную функцию

Строка 10: Устанавливает пользовательский активатор действия

Нам нужна фабричная функция, чтобы предоставить экземпляр для данного типа, но, так как во время исполнения нам не будет известен тип контроллера, мы не сможем передать контроллер как зависимость в конструктор фабрики контроллеров. Но даже в этом случае у нас будет фабрика, которая знает обо всех типах контроллеров в системе.

Наконец, мы используем специальный интерфейс и абстрактный базовый класс, чтобы обозначить фильтры действий, которые мы хотим применить.

Листинг 12-5: Интерфейс для определения пользовательского фильтра
public interface IAutoActionFilter : IActionFilter
{
}

public abstract class BaseAutoActionFilter : IAutoActionFilter
{
	public virtual void OnActionExecuting (ActionExecutingContext filterContext)
	{
	}

	public virtual void OnActionExecuted (ActionExecutedContext filterContext)
	{
	}
}

Строка 1: Наследует IActionFilter

Строка 5: Наследует IActionFilter, IAutoActionFilter

Наш интерфейс IAutoActionFilter наследует IActionFilter. BaseAutoActionFilter наследует IAutoActionFilter и обеспечивает реализацию его методов, которые ничего не делают. Эти пустые методы позволяют в дальнейшем наследовании переопределять только необходимые методы, а не реальзовывать все методы IActionFilter. Это уменьшит в дальнейшем объем работы.

Далее мы переходим к реализации пользовательских фильтров, которые заменят фильтры на основе атрибутов.

Листинг 12-6: Пользовательский фильтр действия, не основанный на атрибутах
public class SubtitleData : BaseAutoActionFilter
{
	readonly ISubtitleBuilder _builder;
	public SubtitleData(ISubtitleBuilder builder)
	{
		_builder = builder;
	}
	public override void OnActionExecuted(ActionExecutedContext filterContext)
	{
		filterContext.Controller.ViewData["subtitle"] = _builder.AutoSubtitle();
	}
}

Строка 4: Принимает зависимости в конструктор

В этой версии фильтра действия мы можем принять зависимость как параметр конструктора (поставляется автоматически контейнером DI). Наконец - чистый фильтр действия: тестируемый, облегченный, с управляемыми зависимостями и без неуклюжих атрибутов.

Может показаться, что это большой объем работы, но как только вы реализуете концепцию, добавление атрибутов фильтров станет простым: простое наследование от BaseAutoActionFilter.

В следующем разделе мы рассмотрим еще один способ оптимизации контроллеров - устранение других проблемных атрибутов из наших действий.

Наследование результатов действий

Один из возможных способов использовать атрибуты фильтров действий - выполнить постобработку ViewData, предоставленной контроллером представлению.

В примере кода для главы 11 у нас был атрибут фильтра действия, который использовал AutoMapper для трансляции исходных типов в типы назначения. Этот атрибут фильтра показан в следующем листинге.

Листинг 12-7: Фильтр действия, который использует AutoMapper
public class AutoMapModelAttribute : ActionFilterAttribute
{
	private readonly Type _destType;
	private readonly Type _sourceType;

	public AutoMapModelAttribute(Type sourceType, Type destType)
	{
		_sourceType = sourceType;
		_destType = destType;
	}

	public override void OnActionExecuted(ActionExecutedContext filterContext)
	{
		object model = filterContext.Controller.ViewData.Model;
		object viewModel = Mapper.Map(model, _sourceType, _destType);
		filterContext.Controller.ViewData.Model = viewModel;
	}
}

Строка 1: Наследует от ActionFilterAttribute

Строка 6: Принимает параметры типа

Строка 15-16: Использует AutoMapper для преобразования ViewData.Model

Применяя этот атрибут к методу действия, мы указываем AutoMapper преобразовать ViewData.Model. Этот атрибут предоставляет важную функциональность – ведь довольно легко забыть применить пользовательский атрибут, а наши представления не будут работать, если атрибут отсутствует. Альтернативный подход - вернуть пользовательский результат действия, который инкапсулирует эту логику, и не использовать фильтр.

Что, если мы наследуем от ViewResult класс, который содержит логику применения преобразования AutoMapper к ViewData.Model перед обычным выполнением, вместо того, чтобы использовать атрибут фильтра? Тогда мы могли бы не только подтвердить, что изначально была настроена правильная модель, но и гарантировать, что AutoMapper будет преобразовывать в правильный тип назначения.

Можно создать много разных результатов действий наподобие этого; однако, необходимо сделать видимым проверяемое состояние - в данном случае, это тип назначения, в который мы преобразовываем.

AutoMappedViewResult создается следующим образом.

Листинг 12-8: Результат действия, который применяет AutoMapper к модели
public class AutoMappedViewResult : ViewResult
{
	public static Func<object, Type, Type, object> Map = (a, b, c) =>
		{
			throw new InvalidOperationException(
					@"The Mapping function must be
						set on the AutoMapperResult class");
		};

	public AutoMappedViewResult(Type type)
	{
		DestinationType = type;
	}

	public Type ViewModelType { get; set; }

	public override void ExecuteResult(ControllerContext context)
	{
		ViewData.Model = Map(ViewData.Model,
					ViewData.Model.GetType(),
					DestinationType);
		base.ExecuteResult(context);
	}
}

Строка 1: Наследует от ViewResult

Строка 3: Определяет функцию отображения

Строка 19: Применяет функцию отображения

Строка 22: Выполняет обычную обработку ViewResult

Этот класс только применяет функцию отображения (определенную делегированием), которую мы установим как функцию отображения AutoMapper, к ViewData.Model, а затем продолжает обычную обработку ViewResult. Мы также должны сделать видимым тип назначения, чтобы его можно было проверить в модульных тестах. Не используя атрибуты, мы теперь точно знаем, что действие преобразует в правильный тип назначения.

Использование AutoMappedViewResult с вспомогательной функцией показано в следующем листинге. Этот результат мы можем легко использовать в действиях.

Листинг 12-9: Использование AutoMappedViewResult в действии
public AutoMappedViewResult Index()
{
	var customer = GetCustomer();
	return AutoMappedView<CustomerInfo>(customer);
}
public AutoMappedViewResult AutoMappedView<TModel>(object Model)
{
	ViewData.Model = Model;
	return new AutoMappedViewResult(typeof(TModel))
	{
		ViewData = ViewData,
		TempData = TempData
	};
}

Строка 4: Возвращает AutoMappedViewResult

Строка 6: Создает AutoMappedViewResult

Возвращать правильный результат – так же просто, как обычный ViewResult, но мы должны указать тип назначения, CustomerInfo (наша презентационная модель). Вспомогательная функция обрабатывает ViewData и TempData.

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

Использование шины приложения

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

То, как работают многие распределенные системы, можно сравнить с отправкой и получением сообщений. Одно приложение будет отправлять сообщение с командой в шину. Шина, среди прочего, отвечает за маршрутизацию сообщений и гарантирует, что они обрабатываются соответствующими получателями. Сервисы имеют общую схему сообщений, но их реализации могут сильно отличаться друг от друга, хотя бы потому, что они разрабатываются на различных платформах. Пока адресат понимает сообщение, службы могут работать вместе. Они не должны зависеть друг от друга, только от шины. Такие системы называются слабо связанными.

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

Что если вместо того, чтобы зависеть от IOrderShippingService, наш сложный контроллер обработки заказов направит сообщение в шину, как показано в следующем листинге?

Листинг 12-10: Отправка сообщений по шине приложения
public class ExampleOrderController : Controller
{
	readonly IBus _bus;
	public ExampleOrderController(IBus bus)
	{
		_bus = bus;
	}
	public ActionResult Ship(int orderId)
	{
		var message = new ShipOrderMessage
		{
			OrderId = orderId
		};
		var result = _bus.Send(message);
		if (result.Successful)
		{
			return RedirectToAction ("Shipped", "Order", new { orderId });
		}
		return RedirectToAction ("NotShipped", "Order", new { orderId });
	}
}

Строка 4: Внедряет зависимость IBus

Строка 10-13: Создает сообщение команды

Строка 14: Посылает сообщение в шину

Строка 15-19: Обрабатывает результат

Контроллер в предыдущем листинге не вызывает метод IOrderShippingService, но вместо этого посылает ShipOrderMessage в шину приложения. Пользовательский интерфейс здесь полностью отделен от конкретного обработчика этой команды. Весь процесс отправки заказов или ответственный интерфейс мог бы измениться, но наш контроллер продолжал бы работать корректно.

С другой стороны, для шины необходим способ соотнесения сообщений с конкретными обработчиками. В распределенной системе понадобился бы довольно сложный механизм для маршрутизации сообщений в различные конечные точки, объединенные в сети, но внутрипроцессные приложения могут использовать систему типов в качестве реестра. Рассмотрим простой IHandler<T>.

public interface IHandler<T>
{
	Result Handle(T message);
}

Реализации этого интерфейса заявляют, что они могут обрабатывать определенный тип сообщения. Когда шина получает ShipOrderMessage, она может найти реализацию IHandler<ShipOrderMessage> и, используя DI-контейнер, создать экземпляр реализации, вызвать в нем Handle и передать в него сообщение. (Пример для этого есть в примере кода для этой главы.)

Для примера сообщения команды мы используем функциональную возможность MvcContrib, которая называется командный процессор. В следующем листинге показан обработчик для сообщения ShipOrder. Реализация IHandler находится в базовом классе Command<T>.

Листинг 12-11: Конкретный обработчик сообщений
public class ShipOrderHandler : Command<ShipOrder>
{
	readonly IRepository _repository;
	public ShipOrderHandler(IRepository repository)
	{
		_repository = repository;
	}
	protected override ReturnValue Execute(ShipOrder commandMessage)
	{
		var order = _repository.GetById<Order>(commandMessage.OrderId);
		order.Ship();
		_repository.Save(order);
		return new ReturnValue().SetValue(order);
	}
}

Командный процессор MvcContrib знает, как размещать обработчиков, и поэтому, чтобы зарегистрировать класс в качестве обработчика для этого сообщения, нужно только наследоваться от Command<ShipOrder>. Фактическая работа выполняется в методе Execute, в котором ShipOrderHandler может использовать свои собственные зависимости по мере необходимости.

Хотя и полезно отделить код бизнес-логики от пользовательского интерфейса, это действие можно применять только в средних и больших приложениях. В маленьких приложениях такой тип разделения не требуется. Кроме того, этот метод не упростил наш контроллер. Цикломатическая сложность осталась, и нам все еще необходимо проверить, что произойдет в случае успешного или неудачного результата.

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

Полный результат действия включен в пример кода для этой главы, но упрощенный CommandResult показан в следующем листинге:

Листинг 12-12: Результат действия для обработки команд
public class CommandResult : ActionResult
{
	// ...
	public override void Execute(ControllerContext context)
	{
		var bus = ObjectFactory.GetInstance<IBus>();
		var result = bus.Send(_message);
		if (result.Successful)
		{
			Success.ExecuteResult(context);
			return;
		}
		Failure.ExecuteResult(context);
	}
}

Строка 6: Инструмент IoC получает шину приложения

Строка 7: Посылает сообщение

Строка 8: Проверяет результат

Строка 10: Выполняет результат действия для успешного выполнения

Строка 13: Выполняет результат действия для неудачного результата

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

Давайте посмотрим, как выглядит конечный вариант действия обработки заказов, в котором используются специальные вспомогательные методы для создания CommandResult.

public CommandResult Ship(int orderId)
{
	var message = new ShipOrderMessage { OrderId = orderId };
	return Command(message,
			() => RedirectToAction("Shipped", new { orderId }),
			() => RedirectToAction("NotShipped", new { orderId }));
}

В новом действии Ship мы вызываем вспомогательный метод с аргументами для сообщения, результаты действий для успешного и неудачного результата. Так как мы пишем декларативный код для определения сообщения и результатов действий, написание и тестирование контроллеров, созданных с помощью этих техник, будет простым. Чтобы их протестировать, достаточно проверить сообщение CommandResult, результаты действий для успешного и неудачного результата и убедиться, что заявленные результаты такие, как предполагалось. Тест для этого действия включен в пример кода для этой главы.

Благодаря тому, что мы посылаем команды через шину приложения, у нас появился небольшой логический путь, по которому движутся все бизнес-операции. Мы можем воспользоваться этим путем и создать механизм для более серьезной валидации, аудита и других комплексных решений.