做 .NET 后端的人,越到后面越会碰到这样一个场景:一套 MVC 项目里,日志记录、异常处理、权限校验这些代码,几乎每个 Action 都要写一遍。粘来粘去倒也罢了,可一旦某个规则要改,就得翻遍所有 Controller,漏掉一个就线上爆炸。这就是典型的横切关注点失控——而 AOP 就是解决这个问题的。这个标题在 ASP.NET MVC 项目里我已经实践了五六年,从最开始只在 Controller 上挂几个 Attribute,到后来把服务层方法也纳入拦截管道,踩过的坑不算少,整理一篇实战向的经验出来,希望能帮到正在搞 MVC 又想写干净代码的朋友。
1. 先搞清楚 AOP 到底解决什么问题
1.1 横切关注点是什么
我在某后台管理系统里接手过一个老项目,登录校验的逻辑散落在四十多个 Action 里。每个方法开头都是几乎一样的代码:取 session、判断用户、跳转登录页。这就是横切关注点——权限、日志、缓存、异常处理这些逻辑,本身和业务方法“不相关”,却要强行出现在每一个业务方法里。
面向对象编程擅长纵向抽象,把用户、订单、商品这些业务对象封装成类;但横切关注点是横向的,像一把刀切过所有业务模块。OOP 里你没办法把这些逻辑优雅地塞进任何一层,硬塞的结果就是代码重复、职责混乱、一改全崩。
1.2 AOP 的工作方式
AOP 的全称是 Aspect Oriented Programming,中文翻译成面向切面编程。它做的事情很简单:在运行期间,把一段公共逻辑“织入”到目标方法执行的某个节点。
你可以类比成装修。业务方法是房子本身,你要在房子里统一装烟感报警器——这就是横切关注点。你不需要在每个房间的装修图纸里单独设计烟感,而是请一个专门的班组,按统一标准在所有房间一次性加装。这个“统一加装”的动作,就是 AOP 里的切面。
ASP.NET MVC 从一开始就内置了这一套机制,也就是 Filter。Filter 本质是对 Action 调用管线的拦截器,它让你可以把日志、权限、异常、缓存这些逻辑写在独立的类里,然后用声明式方式挂到需要的地方。这是最简单、最常用、也最容易上手的 AOP 姿势。
2. 在 ASP.NET MVC 里实现 AOP 的几种主流姿势
2.1 MVC 管线内置的四类 Filter
先梳理一下 MVC 请求管线的顺序。一次请求从路由匹配到返回响应,会经过这样的链路:创建 Controller → 执行 AuthorizationFilter → 执行 Action → 执行 ResultFilter → 输出响应。在这个链路里,有四个钩子可以插手:
| Filter 类型 | 接口 | 执行时机 | 典型场景 |
|---|---|---|---|
| 授权过滤器 | IAuthorizationFilter | Action 执行前,最先运行 | 登录校验、角色权限、接口签名校验 |
| 操作过滤器 | IActionFilter | Action 执行前后 | 日志记录、参数校验、耗时统计 |
| 结果过滤器 | IResultFilter | ActionResult 执行前后 | 统一封装响应格式、给响应头加信息 |
| 异常过滤器 | IExceptionFilter | 只有抛出未处理异常时执行 | 全局异常兜底、错误日志 |
这四种 Filter 就是 MVC 默认的 AOP 能力。它们能被声明成 Attribute 挂在 Controller 或 Action 上,也能注册到全局过滤器集合里一次性作用于所有请求。
2.2 声明式 Attribute 与全局注册
我使用最多的是自定义 Attribute 加全局 FilterConfig 注册。比如把操作日志做成一个 ActionFilterAttribute,然后:
public class OperationLogFilter : ActionFilterAttribute { public override void OnActionExecuting(ActionExecutingContext filterContext) { var controller = filterContext.RouteData.Values["controller"]; var action = filterContext.RouteData.Values["action"]; var user = filterContext.HttpContext.User?.Identity?.Name ?? "anonymous"; var args = string.Join(";", filterContext.ActionParameters.Select(p => $"{p.Key}={p.Value}")); Logger.Info($"Action 开始: {controller}.{action}, 用户: {user}, 参数: {args}"); } public override void OnActionExecuted(ActionExecutedContext filterContext) { if (filterContext.Exception != null) { Logger.Error($"Action 异常: {filterContext.Exception.Message}", filterContext.Exception); return; } Logger.Info($"Action 结束: {filterContext.ActionDescriptor.ActionName}"); } }然后在FilterConfig.RegisterGlobalFilters里注册:
public static void RegisterGlobalFilters(GlobalFilterCollection filters) { filters.Add(new OperationLogFilter()); filters.Add(new HandleErrorAttribute()); }全局注册的意思很明确:所有 Action 都自动套上这个 Filter。如果你只想让某几个 Action 启用,就不要全局注册,而是用特性标注:
[OperationLogFilter] public ActionResult Detail(int id) { // 业务代码 }这种方式的优点是非常直观、侵入性低、好维护;缺点是他只能管到 Action 这一层,你 Controller 里调用的业务服务类方法,Filter 是拦不到的。
2.3 服务层面的方法级拦截:IOC 容器加动态代理
项目变大之后你会发现,很多横切逻辑不在 Action 层,而是在 Service 层。比如查询订单详情的服务可能被多个 Action 调用,缓存逻辑如果写在 ActionFilter 里,等于每个 Action 都要自己调服务再缓存,不合理。
这时候就需要方法级拦截。主流做法是借助 IOC 容器配合动态代理,比如 Autofac 加 Castle DynamicProxy。原理是:Autofac 在解析服务实例时,返回一个动态生成的代理对象,代理对象在目标方法执行前后插入拦截器的逻辑。
我写过的最典型的缓存拦截器是这样:
public class CacheInterceptor : IInterceptor { private readonly IMemoryCache _cache; public CacheInterceptor(IMemoryCache cache) { _cache = cache; } public void Intercept(IInvocation invocation) { var cacheAttr = invocation.MethodInvocationTarget .GetCustomAttributes(typeof(CacheAttribute), true) .FirstOrDefault() as CacheAttribute; if (cacheAttr == null) { invocation.Proceed(); return; } var cacheKey = $"{invocation.TargetType.FullName}.{invocation.Method.Name}:{string.Join(",", invocation.Arguments)}"; if (_cache.TryGetValue(cacheKey, out object cached)) { invocation.ReturnValue = cached; return; } invocation.Proceed(); _cache.Set(cacheKey, invocation.ReturnValue, TimeSpan.FromSeconds(cacheAttr.Duration)); } }注册到 Autofac 容器:
var builder = new ContainerBuilder(); builder.RegisterType<OrderService>() .As<IOrderService>() .EnableInterfaceInterceptors() .InterceptedBy(typeof(CacheInterceptor));这样只要我在服务方法上标注了[Cache(Duration = 60)],调用该方法时就会自动走缓存,业务代码里完全不用关心缓存细节。
2.4 两种姿势的边界对比
| 维度 | MVC Filter | 动态代理拦截器 |
|---|---|---|
| 作用范围 | Controller/Action 管线 | 服务类方法、接口方法 |
| 侵入性 | 低,Attribute 或全局注册 | 低,但需要从容器解析才生效 |
| 可访问上下文 | HttpContext、RouteData、ModelState 等 | 方法参数、返回值、目标类型 |
| 职责侧重 | 请求级横切:权限、异常、日志、结果封装 | 业务级横切:缓存、事务、审计、重试 |
| 依赖 | 无额外依赖,MVC 自带 | 需要 Autofac.Extras.DynamicProxy 等包 |
很多人会把这两者搞混,总觉得 Filter 够了。实际工程中它们常常组合使用:Filter 负责请求管线的横切逻辑,动态代理负责服务层的横切逻辑,各管一段。
3. 实战:用全局 ActionFilter 做日志监控和异常兜底
3.1 为什么日志监控优先用 Filter 而不是拦截器
日志这个东西比较特殊,它既要记录业务方法执行情况,也要记录请求的路由、用户身份、参数、耗时、响应状态。这些信息和 HTTP 上下文强相关,而 ActionFilter 天生就能拿到HttpContext和RouteData,放在这里再合适不过。
另外,全局异常处理本身也是 Filter 的强项。以前我见过有人在每个 Action 里写 try/catch,然后弹一个错误页,这种代码真的应该废除。正确的做法是:业务方法抛出异常,全局异常过滤器统一捕获、写日志、返回友好提示。
3.2 实操:一个完整的操作日志与异常兜底实现
我建一个简单的过滤器类:
public class LogAndExceptionFilter : ActionFilterAttribute, IExceptionFilter { public override void OnActionExecuting(ActionExecutingContext filterContext) { var routeData = filterContext.RouteData.Values; var controller = routeData["controller"]; var action = routeData["action"]; var userId = GetCurrentUserId(filterContext.HttpContext); filterContext.RequestContext.HttpContext.Items["_startTime"] = DateTime.Now; var sb = new StringBuilder(); foreach (var param in filterContext.ActionParameters) { // 敏感字段在参数命名时做好约定,比如包含 password、token 的跳过 if (param.Key.IndexOf("password", StringComparison.OrdinalIgnoreCase) >= 0 || param.Key.IndexOf("token", StringComparison.OrdinalIgnoreCase) >= 0) { sb.Append($"{param.Key}=***;"); } else { sb.Append($"{param.Key}={param.Value};"); } } Logger.Info($"请求开始 | Controller: {controller}, Action: {action}, 用户: {userId}, 参数: {sb}"); } public override void OnActionExecuted(ActionExecutedContext filterContext) { if (filterContext.Exception != null) { // 异常在 IExceptionFilter 中统一处理,这里只记录,不做二次包装 return; } var startTime = filterContext.RequestContext.HttpContext.Items["_startTime"] as DateTime?; var cost = startTime.HasValue ? (DateTime.Now - startTime.Value).TotalMilliseconds : 0; Logger.Info($"请求结束 | Action: {filterContext.ActionDescriptor.ActionName}, 耗时: {cost:F2}ms, 结果: {filterContext.Result?.GetType().Name}"); } public void OnException(ExceptionContext filterContext) { if (filterContext.Exception == null) { return; } var controller = filterContext.RouteData.Values["controller"]; var action = filterContext.RouteData.Values["action"]; var user = GetCurrentUserId(filterContext.HttpContext); Logger.Error($"请求异常 | Controller: {controller}, Action: {action}, 用户: {user}, 异常: {filterContext.Exception}", filterContext.Exception); // 表示异常已经被处理,不再向上抛 filterContext.ExceptionHandled = true; filterContext.Result = new ContentResult { Content = JsonConvert.SerializeObject(new { code = 500, msg = "服务器内部错误" }), ContentType = "application/json", StatusCode = 500 }; } private int GetCurrentUserId(HttpContextBase httpContext) { // 假设登录后把用户 ID 放在 Claims 里 var claim = httpContext.User.Identity as ClaimsIdentity; var idClaim = claim?.FindFirst("userId"); return idClaim != null ? int.Parse(idClaim.Value) : 0; } }这里有几个比较关键的细节:
filterContext.ExceptionHandled = true是在告诉 MVC:这个异常我已经处理完了,你别再往堆栈上抛了。如果不设这个标志,请求最终还是会走默认的错误页。
异常 Filter 里设置filterContext.Result,是为了给前端返回一个统一结构的 JSON。这个场景对于前后端分离的接口项目非常实用,前端只需要统一拦截 HTTP 500 并读取msg字段即可。
ActionFilter的OnActionExecuted在 Action 抛出异常后也会执行,但此时filterContext.Exception不为 null。所以必须在里面做个判断,否则异常会被记录两遍:一遍是 OnActionExecuted,一遍是 OnException。
3.3 注册与验证
全局注册时注意顺序,异常过滤器最好先添加:
public static void RegisterGlobalFilters(GlobalFilterCollection filters) { filters.Add(new LogAndExceptionFilter()); // 其他过滤器按业务需要继续添加 }注册完成后,随便在一个 Controller 里跑一下,看一下日志输出:
2025-01-12 10:23:01 请求开始 | Controller: Order, Action: Detail, 用户: 10086, 参数: id=123; 2025-01-12 10:23:01 请求结束 | Action: Detail, 耗时: 12.31ms, 结果: ViewResult一旦某个 Action 内部抛出异常,则会看到:
2025-01-12 10:23:05 请求异常 | Controller: Order, Action: Detail, 用户: 10086, 异常: NullReferenceException: Object reference not set to an instance of an object.到这里,所有 Action 的日志与异常兜底已经一致化,业务代码里的重复 try/catch 全部可以清掉。
4. 更细粒度的服务层拦截:基于 Autofac 加 Castle DynamicProxy
4.1 什么时候必须用服务级拦截器
ActionFilter 在处理“请求级”横切逻辑时非常顺手,但有一种场景它完全无能为力:同一个服务方法被多个 Action 调用,我希望服务层面的调用能够被统一缓存、统一审计、统一开启事务。
比如订单明细服务:
public interface IOrderService { OrderDetail GetOrderDetail(int orderId); void CancelOrder(int orderId, string reason); }GetOrderDetail需要缓存 60 秒;CancelOrder需要做完整日志审计。如果我用 ActionFilter,那么每个调用这两个方法的 Action 都要自行处理缓存和日志,完全违背 DRY 原则。更重要的是,如果服务方法不是从 Controller 入口调用的,而是在别的服务内部相互调用,ActionFilter 根本不可能拦截。
这时候必须在 IOC 容器层面做文章。Autofac 配合 Castle DynamicProxy,是 .NET 生态里最常见的方案。
4.2 动态代理的核心原理
动态代理的三个关键点:
第一,Castle DynamicProxy 在运行时生成一个类。如果你注册的是接口IOrderService,它生成一个实现该接口的透明代理;如果你注册的是具体类,它生成一个继承该类的子类代理。
第二,所有被代理的方法调用都会先进入拦截器(IInterceptor)的Intercept方法。在这个方法里,你可以选择直接执行目标方法(invocation.Proceed()),也可以完全替换目标方法,或者在其前后附加逻辑。
第三,继承类代理只能拦截virtual方法。这是动态代理最大的限制之一,也是新手最容易踩的坑。
4.3 事务拦截器实操
我拿一个事务拦截器举例,这个在业务项目里非常常用:
public class TransactionInterceptor : IInterceptor { public void Intercept(IInvocation invocation) { var hasTransaction = invocation.MethodInvocationTarget .GetCustomAttributes(typeof(TransactionAttribute), true) .Any(); if (!hasTransaction) { invocation.Proceed(); return; } using (var scope = new TransactionScope()) { invocation.Proceed(); // 如果拦截的方法返回 Task,需要做异步等待 var task = invocation.ReturnValue as Task; if (task != null) { task.GetAwaiter().GetResult(); } scope.Complete(); } } }这里有几个细节值得展开:
invocation.MethodInvocationTarget和invocation.Method的区别。在类代理场景下,invocation.Method是代理类上面的方法,而invocation.MethodInvocationTarget才是真正被拦截的目标类方法。读取目标方法上的 Attribute 时,一定要用后者,否则拿到的是代理类上的方法,可能没有TransactionAttribute,导致事务拦截器永远不生效。
对于异步方法,不能在拦截器里盲目scope.Complete()。如果目标方法是async Task,invocation.Proceed()返回后方法体可能还没真正跑完。用task.GetAwaiter().GetResult()强行同步等待,能保证事务提交时业务逻辑确实执行完毕。这个写法会避免死锁吗?大多数 MVC 场景下没问题,但更优雅的方案是封装一个AsyncInterceptor,后面我会单独说。
4.4 注册并验证拦截器
Autofac 注册代码:
builder.RegisterType<OrderService>() .As<IOrderService>() .EnableInterfaceInterceptors() .InterceptedBy(typeof(TransactionInterceptor)) .InterceptedBy(typeof(CacheInterceptor));在服务方法上标上特性:
public class OrderService : IOrderService { [Cache(Duration = 60)] public OrderDetail GetOrderDetail(int orderId) { // 数据库查询 } [Transaction] public void CancelOrder(int orderId, string reason) { // 业务逻辑 } }然后从容器中解析:
var orderService = container.Resolve<IOrderService>(); var detail = orderService.GetOrderDetail(1001);这句GetOrderDetail执行时,会先被CacheInterceptor拦住,缓存没命中再调目标方法;CancelOrder被TransactionInterceptor拦住,事务边界自动开启和提交。
4.5 与 ActionFilter 分层配合
我个人的分层经验是:
- Controller 层:Filter 管权限、参数校验、请求日志、异常兜底。
- Service 层:拦截器管事务、缓存、敏感操作审计、业务日志。
- 两者互不越界。
也就是说,不要拿动态代理去拦 Controller,也不要让 Filter 去直接查数据库做业务校验。各层管好各层的事,横切逻辑才能真正收拢。
5. 常见问题与排查实录
5.1 过滤器不执行
这是最多人问的问题。过滤器不执行,通常有这几种原因:
一是压根没有注册,只有在 Controller 或 Action 上标注了特性,但没加到FilterConfig里,也不会生效。二是在全局注册时,RegisterGlobalFilters方法没有被调用。很多项目是引用的现成模板,这个方法和RouteConfig一样,需要在Application_Start里显式调用。
三是路由没走对应 Action。MVC 的 Filter 是绑定在 Action 执行链路上的,如果请求根本没有命中该 Action,而是被路由到别的地方,Filter 自然不会触发。排查方法很简单,在 Filter 的构造函数或OnActionExecuting里打一个断点,看是否执行。
5.2 拦截器一直没命中
动态代理不生效的排查顺序:
手动new OrderService()一定不会被拦截,必须从 Autofac 容器解析。
注册的接口代理和类代理要分清。IOrderService对应的是接口代理,拦的是公开接口方法;如果拿类代理却把方法写成了非virtual,拦截器永远不会触发,因为生成的子类根本无法 override 这个方法。
第三种情况是方法带特性但读取方式不对。如上面说的,要读invocation.MethodInvocationTarget而不是invocation.Method。我在某次代码评审里发现有人整整一个星期都在怀疑 Castle 能力,最后发现就是这一行的问题。
第四种情况,目标方法和拦截器不在同一个程序集,但GetCustomAttributes读取的 Attribute 没有被正确引用。我遇到过服务项目和工具项目分离时,TransactionAttribute定义在公共类库,服务层引用了 NuGet 包但实际部署版本不一致,导致运行时Type.GetType返回 null,异常被吞掉了。
5.3 Filter 执行顺序不符合预期
多个 Filter 同时挂在同一个 Action 上时,执行顺序是依赖注册顺序的。全局过滤器优先于 Controller 上的特性,Controller 上的特性优先于 Action 上的特性;同一类中,往后注册的优先执行。
比如我想先做权限校验,再做操作日志,那权限过滤器必须注册在前面。如果顺序反了,日志里就会出现大量未授权请求的访问记录,排查时很难受。
5.4 异步场景下的事务和日志问题
MVC 5 时代已经有异步控制器,Filter 也有异步版本。ActionFilter 想处理异步方法,应该继承ActionFilterAttribute并重写OnActionExecutionAsync:
public class AsyncLogFilter : ActionFilterAttribute { public override async Task OnActionExecutionAsync( ActionExecutingContext filterContext, ActionExecutionDelegate next) { var start = DateTime.Now; var actionExecutedContext = await next(); var cost = (DateTime.Now - start).TotalMilliseconds; if (actionExecutedContext.Exception != null) { // 异常处理 } } }对于 Castle DynamicProxy 处理async拦截器,建议使用专门的AsyncInterceptor封装包,它能识别Task返回类型,在await之后执行后续逻辑,而不是像同步示例那样GetAwaiter().GetResult()硬等。采用这种做法的好处是不阻塞线程池,避免在高并发场景下因为线程切换导致性能损耗。
5.5 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 自定义 Attribute 不触发 | 未在 FilterConfig 注册或未标注 | 确认注册位置与标注位置 |
| 过滤器顺序错乱 | 注册顺序不正确 | 按“先权限、后日志、再异常”调整 |
| 代理方法未拦截 | 方法非 virtual | 改为 virtual,或用接口代理 |
| 读取不到方法特性 | 用了 invocation.Method | 改用 invocation.MethodInvocationTarget |
| 手动 new 的服务不拦截 | 绕过容器解析 | 所有依赖注入对象从容器获取 |
| 日志出现重复 | OnActionExecuted 和 OnException 都记录 | 在 OnActionExecuted 判断 Exception 是否为空 |
| 事务在异步方法中提前提交 | 未等待 Task 完成 | 使用 AsyncInterceptor 处理异步 |
6. 工程中的取舍与我的实操心得
6.1 别把 AOP 当成银弹
AOP 不是越用越好。我在某个项目里见过一种极端情况:一个方法挂了五六个拦截器和四五个过滤器,日志打出来十几行,出了问题完全不知道是哪一层出错。过度使用 AOP 会导致调用链不透明、排障成本升高。
经验是第一原则:横切逻辑只做“真正横切”的事。所谓真正横切,就是几乎所有业务方法都需要,且跟业务代码本身完全无关的那些事——日志、权限、事务、缓存、异常。如果一个横切逻辑只对少数几个方法生效,我会建议你直接写在业务代码里,而不是为了展示技巧硬塞一个切面。
6.2 统一日志上下文
做日志监控 Filter 时,我建议把一次请求的追踪 ID(TraceId)放进HttpContext.Items里,然后在整个调用链中传递。这样从控制器到服务层再到数据库层,所有日志都带上同一个追踪 ID,排障效率能提升非常多。这个 ID 可以在全局过滤器里生成:
filterContext.RequestContext.HttpContext.Items["TraceId"] = Guid.NewGuid().ToString("N");日志格式里带上它即可。服务层日志我用拦截器同样读取 TraceId,不过服务层拿不到 HttpContext,需要通过依赖注入的方式传递一个CallContext或者AsyncLocal辅助类来保持上下文一致性。小项目不强求,但系统一旦到了几十个 Controller 的规模,这个设计会拯救你的排障体验。
6.3 关于性能
动态代理有性能开销,但现代 .NET 下 Castle DynamicProxy 通过 emit 生成高效代码,性能可以接受。真正要警惕的不是代理本身的反射开销,而是你在拦截器里做了重活。比如日志 Filter 里做同步 IO 写文件,在高并发下会造成性能瓶颈。正确做法是日志写入队列异步落盘,或者直接用高性能日志库。
缓存拦截器也要小心缓存 Key 设计。我一度用参数列表直接拼接字符串作为缓存 Key,后来发现参数多了之后 Key 超长、可读性极差。建议对参数做规范化编码,比如用Convert.ToBase64String序列化后的结果。
6.4 一个小技巧:用拦截器组装自定义审计日志
最后分享一个我比较得意的做法。某后台系统要求所有“删除订单”“调整价格”这类敏感操作必须留痕,包括谁删的、什么时候删的、删之前是什么状态。如果用 Filter 做,只能拿 HttpContext 去猜;如果在业务代码里逐行写,又太啰嗦。
我的方案是写一个AuditInterceptor,拦截服务层标记了[Audit]的方法,通过invocation.Arguments拿到参数,再结合当前用户的上下文,把操作前和操作后的关键字段差异统一组装成一条审计记录。所有敏感操作的审计逻辑就收敛到了一个类里面,业务代码零污染。这就是 AOP 在真实项目里最让人舒坦的地方:规则变更时,你只需要改一个切面,而不是四十个 Action。