如果你手头有一套 .NET Core 服务,里面几十个业务方法都需要记录“开始调用、结束调用、入参出参、耗时、异常”,最朴素的做法是每个方法里面手动敲几行日志代码。敲到一半你就会意识到:日志这种横切逻辑,和业务逻辑强耦在一起,既污染代码,又容易漏记。更好的做法,是用 AOP(面向切面编程)将拦截逻辑集中到一处,在方法执行前后自动插入“记录”动作。这篇文章分享我在 .NET Core 里用 Autofac 集成 Castle DynamicProxy 实现 AOP 拦截,给所有注册到容器的服务统一记录执行日志。我会从最基础的工程搭建讲到异步方法拦截、敏感参数脱敏、性能控制,再把实战中容易踩的坑列出来,适合那些已经会用 Autofac 但还没系统做过 AOP 的开发者参考。
1. 为什么用 Autofac + Castle 做 AOP,而不是把日志写进每个方法
1.1 AOP 到底帮我们省了什么
AOP 的核心思路,是把“非业务”的关注点从业务方法里抽出去。比如日志记录、异常监控、执行耗时、权限校验、事务处理,这些都属于横切关注点。它们散落在每个方法里时,你根本管不住:有人记了入参没记出参,有人写日志写一半抛异常,有人忘记处理异步异常,最终日志格式五花八门,排查问题全靠肉眼猜。
用 AOP 之后,所有这些逻辑集中到一个拦截器里。对于业务方法来说,它们感受不到拦截器的存在,还是老老实实写自己的参数校验、调仓储、返回结果。拦截器在代理层做文章:你通过容器拿到的不是原始实例,而是一个动态代理对象,代理对象会把方法调用转发给真实方法,并且在转发前后插入你的统一逻辑。
一个很贴切的类比:业务方法是房子里干活的工人,拦截器是门口的访客登记台。每个进出房间的人都要过登记台,但工人自己不用管登记,登记动作被隔离在门口气闸。这就是 AOP 的价值,你要记录的所有方法,只需要在容器注册阶段声明一次,后面新增的 Service 只要走同一套注册规则,拦截自动生效。
1.2 在 .NET Core 里为什么优先选这套组合
.NET 生态里做 AOP 的路径并不少。Controller 层有 ActionFilter,底层管道有 Middleware,还有一种做法是定义装饰器类手动包裹目标方法。但它们都有各自边界:
- ActionFilter 只在 ASP.NET Core MVC 的 Action 调用链路里生效,它管不到普通业务服务,也不适用于非 Web 场景。
- 装饰器模式可以控制任意对象,但每包一个服务就要手写一个包装类,注册时还要逐个嵌套,服务一多维护成本就上来了。
- 如果你已经用 Autofac 做依赖注入,那么 Autofac.Extras.DynamicProxy 这个集成库几乎是最无痛的选择。只要在注册时加一句
EnableInterfaceInterceptors()和InterceptedBy(),一个可拦截的代理实例就自动生成,不需要手写代理代码。
底层代理引擎用的是 Castle.Core 的 DynamicProxy,它在 .NET 上已经打磨了很多年,是很多框架事实上的代理基础库。它支持两种拦截方式:基于接口的代理,以及基于类继承的子类代理。具体到日志场景,接口代理更稳,因为代理生成的类型实现的是接口,不依赖方法是否 virtual;类代理则要求方法必须可 override,约束多一些。这个选型在我后续实际项目里几乎没有遇到瓶颈,稳定性可以放心。
2. 起步:最小工程、依赖包和两条代理约定
2.1 先搭一个接口加实现的最小工程
我在演示项目里写的是一套极简业务服务,接口和实现分离,所有方法都是异步风格,因为实际业务里绝大多数服务方法都是 async。假设有一个IUserService,里面包含了新增用户、根据 Id 查询用户这两个方法。
public interface IUserService { Task<long> CreateAsync(CreateUserRequest request); Task<UserInfo> GetByIdAsync(long id); } public class UserService : IUserService { public async Task<long> CreateAsync(CreateUserRequest request) { // 模拟业务逻辑 await Task.Delay(20); return 1001; } public async Task<UserInfo> GetByIdAsync(long id) { await Task.Delay(10); return new UserInfo { Id = id, Name = "demo" }; } }对应的 DTO 类就是普通的CreateUserRequest和UserInfo,一个带Name、Password等属性,一个带Id、Name属性。这个结构很常见,真正要落地时你不需要为 AOP 额外改造这些类型。
2.2 安装三个 NuGet 包
接下来给项目安装 Autofac 基础设施。我通常需要这三个包:
dotnet add package Autofac dotnet add package Autofac.Extensions.DependencyInjection dotnet add package Autofac.Extras.DynamicProxy其中Autofac.Extensions.DependencyInjection用来把 Autofac 接入 .NET Core 的宿主系统,让整个应用的服务解析走 Autofac 容器;Autofac.Extras.DynamicProxy提供InterceptedBy、EnableInterfaceInterceptors这些扩展方法。这个包会传递依赖Castle.Core,Proxy 引擎本身就在 Castle.Core 里,所以不需要再手动装 Castle 包。如果你的项目日志系统用的是Microsoft.Extensions.Logging,它已经在宿主里自带,不用单独引入。
版本方面我建议直接使用当前稳定版。不要特意去锁很老的版本,尤其是Autofac.Extras.DynamicProxy的早期版本对某些 .NET 版本行为有差异,稳定版反而省心。
2.3 两条代理约定:接口代理与类代理
这里必须先讲清楚动态代理能生效的前提,不然你照着抄代码会发现拦截器不触发。
情况一:接口代理。注册时用EnableInterfaceInterceptors(),容器会创建一个动态代理类型,这个代理类型实现你注册的接口,并把调用转发给真实实现。接口上的所有公共方法都会被拦截,不要求实现方法带virtual。
情况二:类代理。注册时用EnableClassInterceptors(),容器会创建一个继承自目标类的子类,并重写父类的方法。这就强制要求被拦截的方法必须是virtual,否则子类无法重写,调用就直接走父类方法,拦截器形同虚设。
在实际注册中,如果一个类同时实现了接口,但你不小心用AsSelf()注册并且没有启用EnableInterfaceInterceptors,那拦截也一样不会生效。下面这组注册就是最典型的正确姿势:
var builder = WebApplication.CreateBuilder(args); // 关键:替换默认服务工厂 builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory()); builder.Host.ConfigureContainer<ContainerBuilder>(container => { container.RegisterType<MethodLogInterceptor>().AsSelf().SingleInstance(); container.RegisterType<UserService>() .As<IUserService>() .InstancePerLifetimeScope() .InterceptedBy(typeof(MethodLogInterceptor)) .EnableInterfaceInterceptors(); }); var app = builder.Build();我把拦截器本身也注册到了容器里,这样它内部可以通过构造函数注入ILogger<T>。Autofac.Extras.DynamicProxy在代理创建时会解析InterceptedBy指定的拦截器类型,因此注册里必须能解析到这个类型,否则启动会直接抛异常。
3. 第一个拦截器:把开始、结束、异常和耗时全部记下来
3.1 从 IInterceptor 开始的完整实现
先写拦截器本体。Castle 的IInterceptor接口只有一个方法Intercept,类型是同步的,但这不代表它不能处理异步方法,后面我会专门解释异步的陷阱。
先看一个同时覆盖同步和异步的版本:
public class MethodLogInterceptor : Castle.DynamicProxy.IInterceptor { private readonly ILogger<MethodLogInterceptor> _logger; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ReferenceHandler = ReferenceHandler.IgnoreCycles, MaxDepth = 8 }; public MethodLogInterceptor(ILogger<MethodLogInterceptor> logger) { _logger = logger; } public void Intercept(IInvocation invocation) { var methodName = $"{invocation.TargetType.FullName}#{invocation.Method.Name}"; var stopwatch = Stopwatch.StartNew(); _logger.LogInformation("[AOP] 开始调用:{Method},入参:{Arguments}", methodName, FormatArguments(invocation.Arguments)); try { invocation.Proceed(); } catch (Exception ex) { stopwatch.Stop(); _logger.LogError(ex, "[AOP] 调用发生同步异常:{Method},耗时:{Elapsed}ms", methodName, stopwatch.ElapsedMilliseconds); throw; } if (invocation.ReturnValue is Task task) { invocation.ReturnValue = WrapAsyncResult(methodName, stopwatch, task); } else { stopwatch.Stop(); _logger.LogInformation("[AOP] 结束调用:{Method},耗时:{Elapsed}ms,返回:{Return}", methodName, stopwatch.ElapsedMilliseconds, invocation.ReturnValue); } } private object WrapAsyncResult(string methodName, Stopwatch stopwatch, Task task) { if (task.GetType().IsGenericType) { var resultType = task.GetType().GetGenericArguments()[0]; var wrapMethod = typeof(MethodLogInterceptor) .GetMethod(nameof(WrapAsyncWithResult), BindingFlags.Instance | BindingFlags.NonPublic)! .MakeGenericMethod(resultType); return wrapMethod.Invoke(this, new object[] { methodName, stopwatch, task })!; } return WrapAsyncVoid(methodName, stopwatch, task); } private async Task WrapAsyncVoid(string methodName, Stopwatch stopwatch, Task task) { try { await task; stopwatch.Stop(); _logger.LogInformation("[AOP] 结束调用:{Method},耗时:{Elapsed}ms", methodName, stopwatch.ElapsedMilliseconds); } catch (Exception ex) { stopwatch.Stop(); _logger.LogError(ex, "[AOP] 调用发生异步异常:{Method},耗时:{Elapsed}ms", methodName, stopwatch.ElapsedMilliseconds); throw; } } private async Task<T> WrapAsyncWithResult<T>(string methodName, Stopwatch stopwatch, Task<T> task) { try { var result = await task; stopwatch.Stop(); _logger.LogInformation("[AOP] 结束调用:{Method},耗时:{Elapsed}ms,返回:{Return}", methodName, stopwatch.ElapsedMilliseconds, result); return result; } catch (Exception ex) { stopwatch.Stop(); _logger.LogError(ex, "[AOP] 调用发生异步异常:{Method},耗时:{Elapsed}ms", methodName, stopwatch.ElapsedMilliseconds); throw; } } }这个版本同时考虑了普通异步方法和异步泛型返回值方法。invocation.Proceed()执行完之后,如果是异步方法,invocation.ReturnValue会是一个Task或Task<T>,我们不能在Intercept里直接等待,因为接口是同步方法。正确的做法是把原来的 task 包一层新的 async 方法返回,这样调用方拿到的依然是 Task,但内部已经附加了我们的日志逻辑。
3.2 注册到 Autofac 里的两种姿势
上面我展示了单个服务的注册方式。实际上服务多起来之后,逐条RegisterType写容易漏,更推荐用程序集批量注册。下面这个写法是常见套路,按服务命名后缀自动注册:
container.RegisterAssemblyTypes(typeof(UserService).Assembly) .Where(t => t.Name.EndsWith("Service") && t.IsPublic) .AsImplementedInterfaces() .InstancePerLifetimeScope() .InterceptedBy(typeof(MethodLogInterceptor)) .EnableInterfaceInterceptors();使用批量注册时有一点要特别注意:如果某些类型没有实现接口,AsImplementedInterfaces()会让他们无法被正常解析。批量注册适合你内部有统一 Service 命名约定的项目。如果项目里存在不需要日志的类,比如纯粹的静态工具类,也可以在Where条件里把它排除掉。
还有一种情况是只对个别方法做精细控制,不想全量拦截。那就不走批量注册,只对目标服务单独注册,或者在拦截器内部通过特性判断来跳过,这个方法我放到第四章详细说。
3.3 同步与异步的执行差异,必须单独处理
很多初学者在写第一个拦截器时会漏掉异步处理。invocation.Proceed()执行 async 方法时,会立刻返回一个未完成的 Task,真正的业务代码还在异步执行中。如果只在Proceed()返回后立刻记录“结束”,记到的根本不是真实业务结束时间,同时也捕获不到异步方法里抛出的异常。
我上面代码里的三段式处理就是标准解法:
Proceed()同步抛出的异常,由外层catch捕获;Task在后续异步过程中抛出的异常,由WrapAsyncVoid或WrapAsyncWithResult捕获;- 同步方法的结果,在
Proceed()返回后直接记录。
另外,尽量不要再写async void方法。async void方法在代理层拿不到 Task,异常会直接逃逸到同步上下文,拦截器就管不到了,这在 ASP.NET Core 里也可能引发进程级事故。
我实际测过这个拦截器的效果,日志输出大致长这样:
info: MethodLogInterceptor[0] [AOP] 开始调用:Demo.Service.UserService#CreateAsync,入参:[arg0(type=CreateUserRequest)="{\"name\":\"demo\",\"password\":\"123456\"}"] info: MethodLogInterceptor[0] [AOP] 结束调用:Demo.Service.UserService#CreateAsync,耗时:23ms,返回:1001基础能力已经通了,但真实项目里问题还不少,最典型的就是日志里把密码也打出来了,以及每次参数都完整序列化,热点接口日志过于膨胀。这些问题在下一节处理。
4. 进阶:日志格式、敏感信息脱敏、选择性忽略与性能控制
4.1 统一日志格式,并对敏感字段脱敏
上面拦截器里用了FormatArguments方法,这个方法我还没给出实现。实际项目里入参往往是对象,不能直接ToString(),我们需要序列化成 JSON,但同时又需要做两件事:脱敏和截断。
一个低成本的实现是这样:
private string FormatArguments(object[] args) { if (args == null || args.Length == 0) { return "[]"; } var parts = args.Select((arg, index) => { if (arg == null) { return $"arg{index}=null"; } var typeName = arg.GetType().Name; string json; try { json = JsonSerializer.Serialize(arg, JsonOpts); } catch { json = $"<serialize_error:{typeName}>"; } json = RedactSensitiveFields(json); if (json.Length > 800) { json = json[..800] + "...(truncated)"; } return $"arg{index}(type={typeName})={json}"; }); return string.Join(", ", parts); } private static string RedactSensitiveFields(string json) { // 覆盖常见的敏感键名,统一替换成 "***" return Regex.Replace( json, "\"(password|pwd|secret|token|apiKey|accessToken|captcha)\"\\s*:\\s*\"[^\"]*\"", "\"$1\":\"***\"", RegexOptions.IgnoreCase | RegexOptions.Compiled); }这段正则把所有形如"password":"123456"的片段替换成"password":"***",虽然简单,但在拦截日志这种非强安全场景里够用。它也有明显盲区:如果参数是数组、嵌套对象、或者敏感信息被多层包裹,正则就不一定匹配得准。更严谨的方案是引入序列化时的自定义JsonConverter,或者序列化后遍历 JSON 节点,按属性名黑名单打码。团队有精力时可以做一套通用的脱敏组件,没有精力时先守住“不让密码原文进日志”这条底线。
我自己踩过的坑是:某些业务对象里有循环引用,直接JsonSerializer.Serialize会抛异常。所以JsonSerializerOptions里面要设置ReferenceHandler.IgnoreCycles和MaxDepth,避免序列化器把整个对象图展开,既防止循环,也防止性能爆炸。
4.2 加一个 NoLog 特性做方法级豁免
有些方法确实不该记日志,比如密码校验接口、大文件流下载接口,或者内部高频轮询接口。与其在拦截器里写一堆方法名判断,不如定义自己的特性,标记在目标方法上。
自定义特性很简单:
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] public class NoLogAttribute : Attribute { }在拦截器入口加一个判断:
if (ShouldSkip(invocation)) { invocation.Proceed(); return; } private static bool ShouldSkip(IInvocation invocation) { var method = invocation.Method; var targetMethod = invocation.MethodInvocationTarget; if (targetMethod != null && targetMethod.GetCustomAttribute<NoLogAttribute>() != null) { return true; } if (method != null && method.GetCustomAttribute<NoLogAttribute>() != null) { return true; } var targetType = targetMethod?.DeclaringType ?? method?.DeclaringType; return targetType != null && targetType.GetCustomAttribute<NoLogAttribute>() != null; }为什么同时检查Method和MethodInvocationTarget?因为动态代理生成的代理方法通常不会继承你标注在实现方法上的特性,但MethodInvocationTarget又是原始实现方法的引用,所以两个都查最稳妥。如果特性放在类级别,那这个类的所有方法都被豁免。
这个特性的实际价值是给团队一个显式口子:不需要专门去修改拦截器代码,只要在敏感或不重要的方法上标注一个[NoLog]就能跳过当前方法的日志输出,调用本身仍然会正常执行。
4.3 耗时统计与性能控制开关
日志拦截本身也有性能成本,尤其是参数序列化。如果你有个接口每秒调几千次,每次都把几百 KB 的入参序列化成字符串写日志,那是灾难。
我在线上项目里一般给拦截器加两个开关:总开关和最小耗时阈值。总开关用来在突发事故时一键关掉所有 AOP 日志。最小耗时阈值则保证大部分正常调用不记入参出参,只记录慢调用、异常调用和核心链路调用。
可以在拦截器构造函数里注入一个配置对象,或者直接读取 IConfiguration:
public MethodLogInterceptor( ILogger<MethodLogInterceptor> logger, IConfiguration? configuration = null) { _logger = logger; _enabled = configuration?.GetValue("AopLog:Enabled", true) ?? true; _minMilliseconds = configuration?.GetValue("AopLog:MinMs", 0) ?? 0; }对应Intercept开头变成:
var stopwatch = Stopwatch.StartNew(); if (!_enabled) { invocation.Proceed(); return; } // 只做耗时统计,不记录参数时的轻量分支在异步包装里记录结果前判断:
if (stopwatch.ElapsedMilliseconds < _minMilliseconds && !isError) { // 结束日志照常写,但可以降为 Debug 级别,或者干脆只写一条精简日志 }这样的性能控制让 AOP 不再是一个“黑盒处理器”,而是可以被运维手段调节的组件。实际使用中,我建议默认把完整入参日志的日志级别设成Debug,把异常日志设成Error,普通调用用Debug记,这样生产环境默认不刷屏,排查问题时再动态调整日志级别。
5. 实战中的坑:代理限制、生命周期和那些反直觉的行为
5.1 为什么类代理必须 virtual,接口代理才是长期更优解
接着 2.3 节往下说。类代理的底层实现是生成目标类的子类,子类重写目标类的virtual方法,所以非virtual方法、静态方法、私有方法都不会被拦截。这一点很多人在自测时会踩到:注册类时用了EnableClassInterceptors(),某个方法忘了加virtual,日志就少了。
接口代理不使用继承的方式重写,动态代理类型直接实现接口,并把接口方法调用转发给内部持有的真实实例。从拦截粒度上说,接口代理更符合“面向接口编程”的直觉:你解析的是IUserService,拿到的就是实现了IUserService的代理类型,真实UserService反而是被包裹的对象。
这也是我在生产环境默认选接口代理的原因:约束少,行为稳,不容易因为方法修饰符而漏拦截。前提是你的服务都通过接口注册,并且业务调用都通过容器解析。
5.2 自调用陷阱:this 调 this,代理管不着
这是真正让人疑惑的一个行为。假设UserService的CreateAsync内部又调用了this.GetByIdAsync,即使GetByIdAsync是被拦截的方法,内部这次调用的this仍然是当前真实对象,不是代理对象,所以不会经过拦截器。
为什么很多人反复检查配置都找不到原因?因为表面上看起来“我确实通过容器拿到了代理对象”,可代理对象内部持有的是真实对象,真实对象内部的方法调用当然走不到代理。解决办法有几个:
- 将内部需要被拦截的调用拆到另一个服务里,通过构造函数注入那个服务;
- 在需要自调用的场景下,把自己注入给自己,即通过 Lazy 解析延迟获取代理实例;
- 或者干脆调整设计,让一个 Service 方法不要过多调用兄弟方法。
如果你在排查“拦截器只对部分方法生效”,优先怀疑自调用。
5.3 生命周期管理:别把拦截器注册成与作用域冲突的实例
拦截器会被代理对象调用,代理对象本身又可能处于InstancePerLifetimeScope、SingleInstance等不同生命周期。这里最隐晦的坑是拦截器内部注入了作用域依赖,但拦截器自身注册成了SingleInstance。
举个例子,如果拦截器里要读取当前请求的用户信息,于是注入了一个ICurrentUser,这个服务的生命周期是InstancePerLifetimeScope。拦截器却是SingleInstance,那 Autofac 在解析拦截器时,会捕获第一次创建它的作用域里的ICurrentUser。后续所有请求再用这个单例拦截器时,拿到的还是第一次请求的用户信息,这就不是日志记录问题了,而是隐蔽的数据串线。
一个常见的安全组合:拦截器本身只依赖ILogger<T>、配置对象这类根级组件,注册成SingleInstance没问题。如果拦截器需要请求级数据,就把拦截器注册成InstancePerLifetimeScope,或者把获取请求级数据的方式改成在每次Intercept时动态解析,而不是在构造函数里固定住。
这类问题通常在压力测试或并发访问时才会暴露,排查起来很费劲,所以设计初期就要把生命周期当回事。
5.4 常见症状速查表与避坑笔记
下面这张表是实战里最常见的状况,建议直接收藏:
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
| 拦截器完全不生效 | 未启用EnableInterfaceInterceptors()/EnableClassInterceptors() | 检查注册链是否完整 |
| 类代理只有部分方法被拦截 | 被拦截的方法不是virtual | 给相关方法补virtual,或改用接口代理 |
| 日志记录顺序混乱 | 方法内部存在自调用 | 拆分服务或注入自身代理 |
| 异步异常没有被日志捕获 | 没有包裹 ReturnValue 中的 Task | 用 Task 包装方法重新赋值 ReturnValue |
| 入参是同一个对象但序列化抛异常 | 存在循环引用 | 设置IgnoreCycles和MaxDepth |
| 密码、Token 等敏感信息进日志 | 序列化未脱敏 | 加脱敏正则或自定义转换器 |
| 大量日志拖慢接口 | 每次调用全量序列化 | 打开最低耗时阈值,调整日志级别 |
| 启动时提示无法解析拦截器 | 拦截器类型未注册到容器 | 在容器中注册拦截器 |
测试里用new调用服务,拦截不生效 | 代理只在容器解析时生成 | 通过容器解析对象后再测试 |
还有几个细节是经验之谈。第一,不要在拦截器里擅自修改invocation.Arguments数组,代理后续会把修改后的参数传给真实方法,一旦改错就是业务数据被污染。第二,如果要做耗时统计,Stopwatch最好在Intercept开头创建,不要放到包装 Task 里再创建,不然统计不到等待调度的排队时间。第三,日志模板里尽量用结构化字段,不要直接拼字符串,这样进入日志系统后可以按照 Method 名、耗时做聚合查询。第四,如果工作单位里已经有统一流程 ID 或 TraceId,在拦截器入口用ILogger.BeginScope给整次调用挂一条关联链,后续排查问题时能把一次请求跨越的服务串联起来。
这个方案我陆陆续续在不同服务里复制过很多次,每次改动的只是注册规则和脱敏清单。AOP 拦截器的核心代码长期稳定,因为它解决的是“一段横切逻辑如何与业务解耦”这个通用问题。掌握这套思路之后,你还可以把日志拦截器扩展成性能监控拦截器、切入事务拦截器,原理一模一样,只是Intercept里做的事不同。最后留一个建议:不要试图拦截所有类所有方法,挑好边界,比如服务层门面类,收益最高,副作用最小。