十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Add、TryAdd、TryAddEnumerable:.NET依赖注入服务注册的三种语义

Add、TryAdd、TryAddEnumerable:.NET依赖注入服务注册的三种语义 先讲个真实经历。去年我在维护一个开源类库的 ASP.NET Core 集成层用户报了个很诡异的 bug业务代码里同一个自定义处理器被消费了两次消息重复入库。我第一反应是消费端没有做幂等查了半天 ack 机制和重试策略一无所获。最后把注册服务的扩展方法拉出来一行行看发现问题根本不在消费逻辑而在服务注册上——我给默认实现用的是 AddSingleton用户又在自己的项目里手动注册了一个同名实现两个同款服务同时躺在容器里IEnumerable 解析时全部被捞了出来每个消息等于被两个处理器各执行了一遍。从那以后我花了不少时间把 Add、TryAdd、TryAddEnumerable 这套注册方法的边界彻底捋了一遍。说实话这三个方法在 API 形态上看起来几乎一样实际语义却差了十万八千里很多写了三五年 .NET 的人也未必真能说清楚。这篇文章就把我踩过的坑、做过的实验、读过的源码一次性讲透。1. 先搞懂三兄弟是怎么注册的从 ServiceDescriptor 说起1.1 注册的本质容器里装的其实是一张 List很多人在写services.AddSingletonIUserService, UserService()时已经形成了肌肉记忆却很少回头想这一行代码到底做了什么。底层逻辑其实特别简单IServiceCollection本质上就是ListServiceDescriptor而ServiceDescriptor是这张列表里的一个元素记录了三部分信息——服务类型ServiceType、实现方式ImplementationType / ImplementationInstance / ImplementationFactory三者至少有一个、生命周期Lifetime。容器在调用BuildServiceProvider()构建ServiceProvider时做的核心工作就是遍历这个列表为每个描述符生成对应的解析策略。所以Add 系列方法的所有行为差异本质上都是这个元素是怎么样被放进 List的差异。拿购物车来类比Add 是不管三七二十一直接往购物车里塞商品TryAdd 是塞之前看一眼——如果购物车里已经有同类商品就不塞了TryAddEnumerable 更讲究——如果购物车里已经有一模一样的商品同样的服务和同样的实现就不塞。后面所有的行为差异都可以从这句话推出来。1.2 从方法名拆语义Try 和 Enumerable 分别修饰什么这三个方法命名是有讲究的。先说AddSingleton、AddScoped、AddTransient名字里没有任何限定条件就是往注册表末尾追加一条描述符不检查重复也不管你之前注册过没有。TryAddSingleton、TryAddScoped、TryAddTransient里的 Try 表示尝试暗示有条件的添加。它的条件是如果某个服务类型已经有任何一条注册记录就不再添加新的注册。换句话说TryAdd 的去重粒度是服务类型这一层只要这个接口被注册过一次后续不管你用哪个实现类去 TryAdd都会被忽略。TryAddEnumerable需要单独理解。名字里的 Enumerable 容易让人误以为是用来注册多个服务其实它的本意是把注册表当成一个可枚举的集合检查集合里是否已经包含了这条描述符。它的判断粒度是服务类型 实现类型两层。同一个服务类型下允许存在多个实现类这是它和 TryAdd 最大的区别但同一个实现类不能重复注册这是它和 Add 最大的区别。三个方法的判断维度可以先记一张表方法判断粒度是否允许同一接口下多个实现是否去重Add*无判断无条件追加允许不去重TryAdd*服务类型不允许服务类型级去重TryAddEnumerable服务类型 实现类型允许实现类型级去重记住这张表后面所有实验和源码分析都是围绕它展开的。2. 用一组可视化实验看差异注册三次到底得到几个实例光说不练没有说服力。我搭了一个最简单的控制台工程引用Microsoft.Extensions.DependencyInjection定义了一个接口两个实现类然后逐组跑注册和解析。2.1 实验基座一个控制台工程搞定using Microsoft.Extensions.DependencyInjection; public interface IMessageHandler { } public class MessageHandlerA : IMessageHandler { } public class MessageHandlerB : IMessageHandler { } class Program { static void Main() { var services new ServiceCollection(); // 每组实验在这里切换注册代码 var provider services.BuildServiceProvider(); var handlers provider.GetServicesIMessageHandler().ToList(); Console.WriteLine($IMessageHandler 解析数量: {handlers.Count}); } }用GetServicesIMessageHandler()来观察最终注册结果每次注册代码变了就重新跑一遍。下面这六组实验可以覆盖绝大多数日常场景。2.2 场景一同一实现Add 三次services.AddSingletonIMessageHandler, MessageHandlerA(); services.AddSingletonIMessageHandler, MessageHandlerA(); services.AddSingletonIMessageHandler, MessageHandlerA();结果是 3 个。每个 Add 都无条件追加了一条描述符三条描述符的服务类型和实现类型完全一样但容器不关心这些它照单全收。解析IEnumerableIMessageHandler时三个全部返回。2.3 场景二同一实现TryAdd 三次services.TryAddSingletonIMessageHandler, MessageHandlerA(); services.TryAddSingletonIMessageHandler, MessageHandlerA(); services.TryAddSingletonIMessageHandler, MessageHandlerA();结果是 1 个。第一次注册成功后服务类型IMessageHandler已经有记录了后面两次 TryAdd 检查到这个类型已存在直接略过。2.4 场景三不同实现TryAdd 两次services.TryAddSingletonIMessageHandler, MessageHandlerA(); services.TryAddSingletonIMessageHandler, MessageHandlerB();结果还是 1 个而且只有 MessageHandlerA。这就是 TryAdd 的粒度问题——它只看服务类型不管实现类。IMessageHandler已经被注册过了第二次想换一个实现类进来不行直接忽略。所以TryAdd 本质上只适合这个接口只需要一个默认实现的场景。2.5 场景四不同实现TryAddEnumerable 两次services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA()); services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerB());结果是 2 个A 和 B 都在。这就是 TryAddEnumerable 最核心的价值它以实现类型为去重单位同一个服务类型下允许追加不同的实现。这种语义天然适合策略模式、处理器链、管道行为这类需要注册多个实现的架构。2.6 场景五TryAddEnumerable 混合顺序三次services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA()); services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerB()); services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA());结果是 2 个A、B。A 虽然出现在两段代码里但第二次因为实现类型重复被识别出来了没有重复注册。2.7 场景六Add 和 TryAddEnumerable 混合这组最容易出问题我把两个方向都跑了一遍// 方向一先 TryAddEnumerable再 Add services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA()); services.AddSingletonIMessageHandler, MessageHandlerA();结果是 2 个。TryAddEnumerable 管住了自己但管不住后面的 Add。Add 是无条件追加它不关心当前注册表里有什么直接把 MessageHandlerA 又塞了一个进去。// 方向二先 Add再 TryAddEnumerable services.AddSingletonIMessageHandler, MessageHandlerA(); services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA());结果是 1 个。因为 TryAddEnumerable 检查时发现 MessageHandlerA 已经在集合里了主动跳过。这组对比非常重要它揭示了一个真相Try* 方法只能保证自己不加重复项但无法阻挡其他代码用 Add 硬塞。一旦项目里混用了 Add 和 Try*重复注册依然会发生而且比纯用 Add 更难排查。2.8 实验结果汇总场景操作序列最终描述符数量原因1Add(A) 三次3无条件追加2TryAdd(A) 三次1服务类型已存在则跳过3TryAdd(A) 后 TryAdd(B)1只看服务类型B 进不来4TryAddEnumerable(A) 后 TryAddEnumerable(B)2实现类型不同允许追加5TryAddEnumerable(A/B/A)2实现类型 A 去重6TryAddEnumerable(A) 后 Add(A)2Add 无法被拦截7Add(A) 后 TryAddEnumerable(A)1TryAddEnumerable 兜底跳过到这步为止三兄弟的表面差异已经清楚了但为什么还不够。下一节直接进源码。3. 源码拆解TryAddEnumerable 的实现类型级去重到底在比较什么3.1 源码入口两个方法的判断条件一目了然.NET 的依赖注入源码在ServiceCollectionDescriptorExtensions.cs里我贴一下核心逻辑为了突出主干省略了空值检查的具体代码public static IServiceCollection TryAdd(this IServiceCollection services, ServiceDescriptor descriptor) { // 省略空值检查 if (!services.Any(d d.ServiceType descriptor.ServiceType)) { services.Add(descriptor); } return services; } public static IServiceCollection TryAddEnumerable(this IServiceCollection services, ServiceDescriptor descriptor) { // 省略空值检查 Type? implementationType descriptor.ImplementationType; if (implementationType typeof(object) || implementationType descriptor.ServiceType) { throw new ArgumentException(...); } var callSiteType descriptor.NormalizedImplementationType(); if (services.Any(d d.ServiceType descriptor.ServiceType d.NormalizedImplementationType() callSiteType)) { return services; } services.Add(descriptor); return services; }看到没有差别就藏在Any方法的判断条件里TryAdd 只比ServiceType一个维度TryAddEnumerable 要比ServiceType和归一化后的实现类型两个维度。所谓实现类型级去重本质就是多了第二个维度的比较。3.2 NormalizedImplementationType三种注册方式的统一口径这里有一个容易被忽略的细节ServiceDescriptor有三种构造方式分别是类型注册、实例注册、工厂注册。要在比较时统一口径就必须把三种方式转换成同一个可比较的目标类型。源码里用了一个叫NormalizedImplementationType的扩展方法internal static Type? NormalizedImplementationType(this ServiceDescriptor serviceDescriptor) { if (serviceDescriptor.ImplementationType ! null) { return serviceDescriptor.ImplementationType; } if (serviceDescriptor.ImplementationInstance ! null) { return serviceDescriptor.ImplementationInstance.GetType(); } if (serviceDescriptor.ImplementationFactory ! null) { return serviceDescriptor.ImplementationFactory.Method.ReturnType; } return null; }这段代码的信息量很大。三种注册方式最终都会被拉回到一个Type上有ImplementationType就用它有实例就取实例的GetType()有工厂就取工厂方法的返回值类型。也就是说TryAddEnumerable 在比较重复时比较的是最终产出的对象类型不是对象实例本身也不是注册时的 lambda 表达式。这就带来了两个容易被忽略的语义第一个如果你用同一个实现类去注册两个不同的实例比如new MessageHandlerA()和另一个new MessageHandlerA()TryAddEnumerable 会认为这是重复的后一个被丢弃。因为它比较的是MessageHandlerA这个类型而不是实例引用。第二个如果工厂方法返回的类型是同一个实现类但携带不同的配置比如一个工厂返回new MessageHandlerA(option1)另一个返回new MessageHandlerA(option2)TryAddEnumerable 同样会认为重复。这在插件化场景里需要特别注意想做同类型不同配置多次注册TryAddEnumerable 帮不了你得换 Add。3.3 那个神秘的 ArgumentException 到底在保护什么源码里有一段很显眼的校验逻辑很多人在网上问过if (implementationType typeof(object) || implementationType descriptor.ServiceType) { throw new ArgumentException(...); }什么时候会触发比如你写services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, IMessageHandler())让服务类型和实现类型一致。TryAddEnumerable 的核心语义是在同一个接口下区分不同实现如果服务类型和实现类型完全相同去重判断就退化成了服务类型级判断行为上跟 TryAdd 没有区别同时在解析IEnumerableT时也会制造混乱。所以框架干脆直接拒绝这种注册方式把问题挡在门外。有意思的是Add 系列和 TryAdd 系列都没有这种限制允许AddSingletonIMessageHandler, IMessageHandler()这种写法虽然实际用的人不多。这个异常的存在恰好印证了 TryAddEnumerable 的定位它就是一个专门服务多实现防重的 API不该拿去做单实现的默认注册。4. 为什么框架集成代码里到处都是 TryAddEnumerable4.1 ASP.NET Core 官方库就是最典型的教程如果你翻过 ASP.NET Core 的源码会发现一个规律框架内部的服务注册几乎不用 Add 打头的方法而是大量使用 TryAddEnumerable。比如AddMvcCore、AddAuthorization这些集成方法里面注册的默认IApplicationModelProvider、IAuthorizationHandler基本清一色是 TryAddEnumerable。这样设计的原因不难理解。假设框架在AddMvcCore()里用AddSingletonIApplicationModelProvider, DefaultApplicationModelProvider()注册默认实现用户在AddMvc()之后又来了一份自定义实现——如果都用 Add最终容器里会有两个DefaultApplicationModelProvider如果框架用 TryAdd用户的自定义实现又永远进不来因为服务类型已经被占用了。只有 TryAddEnumerable 能达到最理想的效果默认实现注册一次用户自定义实现正常追加两者不发生冲突也不会重复。这就是框架集成层最常见的需求——默认实现可覆盖但默认实现本身不能重复。微软在官方源码里已经替你写好标准答案了。4.2 写类库集成层时可以直接抄的注册模板结合上面的结论我自己在写类库的AddMyLibrary扩展方法时已经形成了一套固定的注册模板public static IServiceCollection AddMyLibrary(this IServiceCollection services, ActionMyLibraryOptions configure null) { // 默认实现允许用户自定义实现追加但防止重复注册 services.TryAddEnumerable(ServiceDescriptor.ScopedIRequestProcessor, DefaultRequestProcessor()); services.TryAddEnumerable(ServiceDescriptor.ScopedIEventSink, DefaultEventSink()); // 配置项 if (configure ! null) { services.Configure(configure); } return services; }这套模板有几个好处用户多次调用AddMyLibrary()不会产生重复服务用户注册了自己的IRequestProcessor之后默认实现不会把自定义实现挤掉两者可以共存后续如果用户希望完全替换可以通过 Options 或者处理器内部的选择逻辑来调度。本质上就是把谁生效的决定权留给业务层注册层只负责保证容器里没有脏数据。4.3 程序集自动扫描场景下的天然过滤器还有一种常见场景是程序集扫描自动注册。很多模块化框架会扫描某个程序集把所有实现了IEventHandler或者IModule的类型自动注册进容器。这类扫描很容易出问题模块被重复加载、扫描范围重叠、或者框架升级后同一个实现被扫到两次。用 TryAddEnumerable 写扫描注册这些麻烦事会少很多foreach (var type in assembly.GetTypes() .Where(t t.IsClass !t.IsAbstract typeof(IEventHandler).IsAssignableFrom(t))) { services.TryAddEnumerable(ServiceDescriptor.Scoped(typeof(IEventHandler), type)); }这段代码里同一个type即使被扫描两遍也只有第一次会真正添加但同一个IEventHandler接口下不同的事件处理器类型又都能正常注册进去。如果这地方换成 TryAdd那第一个扫描到的实现类就会把后面所有同类注册全堵死换成 Add重复扫描时描述符成倍膨胀。TryAddEnumerable 是唯一一个能让多实现和防重复同时成立的选项。5. 实战中的选型建议与高频踩坑记录5.1 最隐蔽的坑Add 一出现Try* 全线失效前面实验里的场景六在实际项目中是最容易踩的我再强调一遍services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA()); services.AddSingletonIMessageHandler, MessageHandlerA(); // 重复这段代码最终的注册结果里有两个 MessageHandlerA。最坑的是业务代码用GetServiceIMessageHandler()做单值解析时并不会报错——按容器规则它会返回最后一个注册的实例。也就是说表面上一切正常实际上容器里已经带了脏数据。直到某天某处用GetServicesIMessageHandler()做多实例解析或者这个服务又被另一个注册逻辑扫描到问题才会浮出水面而且极其难定位。我自己排查同类问题时的习惯是在开发环境里把IServiceCollection里的描述符直接打印出来一眼就能看出有没有重复项if (env.IsDevelopment()) { var descriptors services.Where(s s.ServiceType typeof(IMessageHandler)); foreach (var d in descriptors) { Console.WriteLine(${d.ServiceType.Name} ${d.ImplementationType?.Name ?? d.ImplementationInstance?.GetType().Name ?? d.ImplementationFactory?.Method.ReturnType.Name} $[{d.Lifetime}]); } }这段诊断代码帮我抓出过不止一次同一个实现被注册两遍的问题比对着业务日志猜效率高太多了。5.2 TryAdd 和 TryAddEnumerable 混用并不总是安全的前面场景里提到过两个方法在发现重复这件事上互相留情面常规混用一般没事services.TryAddSingletonIMessageHandler, MessageHandlerA(); services.TryAddEnumerable(ServiceDescriptor.SingletonIMessageHandler, MessageHandlerA());TryAddEnumerable 检查到 MessageHandlerA 已经存在跳过结果一个 A没问题。反过来先 TryAddEnumerable 再 TryAddTryAdd 发现服务类型已有记录也会跳过还是一个 A。但有一种边界情况要注意当同一个实现类被用在多个服务接口下时跨接口并不会互斥。比如 MessageHandlerA 同时实现了 IMessageHandler 和 IDisposable你把它分别注册到两个接口下TryAddEnumerable 不会拦你——因为每个接口自己的注册表里都没有重复。这本身符合预期但很容易让新人误以为 Try* 是在全局做去重。实际上TryAddEnumerable 的作用域永远是同一个服务类型 同一个实现类型不是程序集级更不是全局级。5.3 IEnumerable 解析时的最后一个赢陷阱关于单值解析和多值解析的差异值得单独说一段。在 Microsoft.Extensions.DependencyInjection 默认容器里GetServiceT()解析单值时行为是返回最后一个注册的实现GetServicesT()解析集合时返回全部注册实现。这两套行为放到一起就会产生一种很迷惑的 bug你往容器里注册了两个同名实现单值解析拿最后一个看起来还行但一旦哪个组件改用集合解析就会拿到两份一模一样的实例每个消息被处理两次甚至造成重复消费、重复落库。从设计上杜绝这个问题的优先级应该是从源头控制同一接口的多实现注册统一走 TryAddEnumerable把注册逻辑收敛到一个扩展方法里不要散落在各模块各处各注册一遍启动流程里加一道诊断检查扫描所有 ServiceDescriptor发现同一 ServiceType 加同一 ImplementationType 出现多次就报警或抛异常。模块化项目里这道防线能救很多命。5.4 最终选型建议表使用场景推荐方法理由普通单实现注册确定不会有重复AddSingleton/AddScoped/AddTransient代码最简洁类库集成层给默认实现做兜底TryAdd*服务类型级去重保证接口只有一个默认实现同一接口多个实现、程序集扫描注册TryAddEnumerable实现类型级去重允许追加不同实现扩展方法会被多次调用、需要幂等TryAddEnumerable重复调用不会产生重复描述符打算强制替换旧实现先 RemoveAll 再 Add清掉旧的再上新的避免残留最后补一句从踩坑里总结出来的操作准则给默认实现做兜底用 TryAdd给一组实现做批量追加和防重用 TryAddEnumerable只有明确知道自己在做什么的时候才用 Add而且用之前先想清楚会不会和别的模块的 Try打架。*5.5 写在这篇经验之后折腾完这一圈我对服务注册这件事的态度发生了不小的变化。以前写AddSingleton纯靠肌肉记忆现在每次写完注册代码我都会在开发环境里把IServiceCollection里的描述符拉出来扫一眼确认没有重复项再继续往下走。这个习惯帮我省下了无数次本该耗费在业务逻辑上的排障时间。另外还发现一个小技巧如果项目里允许引入额外的诊断工具可以写一个简单的IServiceCollection扩展方法在启动早期检查所有 ServiceDescriptor发现同服务类型 同实现类型出现多次就立即输出警告。代码量不大但对长期维护的项目来说等于给依赖注入容器加了一道安全护栏。毕竟容器里的注册项一旦复杂起来肉眼是看不过来的。
返回列表