一次 HTTP 请求里的 DI 全链路:从 RequestServicesFeature.CreateScope 到 ServiceProviderEngineScope.GetService 的真实

将“每请求 Scope”拆开来看,其实就两件事:第一次用到RequestServices才创建 scope;响应结束时自动释放。中间再补上对象分层,你就能把整条链路用断点锁定。

ASP.NET Core 的“每请求 Scope”有一个严格的规则:它不会在请求一进来就生成,而是等到第一次读取HttpContext.RequestServices时,RequestServicesFeature才会延迟调用CreateScope()。释放也不依赖你手动using,而是通过Response.RegisterForDisposeAsync(...)挂到响应结束时自动触发。

下面我只追踪两处源码入口,将结论落实到具体可下断点的位置:

  • RequestServicesFeature.csRequestServicesgetter 决定什么时候创建;DisposeAsync()决定什么时候释放。
  • ServiceProviderEngineScope.csGetService(...)决定 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”。
  • RootServiceProvider:默认容器的解析引擎入口。真正的 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
  • 执行顺序是:
    1. _context.Response.RegisterForDisposeAsync(this)
    2. _scope = _scopeFactory.CreateScope()
    3. _requestServices = _scope.ServiceProvider
    4. _requestServicesSet = true
  • 结论很直接:request scope 不会在 middleware 管线的“起点”自动生成,而是在RequestServicesgetter 真正被用到时才创建。

(2)释放绑定(automatic disposal):

  • RequestServicesFeature自己实现了IDisposable/IAsyncDisposable
  • 更关键的是:它在创建 scope 之前就先做了RegisterForDisposeAsync(this),等于把“释放这件事”先挂到响应结束的回调上。
  • DisposeAsync()里主要是对_scope做类型分派:
    • _scopeIAsyncDisposable就走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 的核心状态(读字段即可验证):

  • ResolvedServicesDictionary<ServiceCacheKey, object?>
    • 用来缓存解析结果,尤其是 scoped 生命周期那部分“每 scope 复用”。
  • _disposablesList<object?>?
    • 记录需要跟着 scope 一起释放的实例(IDisposableIAsyncDisposable)。
  • 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
  • 观察项:
    • _requestServicesSetfalse → 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}");}}}
调试步骤与预期输出
  1. 断点 A 验证

    • RequestServicesFeature.RequestServicesgetter 设置断点
    • 首次调用/api/debugscope/test-scope时,断点会命中 if 分支
    • 观察调试器:_requestServicesSetfalse变为true_scopenull变为非空
  2. 断点 C 验证

    • ServiceProviderEngineScope.GetService(Type)设置断点
    • 当执行requestServices.GetService<IMyScopedService>()时断点命中
    • 观察调试器:_disposed应为falseIsRootScope应为falseserviceType参数应为IMyScopedService
  3. 断点 D 验证

    • RequestServicesFeature.DisposeAsync()设置断点
    • 请求结束后(响应完成)断点自动命中
    • 观察调试器:_scope的实际类型,释放方法调用(Dispose()DisposeAsync()),最后字段被置为null
预期调试器输出截图描述
  1. 断点 A 截图

    • 调用栈显示从控制器到HttpContext.RequestServicesgetter 的路径
    • 局部变量窗口显示_requestServicesSet: false → true的变化
    • _scopenull变为ServiceProviderEngineScope实例
  2. 断点 C 截图

    • 调用栈显示解析链:控制器 →GetService<T>ServiceProviderEngineScope.GetService
    • 监视窗口显示thisRootProvider引用和IsRootScope: false
    • 参数窗口显示serviceType: IMyScopedService
  3. 断点 D 截图

    • 调用栈显示释放路径:HttpResponse完成 →RequestServicesFeature.DisposeAsync
    • 局部变量窗口显示_scope被释放前后的状态
    • 输出窗口显示MyScopedService实例的 Dispose 调用
注册服务配置

Program.cs中添加:

builder.Services.AddScoped<IMyScopedService,MyScopedService>();

这个示例提供了一个可运行的验证环境,让你能够实际观察「创建—解析—释放」的完整生命周期。