Август 2016

ТОМ 31 НОМЕР 8

ASP.NET Core - Фильтры ASP.NET Core MVC

Стив Смит

Продукты и технологии:

ASP.NET Core MVC

В статье рассматриваются:

  • виды фильтров;
  • области действия фильтров;
  • пример MVC-контроллера с фильтрами и без;
  • тестирование фильтров.

Фильтры — отличное, часто недооцениваемое средство ASP.NET MVC и ASP.NET Core MVC. Они предоставляют способ подключения к конвейеру запуска MVC-операций, что делает их превосходной функциональностью для автоматизации распространенных повторяющихся задач. Нередко в приложении действует стандартная политика, которая применяется к тому, как именно обрабатываются определенные условия, особенно те, что способны генерировать конкретные коды состояния HTTP. Кроме того, они позволяют выполнять обработку ошибок или осуществлять протоколирование на уровне приложения специфическим образом по каждой операции. Эти виды политики представляют сквозную функциональность (cross-cutting concerns), и по возможности вы предпочтете следовать принципу «не повторяйтесь» (Don’t Repeat Yourself, DRY) и извлечь ее в общую абстракцию. Потом вы сможете применять эту абстракцию глобально или там, где это необходимо. Фильтры — великолепный способ для достижения этой цели.

А как же промежуточный уровень?

В позапрошлом номере я описал, каким образом промежуточный уровень (middleware) ASP.NET Core дает возможность управлять конвейером запросов в ваших приложениях (msdn.magazine.com/mt707525). Это звучит подозрительно похоже на то, что могут делать фильтры в приложении ASP.NET Core MVC. Разница между ними заключается в контексте. ASP.NET Core MVC реализована на основе промежуточного уровня. (Сама MVC не является промежуточным уровнем, но автоматически конфигурируется как адресат по умолчанию для промежуточного уровня маршрутизации [routing middleware]). ASP.NET Core MVC включает много средств вроде связывания модели (Model Binding), согласования контента (Content Negotiation) и форматирования ответа (Response Formatting). Фильтры существуют в контексте MVC, поэтому у них есть доступ к средствам и абстракциям уровня MVC. Промежуточный уровень, напротив, существует на более низком уровне и не имеет достоверных знаний о MVC или ее средствах.

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

Виды фильтров

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

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

Фильтры операций (action filters) выполняются непосредственно перед выполнением операций и сразу после них. Они запускаются после того, как происходит связывание модели, поэтому они имеют доступ к параметрам, связанным с моделью, которые будут переданы операции, а также к состоянию проверки модели.

Операции возвращают результаты. Фильтры результатов выполняются непосредственно перед возвратом результатов и после этого. Они могут добавлять поведение в работу представления или средства форматирования.

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

В этой статье я сосредоточусь на фильтрах операций.

Области действия фильтров

Фильтры можно применять глобально, на уровне отдельного контроллера или операции. Фильтры, реализованные как атрибуты, обычно могут быть добавлены на любом уровне, причем глобальные фильтры влияют на все операции, фильтры-атрибуты контроллера — на все операции контроллера и фильтры-атрибуты операции — только на эту операцию. Когда к операции применяется несколько фильтров, их порядок определяется в первую очередь свойством Order, а во вторую — их областью действия для данной операции. Фильтры с одинаковым значением свойства Order выполняются снаружи внутрь (outside-in), т. е. глобальный фильтр, затем уровня контролера, а далее уровня операции. После выполнения операции порядок инвертируется, поэтому сначала выполняется фильтр уровня операции, затем уровня контроллера и, наконец, глобальный фильтр.

Фильтры, реализованные не как атрибуты, все равно можно применять к контроллерам или операциям, используя тип TypeFilterAttribute. Этот атрибут принимает тип фильтра для выполнения как параметр конструктора. Например, чтобы применить CustomActionFilter к одному методу операции, вы написали бы следующее:

[TypeFilter(typeof(CustomActionFilter))]
public IActionResult SomeAction()
{
  return View();
}

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

DRY API

Чтобы продемонстрировать несколько примеров, где фильтры могут улучшить структуру приложения ASP.NET MVC Core, я создал простой API, который предоставляет базовую CRUD-функциональность (create, read, update, delete) и следует нескольким стандартным правилам обработки неправильных запросов. Поскольку защита API — отдельная тема, я намеренно оставляю ее за скобками этого примера.

Мое приложение-пример предоставляет API для управления авторами, которые являются простыми типами с парой свойств. API использует стандартные соглашения на основе HTTP-команд, чтобы получить всех авторов или одного автора по его идентификатору, создать нового автора, отредактировать автора и удалить его. Он принимает IAuthorRepository через встраивание зависимостей (dependency injection, DI) для абстрагирования от доступа к данным. (Подробнее о DI см. мою статью в номере за май по ссылке msdn.com/magazine/mt703433.) Реализации контроллера и репозитария являются асинхронными.

API следует двум политикам.

  1. API-запросы, указывающие идентификатор конкретного автора, будут получать ответ с кодом 404, если такого идентификатора нет.
  2. API-запросы, предоставляющие неправильный экземпляр модели Author (ModelState.IsValid == false), будут получать BadRequest со списком ошибок в модели.

На рис. 1 показана реализация этого API вместе с описанными выше правилами.

Рис. 1. AuthorsController

[Route("api/[controller]")]
public class AuthorsController : Controller
{
  private readonly IAuthorRepository _authorRepository;

  public AuthorsController(IAuthorRepository authorRepository)
  {
    _authorRepository = authorRepository;
  }

  // GET: api/authors
  [HttpGet]
  public async Task<List<Author>> Get()
  {
    return await _authorRepository.ListAsync();
  }

  // GET api/authors/5
  [HttpGet("{id}")]
  public async Task<IActionResult> Get(int id)
  {
    if ((await _authorRepository.ListAsync()).All(
      a => a.Id != id))
    {
      return NotFound(id);
    }
    return Ok(await _authorRepository.GetByIdAsync(id));
  }

  // POST api/authors
  [HttpPost]
  public async Task<IActionResult> Post(
    [FromBody]Author author)
  {
    if (!ModelState.IsValid)
    {
      return BadRequest(ModelState);
    }
    await _authorRepository.AddAsync(author);
    return Ok(author);
  }

  // PUT api/authors/5
  [HttpPut("{id}")]
  public async Task<IActionResult> Put(int id,
    [FromBody]Author author)
  {
    if ((await _authorRepository.ListAsync()).All(
      a => a.Id != id))
    {
      return NotFound(id);
    }
    if (!ModelState.IsValid)
    {
       return BadRequest(ModelState);
    }
    author.Id = id;
    await _authorRepository.UpdateAsync(author);
    return Ok();
  }

  // DELETE api/values/5
  [HttpDelete("{id}")]
  public async Task<IActionResult> Delete(int id)
  {
    if ((await _authorRepository.ListAsync()).All(
      a => a.Id != id))
    {
      return NotFound(id);
    }
    await _authorRepository.DeleteAsync(id);
    return Ok();
  }

  // GET: api/authors/populate
  [HttpGet("Populate")]
  public async Task<IActionResult> Populate()
  {
    if (!(await _authorRepository.ListAsync()).Any())
    {
      await _authorRepository.AddAsync(new Author()
      {
        Id = 1,
        FullName = "Steve Smith",
        TwitterAlias = "ardalis"
      });
      await _authorRepository.AddAsync(new Author()
      {
        Id = 2,
        FullName = "Neil Gaiman",
        TwitterAlias = "neilhimself"
      });
    }
    return Ok();
  }
}

Как видите, в этом коде приличный объем логики дублируется, особенно в том, как возвращаются результаты NotFound и BadRequest. Я могу быстро заменить проверки модели/результата BadRequest простым фильтром операции:

public class ValidateModelAttribute : ActionFilterAttribute
{
  public override void OnActionExecuting(
    ActionExecutingContext context)
  {
    if (!context.ModelState.IsValid)
    {
      context.Result = new BadRequestObjectResult(
        context.ModelState);
    }
  }
}

Этот атрибут потом можно применить к тем операциям, которым нужно выполнять проверку модели, и для этого к методу операции следует добавить [ValidateModel]. Заметьте, что установка свойства Result в ActionExecutingContext замкнет запрос. В данном случае ничто не мешает применить атрибут к каждой операции, поэтому я добавлю его к контроллеру, а не к каждой операции.

Проверка на существование автора несколько сложнее, поскольку она опирается на IAuthorRepository, который передается контроллеру через DI. Создать фильтр-атрибут операции, который принимает параметр конструктора, достаточно легко, но, к сожалению, атрибуты ожидают передачи этих параметров в тех местах, где они объявляются. Я не могу предоставить экземпляр репозитария там, где применяется атрибут; мне нужно, чтобы он встраивался в период выполнения контейнером сервисов.

К счастью, атрибут TypeFilter обеспечит поддержку DI, требуемую этим фильтром. Я могу просто применить атрибут TypeFilter к операциям и указать тип ValidateAuthorExistsFilter:

[TypeFilter(typeof(ValidateAuthorExistsFilter))]

Хотя это работает, такой подход не относится к числу предпочитаемых мной, поскольку он менее читаем и поскольку разработчики, стремящиеся применить один из нескольких общих фильтров-атрибутов, не найдут ValidateAuthorExistsAttribute через IntelliSense. Поэтому я выбираю создание подкласса для TypeFilterAttribute, присваиваю ему подходящее имя и помещаю реализацию фильтра в закрытый класс внутри этого атрибута. Рис. 2 демонстрирует этот подход. Реальная работа выполняется закрытым классом ValidateAuthorExistsFilterImpl, чей тип передается в конструктор TypeFilterAttribute.

Рис. 2. ValidateAuthorExistsAttribute

public class ValidateAuthorExistsAttribute :
  TypeFilterAttribute
{
  public ValidateAuthorExistsAttribute():base(typeof
    (ValidateAuthorExistsFilterImpl))
  {
  }

  private class ValidateAuthorExistsFilterImpl :
    IAsyncActionFilter
  {
    private readonly IAuthorRepository _authorRepository;

    public ValidateAuthorExistsFilterImpl(
      IAuthorRepository authorRepository)
    {
      _authorRepository = authorRepository;
    }

    public async Task OnActionExecutionAsync(
      ActionExecutingContext context,
      ActionExecutionDelegate next)
    {
      if (context.ActionArguments.ContainsKey("id"))
      {
        var id = context.ActionArguments["id"] as int?;
        if (id.HasValue)
        {
          if ((await _authorRepository.ListAsync()).All(
            a => a.Id != id.Value))
          {
            context.Result = new NotFoundObjectResult(
              id.Value);
            return;
          }
        }
      }
      await next();
    }
  }
}

Заметьте, что у этого атрибута есть доступ к аргументам, передаваемым операции, как части параметра ActionExecutingContext. Это позволяет фильтру проверять, присутствует ли параметр id, и получать его значение до проверки того, существует ли Author с этим идентификатором. Также следует отметить, что закрытый ValidateAuthorExistsFilterImpl является асинхронным фильтром. При этом шаблоне нужно реализовать всего один метод, и работа может выполняться до или после операции ее запуском до или после вызова next. Однако, если вы замкнете этот фильтр, установив context.Result, вам потребуется вернуть управление без вызова next (в ином случае вы получите исключение).

Еще одно, что следует помнить о фильтрах, — они не должны включать никакого состояния уровня объекта, например поля в IActionFilter (в том числе того, который реализован как атрибут), устанавливаемого в OnActionExecuting, а затем считываемого или модифицируемого в OnActionExecuted. Если у вас возникнет необходимость в логике такого рода, вы можете избежать этот вид состояния, переключившись в IAsyncActionFilter, который способен использовать локальные переменные в методе OnActionExecutionAsync.

После переноса верификации модели и проверки на наличие записей из операций контроллера в общие фильтры можно посмотреть, как это отразилось на коде контроллера, на рис. 3, где показан Authors2Controller, который выполняет ту же логику, что и AuthorsController, но использует эти два фильтра.

Рис. 3. Authors2Controller

[Route("api/[controller]")]
[ValidateModel]
public class Authors2Controller : Controller
{
  private readonly IAuthorRepository _authorRepository;

  public Authors2Controller(IAuthorRepository authorRepository)
  {
    _authorRepository = authorRepository;
  }

  // GET: api/authors2
  [HttpGet]
  public async Task<List<Author>> Get()
  {
    return await _authorRepository.ListAsync();
  }

  // GET api/authors2/5
  [HttpGet("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Get(int id)
  {
    return Ok(await _authorRepository.GetByIdAsync(id));
  }

  // POST api/authors2
  [HttpPost]
  public async Task<IActionResult> Post(
    [FromBody]Author author)
  {
    await _authorRepository.AddAsync(author);
    return Ok(author);
  }

  // PUT api/authors2/5
  [HttpPut("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Put(int id,
    [FromBody]Author author)
  {
    await _authorRepository.UpdateAsync(author);
    return Ok();
  }

  // DELETE api/authors2/5
  [HttpDelete("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Delete(int id)
  {
    await _authorRepository.DeleteAsync(id);
    return Ok();
  }
}

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

А как это протестировать?

Перемещение логики из контроллера в атрибуты очень полезно для уменьшения сложности кода и обеспечения согласованного поведения в период выполнения. К сожалению, если вы запускаете модульные тесты напрямую для методов операций, у ваших тестов не будет поведения, применяемого атрибутом или фильтром. Так и задумано, и, конечно, вы можете выполнять модульное тестирование фильтров независимо от индивидуальных методов операций, чтобы убедиться в их нормальной работе. Но как быть, если вам надо не просто убедиться в работе своих фильтров, а в том, что они правильно настроены и применяются к индивидуальным методам операций? Как быть, если вы хотите выполнить рефакторинг какого-то уже существующего API-кода, чтобы задействовать только что показанные мной преимущества фильтров, и вам нужно быть уверенным, что по окончании рефакторинга API по-прежнему ведет себя корректно? Это требует интеграционного тестирования. К счастью, в ASP.NET Core есть неплохая поддержка для быстрого интеграционного тестирования.

Мое приложение-пример сконфигурировано на использование DbContext в памяти из Entity Framework Core, но даже если бы он использовал SQL Server, я смог бы легко переключиться на применение хранилища в памяти для своих интеграционных тестов. Это важно, поскольку это кардинально увеличивает скорость таких тестов, а так как никакой инфраструктуры не требуется, их подготовка намного облегчается.

Класс, который берет на себя большую часть тяжелой работы, связанной с интеграционным тестированием в ASP.NET Core, — это TestServer, доступный в пакете Microsoft.AspNetCore.TestHost. Вы конфигурируете TestServer идентично тому, как настраиваете веб-приложение в точке входа Program.cs с помощью WebHostBuilder. В своих тестах я предпочитаю использовать тот же класс Startup, что и в приложении-примере, и указываю, что он выполняется в среде Testing. Это инициирует использование некоторых образцов данных при запуске сайта:

var builder = new WebHostBuilder()
  .UseStartup<Startup>()
  .UseEnvironment("Testing");
var server = new TestServer(builder);
var client = server.CreateClient();

В этом случае клиентом является стандартный System.Net.Http.HttpClient, который вы используете для выдачи запросов серверу так, будто он находится в сети. Но поскольку все запросы выдаются к памяти, тесты выполняются чрезвычайно быстро и надежно.

Для своих тестов я применю xUnit, которая, в том числе, позволяет выполнять несколько тестов с разными наборами данных для заданного метода теста. Чтобы подтвердить, что мои классы AuthorsController и Authors2Controller ведут себя идентично, я задействую эту функциональность для указания обоих контроллеров в каждом тесте. На рис. 4 показано несколько тестов метода Put.

Рис. 4. Тесты метода Put для авторов

[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsNotFoundForId0(string controllerName)
{
  var authorToPost = new Author() { Id = 0, FullName = "test",
    TwitterAlias = "test" };
  var jsonContent = new StringContent(
    JsonConvert.SerializeObject(authorToPost),
    Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/
    {controllerName}/0", jsonContent);

  Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
  var stringResponse =
    await response.Content.ReadAsStringAsync();

  Assert.Equal("0", stringResponse);
}

[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsBadRequestGivenNoAuthorName(
  string controllerName)
{
  var authorToPost = new Author() {Id=1, FullName = "",
    TwitterAlias = "test"};
  var jsonContent = new StringContent(
    JsonConvert.SerializeObject(authorToPost),
    Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/
    {controllerName}/1", jsonContent);

  Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);

  var stringResponse =
    await response.Content.ReadAsStringAsync();
  Assert.Contains("FullName", stringResponse);
  Assert.Contains("The FullName field is required.",
    stringResponse);
}

[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsOkGivenValidAuthorData(
  string controllerName)
{
  var authorToPost = new Author() { Id=1,FullName = "John Doe",
    TwitterAlias = "johndoe" };
  var jsonContent = new StringContent(
    JsonConvert.SerializeObject(authorToPost),
    Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/
    {controllerName}/1", jsonContent);
  response.EnsureSuccessStatusCode();
}

Заметьте, что эти интеграционные тесты не требуют ни базы данных, ни соединения с Интернетом, ни работающего веб-сервера. Они почти так же быстры и просты, как модульные тесты, но, что важнее всего, позволяют тестировать приложения ASP.NET по всему конвейеру обработки запросов, а не только изолированный метод в классе контроллера. Тем не менее, я все равно рекомендую писать модульные тесты везде, где это возможно, и переключаться на интеграционные тесты для проверки того поведения, которое нельзя проверить модульными тестами. В любом случае это прекрасно, что у нас есть такой высокопроизводительный способ выполнения интеграционных тестов в ASP.NET Core.

Что дальше?

Фильтры — тематика обширная. У меня хватило места в этой статье лишь на пару примеров. Вы можете посмотреть официальную документацию на docs.asp.net, чтобы узнать больше о фильтрах и тестировании приложений ASP.NET Core.


Стив Смит (Steve Smith) — независимый тренер, наставник и консультант, а также обладатель звания ASP.NET MVP. Написал десятки статей для официальной документации по ASP.NET Core (docs.asp.net). Помогает группам разработчиков быстрее освоить ASP.NET Core. С ним можно связаться через сайт ardalis.com; также следите за его заметками в Twitter (@ardalis).


Выражаю благодарность за рецензирование статьи эксперту Microsoft Дугу Бантингу (Doug Bunting).



Discuss this article in the MSDN Magazine forum