Фильтры действий, или Как просто улучшить читаемость кода

Введение

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

Роль фильтров в процессе обработки запроса

Сначала обсудим сами фильтры: для чего же они нужны? Фильтры позволяют выполнять определённые действия на различных стадиях обработки запроса в ASP.NET Core. Существуют следующие встроенные фильтры:

  • Фильтры авторизации (Authorization filters) выполняются самыми первыми и определяют, может ли пользователь выполнить текущий запрос.
  • Фильтры ресурсов (Resource filters) вызываются после фильтров авторизации и необходимы, как следует из названия, для обработки ресурсов. В частности, данный тип фильтров применяют в качестве механизма кэширования.
  • Фильтры действий (Action Filters) выполняют указанные в них операции до и после выполнения метода контроллера, обрабатывающего запрос.
  • Фильтры исключений (Exception Filters) используются для перехвата необработанных исключений, произошедших при создании контроллера, привязке модели и выполнении кода фильтров действий и методов контроллера.
  • И наконец, самыми последними вызываются фильтры результатов (Result Filters), если метод контроллера был выполнен успешно. Данный тип фильтров чаще всего используется, чтобы модифицировать конечные результаты, например, мы можем создать свой заголовок ответа, в котором добавим нужную нам информацию.

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

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

Внутреннее устройство фильтров действий

Фильтры действий в ASP.NET

Интерфейс IActionFilter, который нужно реализовать, чтобы создать фильтр действий, существовал ещё в ASP.NET MVC. Он определяет методы OnActionExecuting, который вызывается перед выполнением метода контроллера, и OnActionExecuted, который вызывается сразу после. Ниже представлен пример простейшего фильтра действий, который выводит информацию во время отладки приложения до и после выполнения метода контроллера:

public class CustomActionFilter:IActionFilter 
{ 
        public void OnActionExecuting(ActionExecutingContext filterContext) 
        { 
            Debug.WriteLine("Before Action Execution"); 
        } 

        public void OnActionExecuted(ActionExecutedContext filterContext) 
        { 
            Debug.WriteLine("After Action Execution"); 
        } 
}

Чтобы использовать вышеуказанный фильтр, его нужно зарегистрировать. Для этого в файле FilterConfig.cs, который находится в папке App_Start, следует добавить следующую строку:

public static void RegisterGlobalFilters(GlobalFilterCollection filters) 
{ 
        filters.Add(new HandleErrorAttribute()); 
        filters.Add(new CustomActionFilter()); 
}

Но гораздо удобнее использовать фильтры как атрибуты. Для этих целей существует абстрактный класс ActionFilterAttribute, который унаследован от класса FilterAttribute, а также реализует интерфейсы IActionFilter и IResultFilter. Таким образом, наш класс можно переписать следующим образом:

public class CustomActionFilterAttribute:ActionFilterAttribute 
{ 
        public override void OnActionExecuting(ActionExecutingContext filterContext) 
        { 
            Debug.WriteLine("Before Action Execution"); 
        } 

        public override void OnActionExecuted(ActionExecutedContext filterContext) 
        { 
            Debug.WriteLine("After Action Execution"); 
        } 
} 

Теперь, чтобы применить наш фильтр, мы добавляем его к методу контроллера следующим образом:

public class HomeController : Controller 
{ 
        [CustomActionFilter] 
        public ActionResult Index() 
        { 
            return View(); 
        } 
}

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

Фильтры действий в ASP.NET Core

С появлением ASP.NET Core в фильтрах действий произошёл ряд изменений. Кроме интерфейса IActionFilter, теперь имеется ещё и IAsyncActionFilter, который определяет единственный метод OnActionExecutionAsync. Ниже приведён пример класса, реализующего интерфейс IAsyncActionFilter:

public class AsyncCustomActionFilterAttribute:Attribute, IAsyncActionFilter 
{ 
        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) 
        { 
            Debug.WriteLine("Before Action Execution"); 

            await next(); 

            Debug.WriteLine("After Action Execution"); 
        } 
} 

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

Применяют такой фильтр так же, как и синхронный:

public class HomeController : Controller 
{ 
        [AsyncCustomActionFilter] 
        public ActionResult Index() 
        { 
            return View(); 
        } 
}

Также изменения затронули абстрактный класс ActionFilterAttribute: теперь он наследуется от класса Attribute и реализует синхронные и асинхронные интерфейсы для фильтров действий (IActionFilter и IAsyncActionFilter) и для фильтров результатов (IResultFilter и IAsyncResultFilter), а также интерфейс IOrderedFilter.

Фильтры действий в действии

Перейдём непосредственно к случаям, когда лучше использовать фильтры действий. Возьмём, например, ситуацию, когда мы создаём веб-приложение и нам нужно сохранить данные, которые приложение получает с помощью метода POST. Допустим, мы ведём сведения о сотрудниках организации. Чтобы представить данные на сервере, мы используем следующий класс:

public class Employee 
{ 
        [Required(ErrorMessage = "First name is required")] 
        public string FirstName { get; set; } 

        [Required(ErrorMessage = "Last name is required")] 
        public string LastName { get; set; } 

        [AgeRestriction(MinAge = 18, ErrorMessage = "Date of birth is incorrect")] 
        public DateTime DateOfBirth { get; set; } 

        [StringLength(50, MinimumLength = 2)] 
        public string Position { get; set; } 

        [Range(45000, 200000)] 
        public int Salary { get; set; } 
} 

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

После того как были реализованы методы POST и PUT, мы видим, что оба метода содержат повторяющиеся части кода:

[HttpPost] 
public IActionResult Post([FromBody] Employee value) 
{ 
            if (value == null) 
            { 
                return BadRequest("Employee value cannot be null"); 
            } 

            if (!ModelState.IsValid) 
            { 
                return BadRequest(ModelState); 
            } 

            // Perform save actions 
            return Ok(); 
} 

[HttpPut] 
public IActionResult Put([FromBody] Employee value) 
{ 
            if (value == null) 
            { 
                return BadRequest("Employee value cannot be null"); 
            } 

            if (!ModelState.IsValid) 
            { 
                return BadRequest(ModelState); 
            } 

            // Perform update actions 
            return Ok(); 
} 

И здесь нам на помощь приходят фильтры действий. Создадим новый фильтр действий и перенесём в него повторяющиеся строки следующим образом:

public class EmployeeValidationFilterAttribute : ActionFilterAttribute 
{ 
        public override void OnActionExecuting(ActionExecutingContext context) 
        { 
            var employeeObject = context.ActionArguments.SingleOrDefault(p => p.Value is Employee); 
            if (employeeObject.Value == null) 
            { 
                context.Result = new BadRequestObjectResult("Employee value cannot be null"); 
                return; 
            } 

            if (!context.ModelState.IsValid) 
            { 
                context.Result = new BadRequestObjectResult(context.ModelState); 
            } 
        } 
} 

Теперь удаляем ставшие ненужными части кода и применяем созданный нами атрибут-фильтр:

public class EmployeeController : ControllerBase 
{ 
        [EmployeeValidationFilter] 
        [HttpPost] 
        public IActionResult Post([FromBody] Employee value) 
        { 
            // Perform save actions 

            return Ok(); 
        } 

        [EmployeeValidationFilter] 
        [HttpPut] 
        public IActionResult Put([FromBody] Employee value) 
        { 
            // Perform update actions 

            return Ok(); 
        } 
} 

Теперь код выглядит гораздо компактнее и красивее, но в нашем случае его ещё можно упростить: т.к. в контроллере всего 2 метода и оба используют один и тот же фильтр, то можно применить атрибут непосредственно к контроллеру:

[EmployeeValidationFilter] 
public class EmployeeController : ControllerBase 
{ 
            // Perform update actions 
} 

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

Разработчики часто сталкиваются с задачей, когда требуется добавить логирование для определённых методов. Поэтому попробуем добавить в фильтры действий средство логирования, которое будет записывать информацию перед выполнением методов POST или PUT контроллера и сразу после. Наш фильтр будет выглядеть следующим образом:

public class LoggingFilter: IActionFilter 
{ 
        private readonly ILogger _logger; 

        public LoggingFilter(ILoggerFactory loggerFactory) 
        { 
            _logger = loggerFactory.CreateLogger<LoggingFilter>(); 
        } 

        public void OnActionExecuted(ActionExecutedContext context) 
        { 
            _logger.LogInformation($"{context.ActionDescriptor.DisplayName} executed"); 
        } 

        public void OnActionExecuting(ActionExecutingContext context) 
        { 
            _logger.LogInformation($"{context.ActionDescriptor.DisplayName} is executing"); 
        } 
} 

Теперь мы можем применить этот фильтр либо глобально, либо к конкретной области. Сначала попробуем зарегистрировать его глобально. Для этого нам в Startup.cs следует добавить следующие строки:

services.AddControllers(options => 
{ 
                options.Filters.Add<LoggingFilter>(); 
}); 

Если же нам нужно применить фильтр, например, к определённому методу контроллера, то следует его использовать вместе с ServiceFilterAttribute:

[HttpPost] 
[ServiceFilter(typeof(LoggingFilter))] 
public IActionResult Post([FromBody] Employee value) 

ServiceFilterAttribute является фабрикой для других фильтров, реализующей интерфейс IFilterFactory и использующей IServiceProvider для получения нужного фильтра. Поэтому в Startup.cs нам необходимо зарегистрировать наш фильтр следующим образом:

services.AddSingleton<LoggingFilter>(); 

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

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

public class ProviderFilter : IActionFilter 
{ 
        private readonly IDataProvider _dataProvider; 

        public ProviderFilter(IDataProvider dataProvider) 
        { 
            _dataProvider = dataProvider; 
        } 

        public void OnActionExecuted(ActionExecutedContext context) 
        { 
        } 

        public void OnActionExecuting(ActionExecutingContext context) 
        { 
            object idValue; 
            if (!context.ActionArguments.TryGetValue("id", out idValue)) 
            { 
                throw new ArgumentException("id"); 
            } 

            var id = (int)idValue; 
            var result = _dataProvider.GetElement(id); 
            if (result == null) 
            { 
                context.Result = new NotFoundResult(); 
            } 
            else 
            { 
                context.HttpContext.Items.Add("result", result); 
            } 
        } 
} 

Применить этот фильтр можно так же, как и фильтр из предыдущего примера, с помощью ServiceFilterAttribute.

Фильтры действий раньше очень часто применяли, чтобы заблокировать контент для определённых браузеров на основе информации о User-Agent. На ранних этапах становления веб-разработки многие сайты создавались исключительно для наиболее популярных браузеров, остальные же считались «запрещёнными». Сейчас данный подход является нежелательным, т.к. рекомендуется создавать такую HTML-разметку, которую смогло бы поддерживать большинство браузеров. Тем не менее, в некоторых случаях разработчику важно знать источник запроса. Ниже представлен пример получения User-Agent-информации в фильтре действий:

public class BrowserCheckFilter : IActionFilter 
{ 
        public void OnActionExecuting(ActionExecutingContext context) 
        { 
            var userAgent = context.HttpContext.Request.Headers[HeaderNames.UserAgent].ToString().ToLower(); 

            // Detect if a user uses IE 
            if (userAgent.Contains("msie") || userAgent.Contains("trident")) 
            { 
                // Do some actions  
            } 
        } 

        public void OnActionExecuted(ActionExecutedContext context) 
        { 
        } 
} 

Стоит, однако, заметить, что вышеуказанный метод имеет ещё один недостаток. Многие браузеры умеют прятать или подделывать значения, указанные в User-Agent, поэтому данный способ не является однозначно достоверным в определении типа пользовательского браузера.

Другой пример применения фильтров действий — локализация. Создадим фильтр, который в зависимости от указанной культуры будет выводить дату в этой культуре. Ниже представлен код, который задаёт культуру текущего потока:

public class LocalizationActionFilterAttribute: ActionFilterAttribute 
{ 
        public override void OnActionExecuting(ActionExecutingContext filterContext) 
        { 
            var language = (string)filterContext.RouteData.Values["language"] ?? "en"; 
            var culture = (string)filterContext.RouteData.Values["culture"] ?? "GB"; 

            Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo($"{language}-{culture}"); 
            Thread.CurrentThread.CurrentUICulture = CultureInfo.GetCultureInfo($"{language}-{culture}"); 
        } 
} 

Следующим шагом следует добавить маршрутизацию, которая будет перенаправлять URL с данными культуры на наш контроллер:

                endpoints.MapControllerRoute(name:"localizedRoute", 
                    pattern: "{language}-{culture}/{controller}/{action}/{id}", 
                    defaults: new 
                    { 
                        language = "en", 
                        culture = "GB", 
                        controller = "Date", 
                        action = "Index", 
                        id = "", 
                    });

Код выше создаёт маршрут с именем localizedRoute, у которого в шаблоне имеется параметр, отвечающий за локализацию. Значение по умолчанию для этого параметра — “en-GB”.

Теперь создадим контроллер с именем DateController, который будет обрабатывать наш запрос, и представление, которое будет отображать локализованную дату. Код контроллера просто возвращает представлению текущую дату:

[LocalizationActionFilter] 
public class DateController : Controller 
{ 
        public IActionResult Index() 
        { 
            ViewData["Date"] = DateTime.Now.ToShortDateString(); 
            return View(); 
        } 
}  

После того как пользователь перешёл по ссылке localhost:44338/Date, он увидит в браузере следующее:

image
На скриншоте выше текущая дата представлена с учётом локализации, заданной по умолчанию, т.е. с en-GB. Теперь, если пользователь перейдёт по ссылке, в которой будет явно указана культура, например, en-US, то он увидит следующее:


Таким образом, на этом примере мы можем увидеть, как сделать простую и быструю локализацию.

Заключение

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

Let’s block ads! (Why?)

Read More

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *