Friday, February 26, 2016

Using filters in MVC and Web API controllers

Сегодня мы погрузимся в мир изучения работы фильтров в ASP.NET MVC 5. Мы рассмотрим виды фильтров, создание своих фильтров и добавление кастомных фильтров для Web API (ApiController). 
Для начала создадим новое ASP.NET Web Application приложение и назовем его UsingFilterSample.
Затем выберем шаблон Web API.
После того, как мы создали наше приложение, вернемся к теории. По умолчанию в ASP.NET MVC 5 выделяют 5 типов фильтров, которые приведены в таблице ниже.
Как видите, единственным фильтром, у которого нет встроенной реализации, является IAuthenticationFilter, для всех остальных есть реализация по умолчанию.
Давайте рассмотрим методы, которые предоставляют нам эти фильтры.
IAuthentificationFilter: OnAuthentication и OnAuthenticationChallenge
IAuthorizationFilter: OnAuthorization
IExceptionFilter: OnException
Возьмем существующие классы для реализации наших фильтров и на каждый из них реализуем свою реализацию, в которую для начала добавим простое логирование. Чтобы приступить к реализации, остановимся детальнее на классе ActionFilterAttribute. Этот класс уже реализует фильтры IResultFilter и IActionFilter. Функция их описана выше в таблице, а теперь давайте рассмотрим на практике их использование. Для этого на каждый тип фильтров мы создадим свой вариант, в котором просто будем логировать наше действие. Добавим новую папку Filters, если такой папки у вас еще нет. Затем будем по порядку добавлять нужные нам фильтры.
IAuthenticationFilter
Первым делом добавим новый класс AuthenticationCustomFilter, в который добавим следующую логику.
public class AuthenticationCustomFilter : FilterAttribute, IAuthenticationFilter
{
    public void OnAuthentication(AuthenticationContext filterContext)
    {
        IPrincipal user = filterContext.HttpContext.User;
        Debug.WriteLine("OnAuthentication: Controller: {2}, Action Name: {0}, User Name: {1}", filterContext.ActionDescriptor.ActionName,
            user.Identity.Name,
            filterContext.ActionDescriptor.ControllerDescriptor.ControllerName);
    }

    public void OnAuthenticationChallenge(AuthenticationChallengeContext filterContext)
    {
           
    }
}
Так как мы тестируем сейчас MVC фильтры, то в каждый добавленный нами класс добавляем следующие namespace:
using System.Web.Mvc;
using System.Web.Mvc.Filters;
В приведенном выше примере мы попросту имплементировали интерфейс IAuthenticationFilter, задачей которого стоит проверка аутентификации пользователя. В нашем же примере, поскольку мы работаем под анонимным пользователем, не используя какой-либо авторизации и аутентификации, там будет всегда пусто.
AuthorizeAttribute
Затем в эту же папку добавим новый класс AuthorizationCustomFilter, задачей которого будет показ того, как работает авторизация. Ниже приведен код этого класса.
public class AuthorizationCustomFilter : AuthorizeAttribute
{
    public override void OnAuthorization(AuthorizationContext filterContext)
    {
        IPrincipal user = filterContext.HttpContext.User;
        Debug.WriteLine("OnAuthorization: Controller: {2}, Action Name: {0}, User Name: {1}", filterContext.ActionDescriptor.ActionName,
            user.Identity.Name,
            filterContext.ActionDescriptor.ControllerDescriptor.ControllerName);
    }
}
Здесь мы просто логируем пользователя, который к нам залогинился. Зачастую приведенный выше класс AuthorizeAttribute используют без дополнительного написания кода. Например, как показано ниже.
[Authorize(Users = "Joe, Mary", Roles = "Admin, Manager")]
public class HomeController : Controller
{
    public ActionResult Index()
    {
        ViewBag.Title = "Home Page";

        return View();
    }
}
Пример чисто синтетический и не несет особой смысловой нагрузки, поэтому не стоит его копировать.
ActionFilterAttribute
Класс ActionFilterAttribute представляет собой имплементацию интерфейсов IResultFilter и IActionFilter. Для того чтобы посмотреть, как можно использовать фильтры действий и результатов, добавим в папку Filters новый класс, который назовем ActionCustomFilter.
public class ActionCustomFilter : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        Debug.WriteLine("OnActionExecuting: Controller: {0}, Action Name: {1}", filterContext.ActionDescriptor.ControllerDescriptor.ControllerName,
            filterContext.ActionDescriptor.ActionName);
    }

    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        Debug.WriteLine("OnActionExecuted: Controller: {0}, Action Name: {1}", filterContext.ActionDescriptor.ControllerDescriptor.ControllerName,
            filterContext.ActionDescriptor.ActionName);
    }

    public override void OnResultExecuting(ResultExecutingContext filterContext)
    {
        Debug.WriteLine("OnResultExecuting: Controller: {0}, Action Name: {1}", filterContext.RouteData.Values["controller"],
            filterContext.RouteData.Values["action"]);
    }

    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        Debug.WriteLine("OnResultExecuted: Controller: {0}, Action Name: {1}", filterContext.RouteData.Values["controller"],
            filterContext.RouteData.Values["action"]);
    }
}
Здесь мы тоже ничего особенного не делали кроме логирования.
HandleErrorAttribute
Нам осталось напоследок добавить реализацию интерфейса IExceptionFilter, который реализован в классе HandleErrorAttribute, реализацию которого мы и рассмотрим ниже. Для этого мы в нашу папку Filters добавим новый класс CustomExceptionFilter.
public class CustomExceptionFilter : HandleErrorAttribute
{
    public override void OnException(ExceptionContext filterContext)
    {
        Debug.WriteLine("OnException: Controller: {0}, Action Name: {1}, Message: {2}", filterContext.RouteData.Values["controller"],
            filterContext.RouteData.Values["action"],
            filterContext.Exception.Message);
    }
}
Для того чтобы проверить, как работает наш только что созданный фильтр, нужно в наш HomeController добавить следующий метод:
public ActionResult Home()
{
    throw new Exception("Test Custom exceptions");
}
Configuring Filters in ASP.NET MVC
Существуют разные уровни использования фильтров, и сейчас мы по ним пройдемся.
Action Level
Это самый низкий уровень фильтров, которые можно применить, и применяется он на уровне метода. Ниже представлен пример такого метода.
[ActionCustomFilter]
public ActionResult Index()
{
    ViewBag.Title = "Home Page";

    return View();
}
Controller Level
Этот тип фильтров применяется на уровне контроллера. Отличие от предыдущего заключается в том, что под влияние попадают все методы, которые описаны в контроллере.
[Authorize(Users = "Joe, Mary", Roles = "Admin, Manager")]
public class HomeController : Controller
{
    public ActionResult Index()
    {
        ViewBag.Title = "Home Page";

        return View();
    }
}
Global Level
Регистрация глобальных фильтров, которые действуют на уровне всего проекта. Регистрируются они в Global.asax файле в методе
FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
Ниже представлена полная реализация, которая у меня вышла для Global.asax.
public class WebApiApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        AreaRegistration.RegisterAllAreas();
        GlobalConfiguration.Configure(WebApiConfig.Register);
        FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        BundleConfig.RegisterBundles(BundleTable.Bundles);
    }
}
Поскольку для теста предлагаю зарегистрировать все наши фильтры глобально, давайте перейдем в метод RegisterGlobalFilters и изменим его следующим образом:
public class FilterConfig
{
    public static void RegisterGlobalFilters(GlobalFilterCollection filters)
    {
        filters.Add(new CustomExceptionFilter());
        filters.Add(new ActionCustomFilter());
        filters.Add(new AuthorizationCustomFilter());
        filters.Add(new AuthenticationCustomFilter());
    }
}
Теперь давайте запустим наш проект и проверим, что все работает.
А затем в адресной строке браузера введем Home/Home, чтобы посмотреть на наш фильтр обработки ошибок.

Web API Filters
Давайте немного пробежимся по Web API фильтрам. Судя по названию, это фильтры, которые применимы для Web API контроллеров и методов, описанных в них. Все эти фильтры лежат в пространстве имен System.Web.Http.Filters. Если сравнивать Web API фильтры с фильтрами MVC, то у нас остаются практически те же фильтры с той же областью, как и фильтры за исключением того, что в среде Web API фильтров нет интерфейса IResultFilter; вместо него всю эту роль выполняет IActionFilter.
Теперь рассмотрим, как регистрировать глобальные фильтры для Web API. Регистрируются они в файле Global.asax в методе WebApiConfig.Register. Ниже в примере показан пример добавления глобальных фильтров для web api контроллеров.
public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
        // Configure Web API to use only bearer token authentication.
        config.SuppressDefaultHostAuthentication();
        config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));
    }
}
Для того чтобы посмотреть, как работает использование фильтров на уровне методов, откроем файл AccountController и найдем там метод GetUserInfo.
// GET api/Account/UserInfo
[HostAuthentication(DefaultAuthenticationTypes.ExternalBearer)]
[Route("UserInfo")]
public UserInfoViewModel GetUserInfo()
{
    ExternalLoginData externalLogin = ExternalLoginData.FromIdentity(User.Identity as ClaimsIdentity);

    return new UserInfoViewModel
    {
        Email = User.Identity.GetUserName(),
        HasRegistered = externalLogin == null,
        LoginProvider = externalLogin != null ? externalLogin.LoginProvider : null
    };
}
В этом же классе мы сможем увидеть, как работают фильтры на уровне контроллера.
[Authorize]
[RoutePrefix("api/Account")]
public class AccountController : ApiController
{
Чтобы более продуктивно подойти к изучению фильтров для ApiController, рассмотрим более практическое применение этих фильтров. Для этого мы в нашу папку Filters добавим новый класс ValidateModelAttribute, реализация которого приведена ниже.
using System.Net;
using System.Net.Http;
using System.Web.Http.Filters;
using System.Web.Http.Controllers;

namespace UsingFilterSample.Filters
{
    public class ValidateModelAttribute : ActionFilterAttribute
    {
        public override void OnActionExecuting(HttpActionContext actionContext)
        {
            if (actionContext.ModelState.IsValid == false)
            {
                actionContext.Response = actionContext.Request.CreateErrorResponse(
                    HttpStatusCode.BadRequest, actionContext.ModelState);
            }
        }
    }
}
Этот фильтр позволит добавить валидацию для ваших моделей данных. Как видите, достаточно неплохое применение ActionFilterAttribute. Теперь разберемся, как можно использовать кастомную обработку ошибок.  Для этого добавим новый класс DatabaseExceptionFilterAttribute в папку Filters. Ниже представлен код его реализации.
using System.Data.Entity.Infrastructure;
using System.Data.Entity.Validation;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Web.Http.Filters;

namespace UsingFilterSample.Filters
{
    public class DatabaseExceptionFilterAttribute : ExceptionFilterAttribute
    {
        public override void OnException(HttpActionExecutedContext context)
        {
            var e = context.Exception;
            HttpResponseMessage message = null;
            if (e is DbUpdateException)
            {
                var ue = e as DbUpdateException;
                var errorMessage = e.InnerException?.InnerException?.Message ?? ue.InnerException?.Message ?? ue.Message;
                message = context.Request.CreateErrorResponse(HttpStatusCode.BadRequest, errorMessage);
            }
            else if (e is DbEntityValidationException)
            {
                var ev = e as DbEntityValidationException;
                var sb = new StringBuilder();
                foreach (var validationError in
                    ev.EntityValidationErrors.SelectMany(entityValidationError => entityValidationError.ValidationErrors)
                    )
                {
                    sb.AppendLine(string.Format("Property:{0}, ErrorMesage:{1}", validationError.PropertyName, validationError.ErrorMessage));
                }
                message = context.Request.CreateErrorResponse(HttpStatusCode.BadRequest, sb.ToString());
            }
            else
            {
                base.OnException(context);
                return;
            }

            context.Response = message;

            base.OnException(context);
        }
    }
}
Польза данного класса состоит в том, что мы можем расширить ответ, который присылаем клиенту, и клиент может понять, почему ему не удалось сохранить введенные им данные в базу данных. Ниже приведен кусок кода, как это может быть отражено на клиенте через вспомогательные окна или логирование с помощью AngularJS.
$scope.save = function () {
    $http.post('api/Clients', $scope.client).success(function () {
        console.log("Client saved succesfully");
    }).error(function (result) {
        $scope.errors = {};
        errorHelper.validateForms($scope.clientForm, $scope.errors, result.modelState, toastr);
        toastr.error(result.message);
    });
};
Если у нас произошла какая-то ошибка, то мы ее отображаем через message без необходимости проверять тип ошибки и получать необходимое свойство. Например, для DbUpdateException нам пришлось бы детальную ошибку смотреть в InnerException на клиенте, а так фильтры нам помогли упростить нашу задачу.
После проделанной работы давайте зарегистрируем наши фильтры глобально в WebApiConfig.Register, как показано ниже.
config.Filters.Add(new ValidateModelAttribute());
config.Filters.Add(new DatabaseExceptionFilterAttribute());
Полную реализацию можно увидеть ниже.
public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
        // Configure Web API to use only bearer token authentication.
        config.SuppressDefaultHostAuthentication();
        config.Filters.Add(new HostAuthenticationFilter(OAuthDefaults.AuthenticationType));
        config.Filters.Add(new ValidateModelAttribute());
        config.Filters.Add(new DatabaseExceptionFilterAttribute());

        // Web API routes
        config.MapHttpAttributeRoutes();

        config.Routes.MapHttpRoute(
            name: "DefaultApi",
            routeTemplate: "api/{controller}/{id}",
            defaults: new { id = RouteParameter.Optional }
        );
    }
}

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

Исходники: Filters Sample