在 ASP.NET Core 项目中,总有一些横切关注点(Cross-Cutting Concerns)——比如接口日志、权限校验、异常处理、性能监控——它们不属于某个具体的业务方法,但又几乎每个请求都需要执行。如果在每个 Action 里手动写一遍,代码会变得臃肿且难以维护。

过滤器(Filter) 就是 ASP.NET Core 提供的官方解决方案:它允许你在请求管道的特定阶段无侵入地插入自定义逻辑,实现关注点分离。本文基于 SwitchData 项目的实际代码,带你完整走一遍过滤器体系。

Filter 体系速览

ASP.NET Core 内置了五种过滤器类型,按执行顺序排列:

类型 接口 执行时机 典型场景
Authorization Filter IAsyncAuthorizationFilter 最早,模型绑定之前 权限校验、JWT 验证
Resource Filter IAsyncResourceFilter Authorization 之后 缓存、请求短路
Action Filter IAsyncActionFilter Action 执行前后 日志、参数验证
Result Filter IAsyncResultFilter Result 执行前后 响应格式化
Exception Filter IAsyncExceptionFilter Action 抛异常时 全局异常处理

每类过滤器都有同步和异步两种接口。强烈建议优先使用异步版本——同步版本在内部会阻塞线程池线程。

实战一:接口操作日志过滤器

需求

每个 API 接口的调用都需要记录到数据库:调用人、控制器/方法、URL、入参、出参。同时需要自动读取 DisplayAttribute 作为中文名称,敏感参数脱敏,且不能阻塞主请求。

核心实现

public class DbLoggingActionFilter : IAsyncActionFilter
{
    private static readonly string[] SensitiveKeys = { "password", "pwd", "token", "secret", "key" };

    public async Task OnActionExecutionAsync(
        ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // 前置:读取元数据 + 入参
        var (catalog, operation, _) = GetDisplayInfo(context);
        var inputJson = GetInputParameters(context.ActionArguments);

        // 执行 Action
        var executedContext = await next();

        // 后置:读取出参 + 异步写库
        var outputJson = await GetOutputParametersAsync(executedContext);
        _ = LogBusinessOperationAsync(catalog, operation, inputJson, outputJson, executedContext);
    }
}

反射读取 DisplayAttribute

在控制器和方法上标注 [Display] 特性,过滤器通过反射读取它们作为中文业务名:

private (string, string, string) GetDisplayInfo(ActionExecutingContext context)
{
    var ctrlType = context.Controller.GetType();
    var ctrlDisplay = ctrlType.GetCustomAttributes(typeof(DisplayAttribute), true)
        .OfType<DisplayAttribute>().FirstOrDefault();

    var actionDisplay = context.ActionDescriptor.EndpointMetadata
        .OfType<DisplayAttribute>().LastOrDefault();

    var controllerName = ctrlType.Name.Replace("Controller", "");
    var actionName = (context.ActionDescriptor as ControllerActionDescriptor)?.ActionName ?? "Unknown";

    var catalog = ctrlDisplay?.Name ?? controllerName;
    catalog = !catalog.Contains(controllerName) ? $"{catalog}({controllerName})" : catalog;

    var operation = actionDisplay?.Name ?? actionName;
    operation = !operation.Contains(actionName) ? $"{operation}({actionName})" : operation;

    return (catalog, operation, actionDisplay?.Description ?? string.Empty);
}

敏感参数脱敏

入参字典中包含 password、token 等字段时必须脱敏:

private string GetInputParameters(IDictionary<string, object> args)
{
    if (args == null || args.Count == 0) return "{}";

    var safeArgs = new Dictionary<string, object>();
    foreach (var (key, value) in args)
    {
        if (SensitiveKeys.Any(s => key.Contains(s, StringComparison.OrdinalIgnoreCase)))
            safeArgs[key] = "***";
        else
            safeArgs[key] = value;
    }

    return JsonSerializer.Serialize(safeArgs, new JsonSerializerOptions { WriteIndented = true });
}

出参处理:模式匹配 switch

private string GetOutputParameters(ActionExecutedContext ctx)
{
    if (ctx.Exception != null) return $"异常: {ctx.Exception.Message}";
    if (ctx.Result == null) return "{}";

    return ctx.Result switch
    {
        ObjectResult { Value: var v } => v != null ? JsonSerializer.Serialize(v) : "null",
        JsonResult { Value: var v } => v != null ? JsonSerializer.Serialize(v) : "null",
        ContentResult c => $"内容: {c.Content}",
        StatusCodeResult s => $"状态码: {s.StatusCode}",
        RedirectResult r => $"重定向: {r.Url}",
        _ => $"类型: {ctx.Result.GetType().Name}"
    };
}

Fire-and-Forget 异步日志

日志写库不能阻塞主请求。用 _ = Task 发射后不管,但必须吞掉异常:

private async Task LogBusinessOperationAsync(...)
{
    try
    {
        await Task.Run(() =>
        {
            var msg = ctx.Exception != null
                ? $"执行失败 - {ctx.Exception.Message}"
                : "执行完成";

            DbLogHelper.AddLog(catalog, operation, user, ctx.Exception == null,
                              msg, inputJson, outputJson);
        }).ConfigureAwait(false);
    }
    catch (Exception ex)
    {
        // 关键:日志本身不能影响业务
        Console.WriteLine($"记录日志失败: {ex.Message}");
    }
}

注意:_ = Task 不会吞掉异常。如果 Task 内抛异常未被捕获,会触发 UnobservedTaskException 导致进程崩溃。所以 Task 内部必须 try/catch 包裹。

实战二:权限管控——双层拦截架构

SwitchData 用了一个巧妙的方案:NoPermissionAttribute(默认禁止)+ PermissionAttribute(声明放行)。

NoPermissionAttribute:默认封禁

标注在基类 Controller 上,兜底禁止所有未显式声明权限的方法:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class NoPermissionAttribute : Attribute, IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // 1. AllowAnonymous 直接放行
        if (HasAllowAnonymous(context)) { await next(); return; }
        // 2. 有 PermissionAttribute 的交给他自己处理
        if (HasExplicitPermission(context)) { await next(); return; }
        // 3. 兜底 403
        context.HttpContext.Response.StatusCode = 403;
    }
}

PermissionAttribute:声明式放行

标注在具体方法上,精确声明需要哪些权限码:

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class PermissionAttribute : Attribute, IAsyncActionFilter
{
    public string[] Permission { get; }

    public PermissionAttribute(params string[] permission) => Permission = permission;

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // 白名单直接放行
        if (Permission.Contains("White.Default")) { await next(); return; }

        var user = context.HttpContext.User;
        var userid = user.FindFirst("UserId")?.Value;
        var cache = context.HttpContext.RequestServices.GetService<IMemoryCache>();
        var perms = cache.Get<List<string>>($"Permissions_{userid}");

        if (!user.Identity.IsAuthenticated || perms == null)
        {
            context.HttpContext.Response.StatusCode = 401; return;
        }

        // 声明的所有权限码都必须包含在用户权限列表中
        if (Permission.All(p => perms.Contains(p)))
            await next();
        else
            context.HttpContext.Response.StatusCode = 403;
    }
}

使用示例

[ApiController]
[Route("api/[controller]")]
[NoPermission]  // 基类控制器默认禁止
public class UserController : ControllerBase
{
    [HttpPost("login")]
    [AllowAnonymous]  // 登录匿名放行
    public IActionResult Login(LoginRequest req) { ... }

    [HttpGet]
    [Permission("User.View")]  // 声明需要 User.View 权限
    public IActionResult GetList() { ... }

    [HttpPost]
    [Permission("User.Create")]
    public IActionResult Create(AddRequest req) { ... }
}

实战三:自定义 FilterProvider 动态注入

问题:[ServiceFilter] 需要手动标注

DbLoggingActionFilter 需要注入 DbLogHelper,注册为 Scoped 后用 [ServiceFilter] 使用。但每个 Controller 都要手动标注,很繁琐。

方案:SecurityFilterProvider

实现 IFilterProvider,在过滤器管道构建阶段自动扫描并注入:

public class SecurityFilterProvider : IFilterProvider
{
    // Order 越小越先执行,-1000 确保最先注入
    public int Order => -1000;

    public void OnProvidersExecuting(FilterProviderContext context)
    {
        if (context.ActionContext.ActionDescriptor
            is not ControllerActionDescriptor descriptor) return;

        // 扫描控制器级别的 NoPermissionAttribute
        foreach (var attr in descriptor.ControllerTypeInfo
            .GetCustomAttributes(typeof(NoPermissionAttribute), true)
            .Cast<NoPermissionAttribute>())
        {
            context.Results.Add(new FilterItem(
                new FilterDescriptor(attr, FilterScope.Controller), attr));
        }

        // 扫描方法级别的 PermissionAttribute
        foreach (var attr in descriptor.MethodInfo
            .GetCustomAttributes(typeof(PermissionAttribute), true)
            .Cast<PermissionAttribute>())
        {
            context.Results.Add(new FilterItem(
                new FilterDescriptor(attr, FilterScope.Action), attr));
        }
    }

    public void OnProvidersExecuted(FilterProviderContext context) { }
}

注册到 DI 即可全局生效:

builder.Services.AddSingleton<IFilterProvider, SecurityFilterProvider>();

Filter 使用方式对比

方式 支持 DI 适用场景
[MyFilter] 直接标注 不支持 无依赖的轻量逻辑
[ServiceFilter(typeof(T))] 支持 需要注入服务(如 DbLogging)
AddControllers().Filters.Add() 支持 全局过滤器
自定义 IFilterProvider 支持 动态注入、按条件生效

执行顺序全景

请求进入
  -> Global Authorization Filters
  -> Global Resource Filters
  -> Controller Filters(从上到下)
  -> Action Filters(从上到下)
  -> Exception Filter(仅当抛异常时)
  -> 执行 Action 方法体
  -> 返回 Response

权限校验放在 Authorization(最早),日志放在 Action,异常处理放在 Exception。顺序错了就会导致逻辑失效。

最佳实践

  1. 优先用异步接口:IAsyncActionFilter 比同步 IActionFilter 性能更好
  2. next() 是分界点:之前写前置逻辑,之后写后置逻辑。不调用 next() 就是短路请求
  3. 敏感参数脱敏:用关键字匹配(password/token/secret)替换为 ***
  4. Fire-and-forget 必须吞异常:Task 内部 try/catch 包裹所有可能抛异常的操作
  5. Attribute 过滤器不支持 DI:需要注入服务的用 ServiceFilter 或全局注册
  6. 执行顺序用 Order 控制:值越小越先执行,FilterProvider 的 Order 也能调整注入顺序

写在最后

过滤器是 ASP.NET Core 中最能体现关注点分离思想的机制。SwitchData 项目展示了一个生产级别的 Filter 架构:双层权限拦截 + 操作日志记录 + 自定义 FilterProvider 动态注入。掌握这些模式后,你在任何 ASP.NET Core 项目中都能快速搭建清晰的横切逻辑体系。