На переднем крае

Динамические фильтры операций в ASP.NET MVC

Дино Эспозито

Ted NewardВ прошлый раз я рассматривал роль и реализацию фильтров операций в приложении ASP.NET MVC. Кратко напомню, о чем мы говорили. Фильтр операции (action filter) — это атрибут, который, будучи связанным с классом или методом контроллера, позволяет декларативно подключать к запрошенной операции некое поведение. В качестве примера вы могли бы написать атрибут Compress и сделать так, чтобы он прозрачно обрабатывал любой ответ, генерируемый неким методом, сжимая его поток данных по алгоритму gzip. Основное преимущество заключается в том, что код сжатия остается изолированным в отдельном и простом для повторного использования классе; тем самым мы добиваемся предельного сужения круга задач, выполняемых методом.

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

Для веб-сайтов (в основном веб-порталов) с часто меняющимся контентом и функциональностью, а также для приложений «software as a service (SaaS) с широкими возможностями в настройке любое решение, избавляющее от модификации исходного кода, более чем желанно. Итак, вопрос в том, можно ли каким-то образом динамически загружать фильтры операций? И ответ, как демонстрируется в остальной части этой статьи:безусловно, да.

Внутри ASP.NET MVC

ASP.NET MVC предоставляет ряд интерфейсов и переопределяемых методов, с помощью которых можно настраивать практически все аспекты этой инфраструктуры. Если в двух словах, то весь набор фильтров операций для метода контроллера загружается и хранится в памяти как список. Разработчик может обращаться к этому списку и просматривать его. Кроме того, вы можете модифицировать этот список и даже заполнять его динамически.

Давайте подробнее рассмотрим, как это работает и что при этом делает инфраструктура. Попутно вы познакомитесь с центральным компонентом, манипуляции над которым и позволяют работать с динамическими фильтрами: инициатором операции (action invoker).

Инициатор операции в конечном счете ответе за выполнение любых методов операции в классе контроллера. Он реализует внутренний жизненный цикл каждого запроса ASP.NET MVC. Инициатор — это экземпляр класса, реализующего интерфейс IActionInvoker. У каждого класса контроллера есть свой объект инициатора, предоставляемый внешнему миру через обычное свойство ActionInvoker с аксессорами get/set. Это свойство определено в базовом типе System.Web.Mvc.Controller так:

public IActionInvoker ActionInvoker {

  get {

    if (this._actionInvoker == null) {

      this._actionInvoker = this.CreateActionInvoker();

    }

    return this._actionInvoker;

  }

  set {

    this._actionInvoker = value;

  }

}

CreateActionInvoker является защищенным переопределяемым методом типа Controller. Вот его реализация:

protected virtual IActionInvoker CreateActionInvoker() {

  // Creates an instance of the built-in invoker

  return new ControllerActionInvoker();

}

Оказывается, инициатор операции можно как угодно изменять для любого контроллера. Однако, поскольку он начинает работать на весьма ранней стадии жизненного цикла запроса, вам, вероятно, понадобится фабрика контроллера для замены исходного инициатора собственным. В сочетании с инфраструктурой Inversion of Control (IoC) наподобие Unity этот подход позволит вам напрямую модифицировать логику инициатора через параметры контейнера IoC.

В качестве альтернативы вы можете определить свой базовый класс контроллера для приложения и переопределить метод CreateActionInvoker так, чтобы он возвращал только нужный вам объект инициатора. Именно такой подход применяется в инфраструктуре ASP.NET MVC для поддержки асинхронного выполнения операций контроллера.

Инициатор операции построен вокруг интерфейса IActionInvoker, который довольно прост, так как предоставляет всего один метод:

public interface IActionInvoker {

  bool InvokeAction(

    ControllerContext controllerContext, 

    String actionName);

}

Обсудим основные задачи исходного инициатора операции (т. е. предлагаемого по умолчанию). Инициатор сначала получает информацию о контроллере, стоящем за запросом, и о конкретной операции, которую нужно выполнить. Информация поступает через специальный объект-дескриптор (descriptor object). Дескриптор включает имя и тип контроллера, а также список атрибутов и операций. Для большей производительности инициатор создает свой кеш дескрипторов операции и контроллера.

Любопытно взглянуть на прототип класса ControllerDescriptor, который показан на рис. 1. Он представляет базовый класс для любого реального дескриптора.

Рис. 1. Класс ControllerDescriptor

public abstract class ControllerDescriptor : 

  ICustomAttributeProvider {



  // Properties

  public virtual string ControllerName { get; }

  public abstract Type ControllerType { get; }



  // Method

  public abstract ActionDescriptor[] GetCanonicalActions();

  public virtual object[] GetCustomAttributes(bool inherit);

  public abstract ActionDescriptor FindAction(

    ControllerContext controllerContext, 

    string actionName);

  public virtual object[] GetCustomAttributes(

    Type attributeType, bool inherit);

  public virtual bool IsDefined(

    Type attributeType, bool inherit);

}

В инфраструктуре ASP.NET MVC применяются два конкретных класса дескрипторов, интенсивно используемых механизмом отражения в Microsoft .NET Framework на внутреннем уровне. Один из них называется ReflectedControllerDescriptor, а другой — ReflectedAsyncControllerDescriptor (он используется только для асинхронных контроллеров).

Я с трудом могу вообразить ситуацию, где понадобился бы собственный дескриптор. Однако для тех, кому это интересно, я опишу, как создается свой дескриптор.

Представьте себе, что вы создаете производный класс дескриптора и переопределяете метод GetCanonicalActions, чтобы считывать список поддерживаемых операций из конфигурационного файла или из таблицы базы данных. В таком случае вы можете удалять методы операций из списка, исходя из неких настраиваемых данных. Но, чтобы это работало, нужно ввести собственный инициатор операции и корректно написать его метод GetControllerDescriptor, чтобы он возвращал экземпляр вашего объекта-дескриптора:

protected virtual ControllerDescriptor 

  GetControllerDescriptor(

  ControllerContext controllerContext);

Получение информации о контроллере и методе операции — это лишь первый шаг, выполняемый инициатором операции. Далее, что интереснее для тематики данной статьи, инициатор операции получает список фильтров операции для обрабатываемого метода. Кроме того, инициатор проверяет разрешения авторизованного пользователя, анализирует запрос на наличие потенциально опасных данных, а затем вызывает метод.

Получение списка фильтров операции

Хотя инициатор операции идентифицируется с помощью интерфейса IActionInvoker, инфраструктура ASP.NET MVC использует сервисы встроенного класса ControllerActionInvoker. Этот класс поддерживает массу дополнительных методов и механизмов, в том числе ранее упомянутые дескрипторы и фильтры операций.

Класс ControllerActionInvoker предоставляет две основные точки вмешательства в манипуляции с фильтрами операций. Одной из них является метод GetFilters:

protected virtual ActionExecutedContext 

  InvokeActionMethodWithFilters(

  ControllerContext controllerContext, 

  IList<IActionFilter> filters, 

  ActionDescriptor actionDescriptor, 

  IDictionary<string, object> parameters);

а другой — метод InvokeActionMethodWithFilters:

protected virtual FilterInfo GetFilters(

  ControllerContext controllerContext, 

  ActionDescriptor actionDescriptor)

Как видите, оба методы помечены как защищенные и виртуальные.

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

Заметьте, что после вызова GetFilters инициатор сохраняет доступным весь список фильтров для каждой возможной категории, в том числе для фильтров исключений, результатов, авторизации и, конечно, операции. Рассмотрим следующий класс контроллера:

[HandleError]

public class HomeController : Controller {

  public ActionResult About() {

    return View();

  }

}

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

Теперь добавим собственный инициатор, переопределим метод GetFilters и поместим точку прерывания (breakpoint) в последнюю строку кода:

protected override FilterInfo GetFilters(

  ControllerContext controllerContext, 

  ActionDescriptor actionDescriptor) {



  var filters = base.GetFilters(

    controllerContext, actionDescriptor);

  return filters;

}

Реальное содержимое списка переменных фильтров показано на рис. 2.

image: Intercepting the Content of the Filters Collection

Рис. 2. Перехват содержимого набора фильтров

FilterInfo — открытый класс в System.Web.Mvc — предоставляет специфические наборы фильтров для каждой категории:

public class FilterInfo {

  public IList<IActionFilter> ActionFilters { get; }

  public IList<IAuthorizationFilter> AuthorizationFilters { get; }

  public IList<IExceptionFilter> ExceptionFilters { get; }

  public IList<IResultFilter> ResultFilters { get; }

  ...

}

Как и на рис. 2, для тривиального класса, показанного ранее, вы насчитаете один фильтр операции, один фильтр авторизации, один фильтр результатов и два фильтра исключения. Кто определил фильтры операции, результатов и авторизации? Сам класс контроллера является фильтром операции. По сути, базовый класс Controller реализует все необходимые интерфейсы фильтра:

public abstract class Controller : 

    ControllerBase, IDisposable,

    IActionFilter, IAuthorizationFilter, 

    IExceptionFilter, IResultFilter {

    ...

  }

Базовая реализация GetFilters отражает атрибуты из класса контроллера, используя механизм отражения в .NET Framework. В своей реализации метода GetFilters вы можете добавлять столько фильтров, сколько вам нужно, считывая их из любого места. Все, что требуется для этого, — примерно такой код:

protected override FilterInfo GetFilters(

  ControllerContext controllerContext, 

  ActionDescriptor actionDescriptor) {



  var filters = base.GetFilters(

    controllerContext, actionDescriptor);



  // Load additional filters

  var extraFilters = ReadFiltersFromConfig();

  filters.Add(extraFilters);



  return filters;

}

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

Запуск операции

InvokeActionMethodWithFilters запускается при выполнении метода операции. В данном случае метод принимает список фильтров операции. Однако вам все равно разрешается добавлять дополнительные фильтры в этот момент. На рис. 3 показан пример реализации InvokeActionMethodWithFilters, который динамически добавляет фильтр операции для сжатия вывода. Код на рис. 3 сначала проверяет, какой метод вызывается, а затем создает экземпляр нового фильтра и добавляет его. Само собой разумеется, вы можете определять загрузку фильтров так, как это нужно вам, в том числе считывать информацию о них из конфигурационного файла, базы данных или откуда-то еще. Переопределяя метод InvokeActionMethodWithFilters, вам требуется лишь проверять выполняемый метод, подключать дополнительные фильтры операции и вызывать базовый метод, чтобы далее инициатор работал, как обычно. Чтобы получить информацию о выполняемом методе, используйте контекст контроллера и дескриптор операции.

Рис. 3. Добавление фильтра операции до выполнения самой операции

protected override ActionExecutedContext 

  InvokeActionMethodWithFilters(

  ControllerContext controllerContext, 

  IList<IActionFilter> filters, 

  ActionDescriptor actionDescriptor, 

  IDictionary<String, Object> parameters) {



  if (

    actionDescriptor.ControllerDescriptor.ControllerName == "Home" 

    && actionDescriptor.ActionName == "About") {



    var compressFilter = new CompressAttribute();

    filters.Add(compressFilter);

  }



  return base.InvokeActionMethodWithFilters(

    controllerContext, 

    filters, actionDescriptor, parameters);

}

Итак, у вас есть два возможных подхода к динамическому добавлению фильтров к экземпляру контроллера: переопределение GetFilters и переопределение InvokeActionMethodWithFilters. А какая между ними разница?

Жизненный цикл операции

Прохождение через GetFilters или InvokeActionMethodWithFilters во многом идентично. Некоторые различия действительно есть, но не слишком значимые. Чтобы понять разницу, давайте подробнее обсудим, какие шаги предпринимаются инициатором операции по умолчанию, когда дело доходит до выполнения метода операции. Его жизненный цикл представлен на рис. 4.

image: The Lifecycle of an Action Method

Рис. 4. Жизненный цикл метода операции

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

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

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

Динамические фильтры

Динамическая загрузка фильтров — дополнительная возможность, которая в основном нужна приложениям с крайне высоким уровнем изменяемой функциональности. Фильтр, особенно фильтр операции, обеспечивает поддержку аспектно-ориентированных средств в классе контроллера ASP.NET MVC, так как позволяет разработчикам включать и отключать определенные поведения декларативным способом.

При написании исходного кода класса контроллера вы можете добавить атрибуты операции на уровне класса или метода. При этом отнюдь не всегда очевидно, как именно надо организовывать информацию, чтобы было ясно, какие фильтры относятся к тем или иным методам. В случае базы данных можно создать таблицу, где метод и имя контроллера используются как ключ. В случае конфигурационного файла вам, вероятно, потребуется создать свой раздел, где вы будете хранить только нужную вам информацию. Так или иначе, инфраструктура ASP.NET MVC достаточно гибка, чтобы вы могли выбирать, какие фильтры следует применять на уровне индивидуальных методов, а какие — на уровне классов.

Дино Эспозито (Dino Esposito)  — автор книги «Programming ASP.NET MVC» (Microsoft Press, 2010) и соавтор «Microsoft .NET:Architecting Applications for the Enterprise» (Microsoft Press, 2008). Проживает в Италии и часто выступает на отраслевых мероприятиях по всему миру. С ним можно связаться через его блог weblogs.asp.net/despos.

Выражаю благодарность за рецензировании статьи эксперту Скотту Ханселману (Scott Hanselman)