Castle IOC与AOP实战:Windsor容器与动态代理详解
很多人一开始接触 Castle都是被同一个问题逼来的服务方法里的日志记录、异常处理、性能统计散落得到处都是想删不敢删想抽又抽不干净。.NET 自带的依赖注入能帮你装配对象却没法在方法调用外面统一挂上一套逻辑。于是很多人搜到 Castle 之后才发现它居然同时解决 IOC 和 AOP 两层问题——容器负责动态注入动态代理负责生成代理对象。这篇文章就围绕这套方案展开把 Windsor 容器怎么搭、拦截器怎么写、两者怎么配合、实际项目里又会踩到哪些坑一次讲清楚。适合已经在用 .NET、想在不侵入业务代码的前提下给服务层统一加能力的开发者。要理解这套方案先得建立一个大前提Castle 不是一个库而是两个库的组合。容器部分叫 Castle Windsor负责对象的注册、解析和生命周期管理AOP 部分叫 Castle DynamicProxy负责在运行时动态生成代理类。你把这两个库装到同一个项目里它们就会协同工作形成一个既能解决“对象怎么来”又能解决“方法被调用时发生了什么”的整体方案。1. 为什么 .NET 开发者要把 IOC 和 AOP 放在一起学1.1 自带容器的能力边界和真实痛点现在的 .NET 项目里官方自带的依赖注入容器其实已经相当好用。它能根据构造函数递归解析依赖能控制 Singleton、Scoped、Transient 生命周期也能在 Web 应用中自动把控制器和第三方服务装配起来。对大多数中小项目来说这个容器本身完全够用。但它的能力边界也很明显它不做方法级拦截。假设你在几十个 Service 类里面都要记访问日志、统计耗时、捕获异常最朴素的做法就是在每个方法里手写一遍。写一个月之后你会发现日志格式开始不统一时间单位一会儿毫秒一会儿秒异常信息有的抛了有的吞了。把这些重复代码抽取成公共类是个办法但接下来又有个尴尬的问题每个业务类都得自己实例化这个公共类或者自己写构造函数注入然后手动调用。有人会说用装饰器模式也行。对接口一层套一层OrderService 外面套 LoggingOrderServiceLoggingOrderService 外面再套 TransactionOrderService。但你的业务接口只要一多装饰器类数量就会爆炸而且装饰器和业务方法的粒度往往对不上一个装饰器要把接口所有方法都实现一遍。1.2 横切关注点和大楼走廊的关系这些散落在所有业务方法里的日志、事务、权限、缓存逻辑在业界有个统一叫法横切关注点。这个名字起得很形象因为它不是某个模块独有的纵深逻辑而是像走廊一样横向贯穿所有房间。你要在每一个房间里都装上摄像头与其一间一间去装不如把摄像头放在走廊里让它在有人进出时自动记录。AOP 做的事就是你站在走廊里装摄像头这些逻辑被单独写成一个拦截器对象框架在方法被调用时自动跳进去执行它执行完之后再跳回来继续跑业务代码。业务方法自己完全没有感知。这种“不侵入业务代码”的能力正是很多项目从自带容器转向 Castle 的根本原因。1.3 Castle 两件套各自的职责Castle Windsor 解决的是“动态注入”。它通过注册组件的方式让容器在运行时决定一个接口对应哪个实现类并在解析时把所有的依赖链条搭好。Castle DynamicProxy 解决的是“代理”。它能在运行时生成一个看不见的中间类把目标方法调用包一层然后塞进拦截器逻辑。这是很关键的一点Windsor 本身并不强制依赖 DynamicProxy但两套库天生就是按“配合使用”设计的。Windsor 在注册组件时可以直接声明“给这个组件挂哪些拦截器”然后解析出来的对象就不是裸的实现类而是被 DynamicProxy 包装过的代理对象。调用方拿到的是一个接口背后是一个代理代理背后才是你的真实实现。这一整套链路就是标题里说的“IOC AOP动态注入 代理”。2. Windsor 的容器三板斧注册、解析和生命周期2.1 从一段最小示例认识 Windsor首先在项目里安装 Windsordotnet add package Castle.Windsor这个包会把 Castle.Core 一起带进来所以不需要额外再装 DynamicProxy。然后写一个最小示例using Castle.MicroKernel.Registration; using Castle.Windsor; var container new WindsorContainer(); container.Register( Component.ForIOrderService() .ImplementedByOrderService() .LifestyleTransient() ); var service container.ResolveIOrderService(); service.GetOrder(42); container.Release(service); container.Dispose();这段代码和官方容器的思路很像但有三个明显区别。第一注册时用接口而不是实现类这是后面接拦截器的前提。第二用 Component.For 的链式调用声明生命周期和拦截器配置都在注册表达式里一眼就能看全。第三Windsor 要求你主动 Release 解析出来的对象不像官方内置容器等根容器销毁时才统一处理。2.2 三种生命周期用错顺序 对象活得比预期长生命周期配置直接决定对象的存活时间。我把最常用的三种放在一起对比生命周期创建时机释放时机典型场景LifestyleTransient每次 Resolve 时新建调用方负责 Release无状态查询服务、短生命周期工具类LifestyleSingleton首次 Resolve 时创建一次容器销毁时释放配置读取、日志组件、无状态基础设施LifestyleScoped每个 Scope 一次Scope 结束时释放每次请求/会话内的服务如仓储、工作单元实际项目中最常见的错误是把有状态的服务注册成 Singleton然后发现多用户之间互相串数据。反过来把本来是无状态的基础工具类注册成 Transient也不会有功能错误只是每次解析都新建对象性能和内存上会有多余开销。我的经验是先判断“这个服务有没有内部状态”再决定生命周期而不是默认 Full 地全注册成 Transient。Windsor 对 Singleton 的管理比官方容器更严格一些它允许多个相同接口的注册再通过命名区分。这个能力在插件系统里非常有用但在普通业务系统里反而容易引入歧义所以中小项目尽量保持一个接口只注册一个实现。2.3 用 Installer 组织注册代码别让容器代码失控项目规模一大如果你把所有注册都写在入口方法里那个方法会变成几千行。Windsor 提供了 Installer 的约定让每个模块实现 IWindsorInstaller 接口负责注册自己模块内的组件。using Castle.MicroKernel.Registration; using Castle.MicroKernel.SubSystems.Configuration; using Castle.Windsor; public class OrderModuleInstaller : IWindsorInstaller { public void Install(IWindsorContainer container, IConfigurationStore store) { container.Register( Component.ForIOrderService() .ImplementedByOrderService() .LifestyleTransient(), Component.ForIProductService() .ImplementedByProductService() .LifestyleTransient() ); } }入口处只需要一次安装var container new WindsorContainer(); container.Install(FromAssembly.This());这么做的价值在后期维护时才体现出来。某个模块要新增服务只需要找到对应的 Installer加一行。调试时也可以通过断点快速确认某个模块有没有注册。比起在入口方法里从上往下翻这种组织方式会清爽很多。2.4 注册阶段最容易犯的三个疏漏第一个疏漏是忘了容器不会自动扫描程序集。Windsor 默认只注册你显式写出来的组件如果你不调用 FromAssembly它是不会扫描程序集内部的所有类的。很多第一次用的人会以为 Resolve 某个接口能自动找到实现类结果运行时才报“找不到组件”。第二个疏漏是用 new 来创建服务。new OrderService()和container.ResolveIOrderService()在功能上可能一样但前者绕过了容器也就绕过了代理、拦截器、生命周期管理。一旦这个服务的依赖链在未来发生变化所有 new 的地方都会成为维护黑洞。第三个疏漏是没有考虑释放顺序。Windsor 里组件之间的依赖关系很复杂时容器释放顺序通常不需要你操心但如果你自己在代码里using (container.ResolveIOrderService())那就得确认 OrderService 真的实现了 IDisposable否则释放逻辑根本不会执行。更推荐的做法是让容器统一管理尽量不在业务代码里手动释放依赖服务。3. 动态代理运行时才动手从 Invocation 到拦截器实现3.1 编译期根本不存在的类运行时凭空出现先说一个不少人困惑的点动态代理生成的类在你的源代码里是不存在的。它既不是你写的实现类也不是某个第三方类库而是 Castle 在运行时根据接口临时生成的一个新类。这个类实现了同一个接口持有对你真实实现类的引用然后把所有接口方法的调用先接管过来。你可以把它类比成大门口的传达室。快递送到传达室传达室的人先问你找谁确认之后再放你进去见目标对象。这个“传达室”在你设计办公楼平面图时不存在是在办公楼投入使用之后才设置的。动态代理就是这样一个运行时才出现的传达室。不依赖容器DynamicProxy 也能单独使用using Castle.DynamicProxy; var generator new ProxyGenerator(); var proxy generator.CreateInterfaceProxyWithTargetIOrderService( new OrderService(), new LoggingInterceptor() );这里proxy的类型是IOrderService你调用proxy.GetOrder(42)时其实先进了 LoggingInterceptor然后由 LoggingInterceptor 决定是否继续调用真正的OrderService.GetOrder。调用者完全不知道背后发生了什么类型对不上都不行。3.2 拦截器的三个基本动作Before、Proceed、AfterCastle 的拦截器只需要实现一个接口public class LoggingInterceptor : IInterceptor { public void Intercept(IInvocation invocation) { // Before Console.WriteLine($调用前{invocation.Method.Name}); invocation.Proceed(); Console.WriteLine($调用后{invocation.Method.Name}); } }invocation.Proceed()是这个接口最核心的方法。它表示“继续执行真实的业务方法”。如果拦截器不调用 Proceed业务方法就不会执行。如果你在 Proceed 之前写一段逻辑这叫前置通知在 Proceed 之后再写一段逻辑叫后置通知。在 Proceed 前后都写相当于 RabbitMQ 的环绕消息。invocation还带了不少有用信息invocation.Method是当前被调用的方法invocation.Arguments是参数列表invocation.ReturnValue可以用来读取或者覆盖返回值。利用这些信息几乎任何横切关注点都能写。3.3 一个完整可落地的性能日志拦截器静态的日志通常大家都会写但动态代理最大的价值在于你写一次处处生效。下面这个性能拦截器是真实项目里的常用形态public class PerformanceLogInterceptor : IInterceptor { private readonly ILogger _logger; public PerformanceLogInterceptor(ILogger logger) { _logger logger; } public void Intercept(IInvocation invocation) { var sw Stopwatch.StartNew(); try { invocation.Proceed(); } catch (Exception ex) { sw.Stop(); _logger.LogError(ex, ${invocation.TargetType.Name}.{invocation.Method.Name} 异常耗时 {sw.ElapsedMilliseconds} ms); throw; } sw.Stop(); if (invocation.Method.Name.StartsWith(Get)) { _logger.LogInformation(${invocation.TargetType.Name}.{invocation.Method.Name} 成功耗时 {sw.ElapsedMilliseconds} ms); } } }这个拦截器有三个细节值得学习。第一异常处需要 Stopwatch 停表并且要重新抛出异常保证业务调用方感知到错误而不是被拦截器吞掉。第二Stopwatch 的 StartNew 比 Stopwatch.Start() 更推荐因为后者还要依赖类型本身的静态方法可读性略差。第三通过判断方法名前缀可以只统计查询方法这个策略可以根据项目实际调整。异步方法要多留意一个问题如果业务方法是Task或TaskT直接调用invocation.Proceed()并不会等异步方法真正执行完。ReturnValue 里是一个 Task真正的耗时在后面。可靠的写法是在拦截器里检测返回值如果是 Task就把后续计时和日志逻辑挂到 ContinueWith 或者 async 等待上。这一步不做你的性能日志记录的只是“方法开始执行到拿到 Task”的时间几乎毫无意义。3.4 接口代理和类代理的取舍DynamicProxy 支持两种代理方式基于接口的代理和基于类的代理。两者使用场景和限制差别很大这里用一张表说清楚对比项接口代理类代理使用方式代理对象实现接口调用目标实例代理对象继承目标类目标要求目标类无特殊要求目标类不能是 sealed方法必须是 virtual依赖关系面向接口编程最自然的 DI 风格需要保留虚方法侵入性稍强实用性高推荐低一般只在接口已无法补充时使用接口代理的核心价值在于调用方只需要知道接口不需要知道实现类。Windsor 的推荐用法也是面向接口注册。类代理更适合给那些没有接口的第三方类追加拦截逻辑但一旦类里有 sealed 方法或者非虚方法Castle 就无能为力了。所以设计新服务时从一开始就给核心服务配好接口这个习惯能让 AOP 少走很多弯路。4. 把注入和代理拧成一股绳容器挂拦截器的实战组合4.1 注册表达式里直接挂拦截器容器会自动生成代理IOC 和 AOP 真正合体的那一步是在容器注册时声明拦截器container.Register( Component.ForIOrderService() .ImplementedByOrderService() .LifestyleTransient() .InterceptorsPerformanceLogInterceptor() );就这么一行扩展方法Windsor 在解析IOrderService的时候就会生成一个动态代理对象这个代理会先经过 PerformanceLogInterceptor再进入真实的 OrderService。调用方只需要写成var orderService container.ResolveIOrderService();它拿到的就是这个代理而不是 OrderService 本体。这个模式的可维护性非常高你想去掉日志把.InterceptorsPerformanceLogInterceptor()删掉就行一个字符都不用动业务代码。你想换日志的实现改拦截器内部逻辑就行所有挂载了它的服务同时生效。这也是为什么很多人把“注册时挂拦截器”称为声明式 AOP——它把横切关注点的开关全部集中到了容器注册层。4.2 多个拦截器的执行顺序就像剥洋葱真实系统不可能只有一个横切关注点。日志、事务、缓存、权限往往同时存在。Windsor 允许给一个组件挂多个拦截器container.Register( Component.ForIOrderService() .ImplementedByOrderService() .LifestyleScoped() .InterceptorsPerformanceLogInterceptor, TransactionInterceptor, CacheInterceptor() );多个拦截器的执行顺序严格按照注册顺序形成一条链。PerformanceLogInterceptor 最先执行它调用 Proceed 后进入 TransactionInterceptorTransactionInterceptor 再调用 Proceed进入 CacheInterceptor最后才轮到真正的 OrderService。返回值再从里往外一层层返回。这个过程像剥洋葱最外层拦截器只能看到第二层的返回结果看不到业务方法内部。理解这一点你在设计拦截器时就要特别注意顺序。比如缓存拦截器应该放在日志外面还是里面如果日志记录的是“实际执行了查询”那它就放在缓存之后。如果日志记录的是“用户发起了一次请求”那它应该放在缓存之前。顺序不同语义完全不同。4.3 一套实战组合日志 事务 缓存同时挂到订单服务我来写一个更完整的例子。假设订单服务有两个方法查询订单和创建订单需求是所有入口要记录耗时写操作要放进事务热数据要加缓存。先写业务接口public interface IOrderService { Order GetOrder(int id); void CreateOrder(Order order); }然后三个拦截器各司其职。事务拦截器是很多 .NET 项目里最容易写错的它要正确处理提交和回滚public class TransactionInterceptor : IInterceptor { [ThreadStatic] // 避免事务内部多线程流动问题真实场景可换成 AsyncLocal private static TransactionScope _current; public void Intercept(IInvocation invocation) { using (var scope new TransactionScope(TransactionScopeOption.Required, new TransactionOptions(), TransactionScopeAsyncFlowOption.Enabled)) { invocation.Proceed(); scope.Complete(); } } }这里有个很重要的经验在传统 .NET Framework 环境下事务默认不会自动异步流动必须显式开启异步流选项而在 .NET Core 及以上运行时这个选项实际已经被默认支持。很多老项目迁移到新运行时后仍然带着这个参数问题不大但调试时看到它不生效也别疑惑因为行为已经被运行时接管了。缓存拦截器的逻辑可以写得轻量一点public class CacheInterceptor : IInterceptor { public void Intercept(IInvocation invocation) { var cacheKey ${invocation.TargetType.Name}.{invocation.Method.Name}:{string.Join(,, invocation.Arguments)}; if (TryGetCache(cacheKey, out object? cached)) { invocation.ReturnValue cached; return; } invocation.Proceed(); if (invocation.ReturnValue ! null) { SetCache(cacheKey, invocation.ReturnValue); } } }注册时把这个组合挂上去调用CreateOrder时调用链就是性能日志 - 事务 - 缓存 - 真实方法。缓存拦截器对写操作本来就不友好因为写操作没有返回值所以在缓存拦截器内部判断了ReturnValue ! null才会缓存。真实项目里更合理的设计是给缓存拦截器加上“只缓存查询请求”的过滤条件这里刻意简化是希望你注意到这个边界。4.4 解析出来的对象是代理调试时别被类型迷惑很多人第一次调试这种组合时会在断点里查看ResolveIOrderService()返回的对象类型然后愣住了类型全名居然不是OrderService而是一个叫IOrderServiceProxy的东西命名空间是Castle.Proxies。这是正常的你看到的就是 Castle 生成的透明代理。这也是整个方案最优雅的地方整个项目里所有服务都是面向接口编程容器生产代理调用方完全无感知。你替换拦截器、调整注册业务层不用改任何代码。想要验证拦截器是否生效最简单的办法是在拦截器的Intercept方法入口打个断点看它有没有进去。如果断点没进优先检查注册时是不是漏了.Interceptors...()。5. 代理不生效、释放不及时实战里踩过的坑与定位方法5.1 谁来释放对象Windsor 和官方容器的最大区别Windsor 对组件释放的要求比官方容器严格得多。官方容器的 Scoped 会在请求结束时自动释放Transient 有时会被容器跟踪。而 Windsor 更希望你明确调用container.Release(解析出来的对象)。这不是故意制造麻烦而是为了让对象图清理变得可控。如果 A 依赖 BB 依赖 C你手动释放 A 时Windsor 可以追溯释放整条依赖链里由它创建的对象。如果你不走 Release而是一直 Resolve那 Windsor 为了追踪对象会保留相关信息时间久了就成了内存泄漏尤其在循环里或者高频请求的场景下特别明显。我的使用习惯是单例服务基本上不手动 Release交给容器销毁。Transient 服务在方法内用完就 Release。Scoped 服务在自定义 WCF 或批量任务场景下需要手动创建 Scope 并在 Scope 结束时 Release 里面的对象。如果你发现 Release 后对象字段状态异常先检查是不是有 Singleton 的拦截器或依赖被意外注册成了 Scoped。5.2 代理不生效的三种典型情况排查方向代理不生效通常不是 Castle 本身的问题而是代码把对象绕过了容器。最常见的三种情况如下。第一种直接new实现类。你写了个测试代码var svc new OrderService()然后调用方法发现拦截器没触发。原因很简单AOP 代理只能配合容器或者 ProxyGenerator 使用你 new 出来的是裸实现类没有任何中间层自然没人拦截。第二种在实现类内部用this.AnotherMethod()调用同一类的另一个方法。代理只拦截“从外部经由代理对象发起的调用”如果 OrderService 的 A 方法内部写this.B()B 方法并不会被拦截因为 B 是直接在目标对象上执行的根本没有经过代理。这个问题很隐蔽表现是同一个接口的方法外部调用有日志A 方法内部调用 B 时日志却消失了。解决办法通常是拆分服务或者把 B 的方法调用改为通过独立服务完成。第三种想代理的类被sealed修饰或者方法不是virtual。Castle 生成类代理时需要通过继承目标类来重写方法sealed 类不能继承非虚方法不能重写。遇到这种情况最直接的办法是给这个类提取接口改用接口代理这也再次说明了前面“新服务优先面向接口”这条经验的价值。5.3 调试动态代理的几个实用技巧定位代理问题第一件事就是确认解析出来的对象类型。在断点里右键变量选择“快速监视”查看 Type 属性看到Castle.Proxies.xxxProxy就说明代理已经生效。第二件事如果代理生效但没有走拦截器逻辑检查Interceptors有没有正确列出拦截器类型同时检查拦截器本身是不是真的实现了Castle.Core.IInterceptor接口。有人会把程序集里两个同名接口搞混一个是 Castle 的一个是自己项目的结果方法实现签名对不上注册时编译能过运行时却抛类型异常。第三件事拦截器方法的断点如果一直不触发很有可能是代理对象被拆箱为具体类了。不要把 Resolve 的结果强转成OrderService而要始终持有接口类型。一旦强转编译器虽然会把对象当具体类型处理但运行时还是同一个代理对象接口方法和类方法的调度可能因为类型判断不同而行为异常。第四件事如果怀疑动态代理生成失败可以设置 Castle 的日志输出或者在拦截器最前面写一行临时日志确认调用链是否完整。很多时候问题其实在前置的依赖注入失败上对象根本还没生成代理异常就被抛出来了。5.4 释放异常和对象图不匹配的排查思路排查释放问题比代理不生效更头疼因为表现通常是内存慢慢涨而不是立刻报错。常见的是在 Web 项目中每次请求都 Resolve 了大量 Scoped 服务而请求结束后没人 ReleaseWindsor 为追踪对象引用的容器条目不断累积。定位方法其实也不难在容器外层包一层像下面这样开启 Windsor 的泄漏日志container.Kernel.ComponentRegistered (key, handler) { // 在高频 Resolve 场景下观察 handler.ComponentModel 的 LifestyleType };或者更简单地在长时间运行后抓一个 dump 文件查看Castle.MicroKernel.Handlers.DefaultHandler相关对象数量和占用内存。它如果持续增长基本可以断定是某个 Scoped 或 Transient 对象没走 Release。另外还有个容易忽视的点如果你的服务实现了IDisposableWindsor 会把它的释放管理得更严格。一个接口解析出一个对象之后你却把它转存到了静态字段或缓存然后立刻 Release调用时会报对象已释放。这种“提前释放”和“忘记释放”就像天平的两端需要结合业务场景选一边。我的个人经验是在业务代码里少写 Release把释放责任集中到请求边界或容器边界比四处手动释放要安全得多。6. Castle 与内置容器的混合选型什么时候上、怎么上6.1 什么情况下值得引入 Castle什么情况下别上先说结论不是所有项目都需要把内置容器换成 Windsor。如果你的系统只有依赖注入需求没有明显的横切关注点官方内置容器加上自己写的装饰器就足够了。引入 Windsor 相当于引入了一套独立的组件模型它有自己的一套注册语法和生命周期约定团队学习成本和后续维护成本是实打实的。那什么情况下值得上我总结了几条高信号指标项目中大量方法都在写重复的日志、权限校验、事务或缓存逻辑业务代码已经膨胀到不想再改每一个 Service 方法你需要在运行时替换某个服务实现或者按条件动态启用某类拦截器。这些场景下Castle 的收益通常能在几天内覆盖学习成本。还有一个隐性的优势Windsor 对复杂对象图的处理比内置容器更细比如它能按名称区分同一接口的多个实现能配置拦截器依赖还能用 Installer 把注册代码按模块拆分。这些能力在插件化系统和大型业务模块里尤其重要。6.2 混合方案内置 DI 管 Web 层Windsor 管服务层很多现实项目不会直接推倒重来而是选择混合落地。Web 的入口和控制器仍然由内置 DI 管理因为它在 Web 宿主集成上最省事。核心业务服务层则改用 Windsor 注册再由 WCF、定时任务或控制台入口显式调用容器解析。这种混合方案的实现思路是在服务层启动时创建一个 Windsor 容器把所有服务类和拦截器注册进去提供一个小工具类供外部通过ResolveServiceT()方法获取代理后的服务对象。Windsor 容器可以在整个进程生命周期内保持 Singleton这样服务对象也只有一个来源不会和内置容器互相抢对象。具体接线时可以直接在入口处注册var serviceContainer new WindsorContainer(); serviceContainer.Install(FromAssembly.ContainingOrderModuleInstaller()); ServiceLocator.Initialize(serviceContainer);再用静态门面提供访问public static class ServiceLocator { private static IWindsorContainer _container; public static void Initialize(IWindsorContainer container) _container container; public static T ResolveT() _container.ResolveT(); }这里要注意一个边界不要把 ServiceLocator 满天飞地到处调用否则整个容器就退化成一根全局总线失去了依赖注入原本“构造函数传依赖”的清晰度。我一般只在程序边界或者无法依赖注入的位置比如静态工具、AOP 拦截器内部使用 ServiceLocator业务类一律构造函数注入。还有一个更彻底的混合路线保留内置 DI 作为宿主容器的同时把 Windsor 以IServiceProviderFactory的形式接入。这个方式在主流 .NET 宿主框架里已经有人实践过网上能找到注册 Windsor 作为默认 ServiceProvider 工厂的示例。它适合那些想保留内置 DI 的 API 风格的团队但需要对容器的生命周期边界有更强控制力。从这个角度说混合方案并不复杂复杂的是你心里一定要清楚每个对象的创建者是谁销毁者就该是谁。6.3 我个人的配置习惯从小处起步用日志拦截器验证链路最后分享一点我自己的配置习惯。每到一个新项目我不会一上来就把日志、事务、缓存、权限四个拦截器全写出来。我会先写一个最小可用的 PerformanceLogInterceptor挂到一两个核心服务上跑通“容器解析 - 代理生成 - 拦截器执行 - 真实方法调用”这条完整链路。链路通了再逐步补上事务和缓存。拦截器本身的注册方式我也倾向于在 Installer 中给每个拦截器统一配成 Singleton。拦截器是无状态的它整个生命周期只需要一份实例。如果某个拦截器内部将来要存上下文数据再单独改成 Scoped 也不迟。这里的关键是不要让拦截器持有和业务请求绑定的状态否则并发场景下会出现数据交叉污染。最终项目跑起来之后你会发现最大的收益不是某个拦截器多么巧妙而是你把那些横切关注点从业务代码里拆干净了。业务方法里剩下的就是业务逻辑新来的同事看代码效率明显提高修改日志格式也只需要动一个类而不是几十个类。这就是 Castle 组合方案在实际项目里的真正价值。