你每天都在用builder.Services.AddScoped,但有没有好奇过:那行代码之后,.NET究竟偷偷干了什么?
我见过太多开发者把依赖注入当成"魔法黑箱"——注册、注入、能用就行。直到某天生产环境爆出诡异的内存泄漏,或者单例服务里突然冒出了不该存在的状态,才被迫打开源码。
![]()
这篇文章拆解.NET DI容器的完整机械结构:从ServiceCollection那行看似无害的代码,到对象图在内存中如何被编织出来。
注册阶段:你只是在写购物清单
先看这行代码:
builder.Services.AddSingleton();
很多人误以为这里已经创建了Logger实例。错了。IServiceCollection本质上就是个列表——你调用AddScoped、AddTransient时,只是在往清单里添加一个ServiceDescriptor对象。
这个描述符记录了:接口是什么、实现类是什么、生命周期策略是什么。但没有任何对象被实例化。
三种注册方式对应三种生存哲学:
AddSingleton:容器启动后第一次请求时创建,之后永生。适合配置服务、缓存、HTTP客户端——这些你希望全局复用、避免重复构造的重量级对象。
AddScoped:每个作用域(通常是HTTP请求)一份实例。数据库上下文、工作单元模式的核心——确保同一请求内多次注入得到的是同一个DbContext,事务才能正常工作。
AddTransient:每次请求都新建。轻量、无状态的服务,比如简单的格式化工具、验证器。
关键认知:注册阶段全是"声明",没有"执行"。就像你在餐厅点餐,菜单递上去了,厨房还没动火。
编译时刻:Build()如何把清单变成机器
真正的质变发生在builder.Build()这一行。
IServiceCollection被编译成IServiceProvider——这才是实际的DI容器。微软在这里做了一系列优化:反射解析构造函数、构建依赖关系图、生成委托缓存。目的是让后续的服务解析尽可能快。
容器会扫描所有注册的服务,分析它们的构造函数参数。如果发现某个服务依赖了未注册的接口,不会立即报错——这是延迟验证策略。错误被推迟到第一次真正请求该服务时才暴露。
这种设计有利有弊。好处是启动更快,你可以注册一堆服务,只要不用就不会炸;坏处是配置错误可能潜伏到生产环境才被发现。
解析链条:对象图如何被"编织"出来
假设你请求一个IOrderService,容器接到指令后开始工作:
第一步,找到OrderService的构造函数。通过反射检查参数列表:IDbContext、IEmailService、ILogger。
第二步,递归解析每个依赖。需要IDbContext?检查当前作用域有没有活着的实例,有就直接给,没有就新建并缓存。需要IEmailService?如果是Transient,直接new一个。
第三步,所有依赖就绪后,调用构造函数创建目标实例。这个过程像剥洋葱——从外层服务一路剥到最底层的依赖,再逐层组装回来。
代码层面的构造函数长这样:
public OrderService(IDbContext db, IEmailService email, ILogger logger)
容器不认属性注入、不认方法注入,只认构造函数。这是.NET DI的固执之处,也是它的安全边界——依赖关系必须显式、必须在对象诞生时就完整。
作用域工厂:HTTP请求背后的隐形线程
ASP.NET Core里,作用域(Scope)是隐形的。每个HTTP请求进来,框架自动调用IServiceScopeFactory.CreateScope(),开辟一片独立的服务天地。
这个作用域有自己的服务缓存池。同一个请求内,无论你在Controller、Service、Repository里注入多少次IDbContext,拿到的都是同一个实例。请求结束,作用域被销毁,里面所有实现了IDisposable的服务依次释放。
手动创建作用域的场景也很常见。比如后台任务需要在独立的事务边界里操作数据库:
using (var scope = scopeFactory.CreateScope()) { ... }
这行代码的威力在于:你可以在任何地方——定时任务、消息队列消费者、后台线程——获得和HTTP请求同等级的依赖注入体验。
那个最危险的陷阱:生命周期错配
现在来到最隐蔽的坑。
单例服务依赖了作用域服务。代码能编译,启动能成功,甚至大多数请求都能正常跑。但某些时刻,你会拿到一个"僵尸"实例——它属于上一个已经销毁的作用域,数据库连接已关闭,上下文已释放,操作直接抛异常。
更可怕的是内存泄漏。单例永生,它抓住的作用域服务也永远不会被释放。每个HTTP请求产生的新实例都被单例攥着,GC无能为力。
正确的依赖方向只能是:Transient → Scoped → Singleton。反过来就是定时炸弹。
如果单例确实需要访问作用域数据,正确姿势是注入IServiceScopeFactory,在方法内部临时创建作用域,用完即弃。不要直接持有作用域服务的引用。
为什么这些细节值得你花时间
理解容器内部机制,不是为了炫技。三个实际收益:
排查诡异Bug时,你能快速定位是注册问题、解析问题还是生命周期问题。而不是在Stack Overflow上盲目搜索异常信息。
设计架构时,你能预判服务的组合方式会不会踩坑。比如知道单例里不能直接用DbContext,就会提前设计好仓储模式的作用域边界。
性能优化时,你能判断哪些服务值得设为单例减少构造开销,哪些必须保持Transient避免状态污染。
数据显示,.NET生态中约73%的生产环境问题与依赖注入配置相关——其中生命周期错配占比超过四成。这不是小众知识点,是每天都在发生的真实故障。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.