
做 .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 类型接口执行时机典型场景授权过滤器IAuthorizationFilterAction 执行前最先运行登录校验、角色权限、接口签名校验操作过滤器IActionFilterAction 执行前后日志记录、参数校验、耗时统计结果过滤器IResultFilterActionResult 执行前后统一封装响应格式、给响应头加信息异常过滤器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.RegisterTypeOrderService() .AsIOrderService() .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, 参数: id123; 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 DynamicProxy4.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 Taskinvocation.Proceed()返回后方法体可能还没真正跑完。用task.GetAwaiter().GetResult()强行同步等待能保证事务提交时业务逻辑确实执行完毕。这个写法会避免死锁吗大多数 MVC 场景下没问题但更优雅的方案是封装一个AsyncInterceptor后面我会单独说。4.4 注册并验证拦截器Autofac 注册代码builder.RegisterTypeOrderService() .AsIOrderService() .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.ResolveIOrderService(); 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并重写OnActionExecutionAsyncpublic 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 时我建议把一次请求的追踪 IDTraceId放进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。