在 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。顺序错了就会导致逻辑失效。
最佳实践
- 优先用异步接口:IAsyncActionFilter 比同步 IActionFilter 性能更好
- next() 是分界点:之前写前置逻辑,之后写后置逻辑。不调用 next() 就是短路请求
- 敏感参数脱敏:用关键字匹配(password/token/secret)替换为 ***
- Fire-and-forget 必须吞异常:Task 内部 try/catch 包裹所有可能抛异常的操作
- Attribute 过滤器不支持 DI:需要注入服务的用 ServiceFilter 或全局注册
- 执行顺序用 Order 控制:值越小越先执行,FilterProvider 的 Order 也能调整注入顺序
写在最后
过滤器是 ASP.NET Core 中最能体现关注点分离思想的机制。SwitchData 项目展示了一个生产级别的 Filter 架构:双层权限拦截 + 操作日志记录 + 自定义 FilterProvider 动态注入。掌握这些模式后,你在任何 ASP.NET Core 项目中都能快速搭建清晰的横切逻辑体系。