2.1 主机Host与依赖注入容器


文档摘要

2.1 主机 Host 与依赖注入容器 主机是 ASP.NET Core 进程的骨架:它装载配置、初始化日志、建好依赖注入容器、启动 Kestrel 监听,然后才把控制权交给中间件管线。依赖注入容器则是管线的循环系统——每一站需要的对象都从这里取。 上一章结尾留了个引子:四行代码的入口文件,背后是一整套启动流程。本节把那四行还原成真实的样子。观察哨这一站设在进程启动的瞬间,比任何 HTTP 请求都早。 主机启动时做了什么 WebApplication.CreateBuilder 不是简单 new 一个对象。

2.1 主机 Host 与依赖注入容器

主机是 ASP.NET Core 进程的骨架:它装载配置、初始化日志、建好依赖注入容器、启动 Kestrel 监听,然后才把控制权交给中间件管线。依赖注入容器则是管线的循环系统——每一站需要的对象都从这里取。

上一章结尾留了个引子:四行代码的入口文件,背后是一整套启动流程。本节把那四行还原成真实的样子。观察哨这一站设在进程启动的瞬间,比任何 HTTP 请求都早。

主机启动时做了什么

WebApplication.CreateBuilder 不是简单 new 一个对象。按时间顺序,它至少完成五件事:读取配置来源链(appsettings 文件、环境变量、命令行参数)、创建日志工厂并接入控制台与调试输出、准备服务容器、注册框架基础服务(路由、主机生命周期等)、返回一个 builder 让你继续追加服务。

Build 调用之后,容器定型,app 对象拿到管线的组装权;Run 则启动服务器进入阻塞监听。这三步的生命周期关系如下:

图 2.1-1 主机启动流程与三阶段职责

图 2.1-1 主机启动流程与三阶段职责

依赖注入:管线的循环系统

容器里放的是"服务"——构造好的对象及获取方式的说明书。三种子代码对应三种生命周期,这是本节最重要的知识点:

方法 生命周期 典型用途 误用后果
AddSingleton 全进程一个实例 缓存、配置快照 存请求状态会串号
AddScoped 每个请求一个实例 数据库上下文 单例里注入它直接抛异常
AddTransient 每次注入都新建 轻量无状态工具 高频调用产生分配压力

动手验证 Scoped 的边界。注册一个带编号的服务,并在两个端点里各注入一次:

var builder = WebApplication.CreateBuilder(args); // 注册为 Scoped:同一个请求内拿到同一个实例,不同请求各拿各的 builder.Services.AddScoped<Counter>(); var app = builder.Build(); app.MapGet("/a", (Counter c) => $"a 端点看到的编号: {c.Id}"); app.MapGet("/b", (Counter c) => $"b 端点看到的编号: {c.Id}"); app.Run(); // 辅助类型:构造时生成编号,用于观察实例身份 public class Counter { public int Id { get; } = System.Threading.Interlocked.Increment(ref _seed); private static int _seed; }

连续访问,输出示例:

GET /a → a 端点看到的编号: 1 GET /a → a 端点看到的编号: 2 # 新请求新实例,证明作用域=请求 GET /b → b 端点看到的编号: 3

编号每次递增,说明每个请求各自拿到新实例。若把 AddScoped 换成 AddSingleton,三次访问会输出同一个编号——并发请求共享一个对象,这正是"把请求状态存进单例导致串号"事故的原理。

解析规则与一个经典事故

生命周期有一条安全线:作用域短的服务不能被注入进活得比它久的服务。最典型的是把数据库上下文(Scoped)注入进单例。容器在编译期就能发现这种"俘获",直接抛异常,算是一种保护:

// 错误示范:单例试图持有 Scoped 服务 builder.Services.AddSingleton<BadHolder>(); // BadHolder 的构造函数要求注入 Scoped 的服务时, // 启动阶段即抛出类似如下异常: // Unable to resolve service for type 'Counter' while attempting // to activate 'BadHolder'.(实际场景常见于 DbContext 注入单例)

正确做法两种:单例里只放与请求无关的东西;确需在单例里使用 Scoped 服务,用 IServiceScopeFactory 手动开作用域,用完即弃——第 5 章 EF Core 的后台任务场景会用到这个技巧。

⚠️ 常见坑:异常信息说"无法解析类型",新手以为是没注册,其实九成是生命周期冲突或者漏注册接口映射(注册了实现类却按接口注入)。先查注册方式,再查生命周期。

案例:后台定时任务要用数据库怎么办

背景:观察哨项目要加一个每分钟跑一次的统计任务,统计逻辑依赖 Scoped 的计数服务。宿主服务(HostedService)是单例,直接构造注入会撞上生命周期安全线。

操作:不注入服务本身,注入"作用域工厂",在每次执行时手动开作用域:

// 后台任务:定时开作用域取用 Scoped 服务,用完释放 public class CountJob : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; public CountJob(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory; protected override async Task ExecuteAsync(CancellationToken token) { while (!token.IsCancellationRequested) { // 关键动作:为本次任务手动创建一个作用域,相当于"临时请求" using (var scope = _scopeFactory.CreateScope()) { var counter = scope.ServiceProvider.GetRequiredService<Counter>(); Console.WriteLine($"后台统计:当前编号 {counter.Id}"); } // using 结束,作用域连同其中的 Scoped 实例一并释放 await Task.Delay(TimeSpan.FromMinutes(1), token); } } } // 注册:宿主服务本身是单例,但每次执行都新开作用域 builder.Services.AddHostedService<CountJob>();

结果:任务每分钟输出一行"后台统计:当前编号 N",且 N 每次递增——每次循环拿到的是全新实例;作用域释放及时,没有对象被长命宿主意外持有。

解读:IServiceScopeFactory 本身是单例,注入它合法;它造出的作用域模拟了"一个请求的生命周期"。DbContext 这类 Scoped 服务放进这个模式,就避免了"上下文活得比请求久"导致的并发与内存问题。

变式:如果任务频率很高,"每次执行开一个作用域"的开销开始可观,可以考虑把统计逻辑改造成无状态单例、用原子计数器替代 Scoped 状态,或降低任务频率批量处理。容器规则是死的,架构选择是活的——先让规则讲清楚代价,再谈取舍。

💡 关键直觉:把容器当"对象的水电网"理解——注册是布线,注入是插座,生命周期是供电范围。管线每一站(中间件、控制器、后台服务)都从同一张网上取电。

本节要点回顾

  • 主机三阶段:CreateBuilder 装配置建容器雏形、Build 定型、Run 监听阻塞;
  • 启动期代码每进程一次,请求期代码每请求一次,读启动文件先分清两者;
  • 三种生命周期:Singleton 全进程、Scoped 每请求、Transient 每注入;
  • 安全线:不能把短命服务注入长命服务,冲突会在启动期暴露;
  • 框架自带容器已覆盖绝大多数需求,不必急着换第三方容器。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U