一次 HTTP 请求里的 DI 全链路:从 RequestServicesFeature.CreateScope 到 ServiceProviderEngineScope.GetService 的真实
将“每请求 Scope”拆开来看,其实就两件事:第一次用到RequestServices才创建 scope;响应结束时自动释放。中间再补上对象分层,你就能把整条链路用断点锁定。
ASP.NET Core 的“每请求 Scope”有一个严格的规则:它不会在请求一进来就生成,而是等到第一次读取HttpContext.RequestServices时,RequestServicesFeature才会延迟调用CreateScope()。释放也不依赖你手动using,而是通过Response.RegisterForDisposeAsync(...)挂到响应结束时自动触发。
下面我只追踪两处源码入口,将结论落实到具体可下断点的位置:
RequestServicesFeature.cs:RequestServicesgetter 决定什么时候创建;DisposeAsync()决定什么时候释放。ServiceProviderEngineScope.cs:GetService(...)决定 scope 作为“解析入口”时,最终把调用转发到哪里。
最后你会得到两样东西:一条能复述的调用路径,以及一份够用的最短断点清单——从HttpContext.Features一路走到ServiceProviderEngineScope.GetService。
总览:一次请求内 DI 的层级结构与职责边界(Request → Feature → Scope → RootProvider)
(适用场景/诞生背景)
- 场景:你已经知道 Singleton/Scoped/Transient 的概念,但通常会卡在三个点:
- Scoped 服务到底什么时候“开始存在”?
- 请求结束时由谁来负责 Dispose?
RequestServices.GetService(...)真正的解析发生在什么位置?
- 目标:把“每请求 Scope”拆成能定位的4 层对象和1 个触发条件,每一步都能用断点验证。
(核心原理)
(1)层级框架(定义式):
HttpContext.Features:请求态的扩展点容器;和 DI 相关的入口是IServiceProvidersFeature。RequestServicesFeature:实现IServiceProvidersFeature,主要负责两件事:- 延迟创建 request scope(lazy init)。
- 把 scope 的释放注册到响应结束(automatic disposal)。
IServiceScope(默认实现:ServiceProviderEngineScope):scope 的“状态载体”,包括缓存、可释放对象追踪,同时它本身也充当“当前 scope 的IServiceProvider”。- Root
ServiceProvider:默认容器的解析引擎入口。真正的 CallSite 构建、缓存命中、实例创建等逻辑都在这里(本文只停在“调用会回到 RootProvider”这一层)。
(2)触发条件(公式化表达):
- Scope 创建时机:
- 条件:首次读取
HttpContext.RequestServices,并且_requestServicesSet == false且_scopeFactory != null。 - 结果:调用
CreateScope(),生成 request scope,然后把scope.ServiceProvider暴露为RequestServices。
- 条件:首次读取
(缺陷/对比)
- 很多人会按直觉以为“请求进来就创建 scope”,但默认实现不是这么做的,它是 lazy。
- 直接能推到一个可验证的结论:如果整个请求里从没访问
RequestServices,就不会从这条入口创建 request scope。(至于有没有别的路径触发解析,那是另一回事,但至少这里不会。)
(回扣)
- 下一节从
RequestServicesFeature.RequestServices的 getter 开始,把“创建”和“释放”都固定到同一段代码、同一组字段上。
入口:RequestServicesFeature 如何在第一次访问 RequestServices 时 CreateScope 并注册请求结束释放
(适用场景/诞生背景)
- 场景:调试时经常会问:Scoped 服务到底什么时候“出现”?为什么不用写
using也会在请求结束释放? - 对齐方式:别从某个业务服务的构造函数往回倒,直接从第一次读取
HttpContext.RequestServices的那一行进源码。
(核心原理:对源码逐行对齐)
(1)延迟创建(lazy init):
- 触发条件:
!_requestServicesSet && _scopeFactory != null。 - 执行顺序是:
_context.Response.RegisterForDisposeAsync(this)_scope = _scopeFactory.CreateScope()_requestServices = _scope.ServiceProvider_requestServicesSet = true
- 结论很直接:request scope 不会在 middleware 管线的“起点”自动生成,而是在
RequestServicesgetter 真正被用到时才创建。
(2)释放绑定(automatic disposal):
RequestServicesFeature自己实现了IDisposable/IAsyncDisposable。- 更关键的是:它在创建 scope 之前就先做了
RegisterForDisposeAsync(this),等于把“释放这件事”先挂到响应结束的回调上。 DisposeAsync()里主要是对_scope做类型分派:_scope是IAsyncDisposable就走DisposeAsync()。- 否则如果是
IDisposable就走Dispose()。 - 最后把
_scope和_requestServices置空,避免 feature 继续持有一个已经释放过的 scope。
(缺陷/对比)
- 和你手动写
using var scope = provider.CreateScope()放一起看会更清楚:- 手动 scope:创建和释放都在你代码里显式发生。
- request scope:创建靠首次访问触发;释放靠
Response生命周期回调。
- 边界也很明确:只有真的触发了 getter 里的创建分支,才会有 scope,也才谈得上释放
_scope。
(回扣)
- 到这一步,其实就能把两件事钉死:
- 创建发生在
RequestServicesgetter 的 if 分支。 - 释放发生在响应结束触发的
DisposeAsync/Dispose。
- 创建发生在
- 下一步继续追:
CreateScope()产生的_scope.ServiceProvider到底是什么对象,它的GetService又会把解析带到哪里。
中继:Scope 不是解析引擎——ServiceProviderEngineScope 的职责是“状态容器 + 转发入口”
(适用场景/诞生背景)
- 场景:看到
_requestServices = _scope.ServiceProvider很容易误会:是不是“解析就在 scope 里完成”? - 先把分工说明白:在默认容器里,scope 更像请求态上下文,它不承载解析算法本体。
(核心原理)
(1)Scope 的接口形态(定义):
ServiceProviderEngineScope同时实现:IServiceScope:定义 scope 的生命周期边界。IServiceProvider:能直接GetService(...),所以它可以被当作RequestServices暴露出去。IServiceScopeFactory:允许从当前 scope 再创建子 scope。IAsyncDisposable:支持异步释放。
- 直观含义:它既像一个“请求内的 IServiceProvider 外壳”,也负责 scope 的释放与子 scope 的创建。
(2)Scope 的核心状态(读字段即可验证):
ResolvedServices:Dictionary<ServiceCacheKey, object?>。- 用来缓存解析结果,尤其是 scoped 生命周期那部分“每 scope 复用”。
_disposables:List<object?>?。- 记录需要跟着 scope 一起释放的实例(
IDisposable或IAsyncDisposable)。
- 记录需要跟着 scope 一起释放的实例(
Sync:以ResolvedServices作为锁对象。- 用于保护 scope 状态;root 与非 root 的保护范围略有差异(源码注释里有交代)。
(缺陷/对比)
- 如果把“scope=解析器”当成前提,会走偏:
- 解析算法(选构造函数、处理 IEnumerable、闭包缓存等)并不在 scope 里。
- scope 更像“解析上下文”:给 RootProvider 提供缓存位置和释放边界。
(总结)
- 下一节把断点下在
ServiceProviderEngineScope.GetService:用一行代码确认它怎么把请求转发给 RootProvider,同时观察 scope 参数是怎么参与解析语境的。
落点:ServiceProviderEngineScope.GetService 如何把解析委托给 RootProvider(以及这意味着什么)
(适用场景/诞生背景)
- 场景:你想在断点里看清楚“Scoped 解析到底走到哪一层”,并能区分 RootScope 和 request scope 的行为差异。
- 方法:不用猜,直接看
ServiceProviderEngineScope.GetService(Type)。
(核心原理)
(1)GetService 的真实动作:
ServiceProviderEngineScope.GetService(Type serviceType)的核心就是这一句:RootProvider.GetService(ServiceIdentifier.FromServiceType(serviceType), this)
- 这里的
this(当前 scope)被当成参数传进去,意思是“在这个 scope 的语境下解析”。
(2)语义解释(可检验):
RootProvider:解析引擎入口,决定怎么构建、怎么缓存、怎么创建实例。scope(this):解析上下文,决定缓存写到哪里、可释放对象挂到哪里。- 所以结论可以讲得很清楚:
- 同一个服务类型,换个 scope 可能就是另一个实例(scoped 的语义)。
- 解析算法不会在每个 scope 里复制一份,它集中在 RootProvider。
(缺陷/对比)
- 默认容器并不是“每个 scope 一套独立解析器”。
- scope 更像 RootProvider 的一个参数:它改变的是缓存与释放边界,不是解析算法本身。
(总结)
- 这时链路其实已经闭合:
RequestServicesFeature暴露出来的RequestServices,本质上就是一个 scope(实现了IServiceProvider)。 - 而 scope 的
GetService会回到 RootProvider。要继续追 CallSite、缓存命中、实例创建,就从RootProvider.GetService(...)往下走。
断点清单:把“创建—解析—释放”做成一次可复现的调试脚本
(适用场景 / 诞生背景)
- 场景:你想照着源码复现结论,需要的是最短路径:断点位置 + 预期现象 + 观察项。
(调试脚本:断点位置 + 预期现象 + 观察项)
(1)断点 A:RequestServicesFeature.RequestServicesgetter
- 预期现象:
- 第一次访问会进 if 分支;之后再访问直接 return
_requestServices。
- 第一次访问会进 if 分支;之后再访问直接 return
- 观察项:
_requestServicesSet:false → true。_scope:从null变为非空。_requestServices:被赋值为_scope.ServiceProvider。
(2)断点 B:_context.Response.RegisterForDisposeAsync(this)
- 预期现象:注册发生在
CreateScope()之前,这能确认“先把释放挂上,再创建 scope”。 - 观察项:
- 该调用执行完返回即可;如果还想验证“响应结束如何触发回调”,要继续追
HttpResponse内部的注册与执行点(本文不展开调用栈证据)。
- 该调用执行完返回即可;如果还想验证“响应结束如何触发回调”,要继续追
(3)断点 C:ServiceProviderEngineScope.GetService(Type serviceType)
- 预期现象:
- 只要从
RequestServices发起解析(比如构造函数注入背后的解析),通常就会命中这里。 - 命中后会马上转发到
RootProvider.GetService(..., this)。
- 只要从
- 观察项:
_disposed:是否已释放,用来排除“请求结束后仍在解析”的异常路径。IsRootScope:区分 root scope 和 request scope。RootProvider:RootServiceProvider的引用。- 入参
serviceType:当前正在解析的服务类型。
(4)断点 D:RequestServicesFeature.DisposeAsync()
- 预期现象:
- 请求结束时触发;对
_scope做 IDisposable/IAsyncDisposable 分派释放;最后字段置空。
- 请求结束时触发;对
- 观察项:
_scope的实际运行时类型。- 走的是
Dispose()还是DisposeAsync()。 _scope、_requestServices在末尾被置为null。
(缺陷 / 对比)
- 如果请求期间从未访问
HttpContext.RequestServices:- 断点 A 不会命中创建分支。
- 断点 D 仍可能被调用(feature 被 dispose),但
_scope为空时不会发生 scope 释放动作;这就是“没创建就没释放”的边界。
(总结)
- 把 A(创建)→ C(解析转发)→ D(释放)连起来,就足够在本机断点里还原“每请求 scope”到底是怎么回事。
- 想再往里追,就从 C 里看到的
RootProvider.GetService(...)继续向下走,进入 CallSite、缓存命中、捕获可释放对象等细节。
最后总结成一句话就够了:ASP.NET Core 的“每请求 Scope”不是玄学,第一次访问RequestServices才会CreateScope,响应结束会触发Dispose;解析入口统一交给 RootServiceProvider,scope 主要提供“缓存位置 + 释放边界”。
行动建议(最短闭环):按上面的断点 A → C → D 跑一遍真实请求,把三处调用栈截图留档;后面你要继续追RootProvider.GetService(...)、CallSite 构建和 scoped 缓存命中,这三张图就是最稳的基线。
代码示例:在控制器中验证断点 A、C、D
下面是一个简单的 ASP.NET Core 控制器示例,演示如何触发断点清单中的关键点:
usingMicrosoft.AspNetCore.Mvc;usingMicrosoft.Extensions.DependencyInjection;namespaceDebugDi.Controllers{[ApiController][Route("api/[controller]")]publicclassDebugScopeController:ControllerBase{privatereadonlyIServiceProvider_serviceProvider;privatereadonlyILogger<DebugScopeController>_logger;publicDebugScopeController(IServiceProviderserviceProvider,ILogger<DebugScopeController>logger){_serviceProvider=serviceProvider;_logger=logger;}[HttpGet("test-scope")]publicIActionResultTestRequestScope(){// ========== 断点 A 触发点 ==========// 第一次访问 HttpContext.RequestServices 会触发 scope 创建// 在 RequestServicesFeature.RequestServices getter 设置断点varrequestServices=HttpContext.RequestServices;_logger.LogInformation("第一次访问 RequestServices,scope 应已创建");// ========== 断点 C 触发点 ==========// 通过 RequestServices 解析服务会触发 ServiceProviderEngineScope.GetService// 在 ServiceProviderEngineScope.GetService(Type) 设置断点varscopedService=requestServices.GetService<IMyScopedService>();_logger.LogInformation("解析 Scoped 服务,应命中断点 C");// 再次访问不会创建新 scope(断点 A 不会进 if 分支)varsameRequestServices=HttpContext.RequestServices;_logger.LogInformation("再次访问 RequestServices,应直接返回已有实例");// ========== 断点 D 触发点 ==========// 响应结束时自动触发,无需手动代码// 在 RequestServicesFeature.DisposeAsync() 设置断点// 观察请求结束后 scope 如何被释放returnOk(new{Message="断点验证完成",RequestServicesCreated=requestServices!=null,ScopedServiceResolved=scopedService!=null,SameInstance=ReferenceEquals(requestServices,sameRequestServices)});}}// 示例 Scoped 服务publicinterfaceIMyScopedService{}publicclassMyScopedService:IMyScopedService,IDisposable{privatereadonlyGuid_id=Guid.NewGuid();publicMyScopedService(){Console.WriteLine($"MyScopedService 实例创建:{_id}");}publicvoidDispose(){Console.WriteLine($"MyScopedService 实例释放:{_id}");}}}调试步骤与预期输出
断点 A 验证:
- 在
RequestServicesFeature.RequestServicesgetter 设置断点 - 首次调用
/api/debugscope/test-scope时,断点会命中 if 分支 - 观察调试器:
_requestServicesSet从false变为true,_scope从null变为非空
- 在
断点 C 验证:
- 在
ServiceProviderEngineScope.GetService(Type)设置断点 - 当执行
requestServices.GetService<IMyScopedService>()时断点命中 - 观察调试器:
_disposed应为false,IsRootScope应为false,serviceType参数应为IMyScopedService
- 在
断点 D 验证:
- 在
RequestServicesFeature.DisposeAsync()设置断点 - 请求结束后(响应完成)断点自动命中
- 观察调试器:
_scope的实际类型,释放方法调用(Dispose()或DisposeAsync()),最后字段被置为null
- 在
预期调试器输出截图描述
断点 A 截图:
- 调用栈显示从控制器到
HttpContext.RequestServicesgetter 的路径 - 局部变量窗口显示
_requestServicesSet: false → true的变化 _scope从null变为ServiceProviderEngineScope实例
- 调用栈显示从控制器到
断点 C 截图:
- 调用栈显示解析链:控制器 →
GetService<T>→ServiceProviderEngineScope.GetService - 监视窗口显示
this的RootProvider引用和IsRootScope: false - 参数窗口显示
serviceType: IMyScopedService
- 调用栈显示解析链:控制器 →
断点 D 截图:
- 调用栈显示释放路径:
HttpResponse完成 →RequestServicesFeature.DisposeAsync - 局部变量窗口显示
_scope被释放前后的状态 - 输出窗口显示
MyScopedService实例的 Dispose 调用
- 调用栈显示释放路径:
注册服务配置
在Program.cs中添加:
builder.Services.AddScoped<IMyScopedService,MyScopedService>();这个示例提供了一个可运行的验证环境,让你能够实际观察「创建—解析—释放」的完整生命周期。