如何理解 C#/.NET 的依赖注入与生命周期

📅 2026/7/11 22:24:36
如何理解 C#/.NET 的依赖注入与生命周期
从问题引入 DI 解决方案先看几段能跑但会越写越乱的代码再讲依赖怎么从外部传入、容器如何自动组装以及 Singleton / Scoped / Transient 三种生命周期。第一个问题实现写死了想换实现就要改业务代码一开始订单服务直接 new 仓储能跑publicclassOrderService{publicOrderDtoGet(intid){varreponewSqlOrderRepository();returnrepo.Find(id);}}产品说测试环境别连真库换成内存版。你只能改OrderService里的实现类publicOrderDtoGet(intid){varreponewInMemoryOrderRepository();// 改这里returnrepo.Find(id);}问题业务类OrderService和具体仓储SqlOrderRepository绑死了。换实现 改业务代码。依赖越多改的地方越多。第一步改进不要自己在内部 new让外面传进来这叫构造函数注入还没用到 DI 容器publicinterfaceIOrderRepository{OrderDtoFind(intid);}publicclassOrderService{privatereadonlyIOrderRepository_repo;publicOrderService(IOrderRepositoryrepo){_reporepo;}publicOrderDtoGet(intid)_repo.Find(id);}生产环境手动组装IOrderRepositoryreponewSqlOrderRepository();varservicenewOrderService(repo);测试环境IOrderRepositoryreponewInMemoryOrderRepository();varservicenewOrderService(repo);OrderService的代码可以固定不动了它只认识IOrderRepository不再认识SqlOrderRepository还是InMemoryOrderRepository。换环境时改的是外面传进来哪一个实现。对比一下依赖关系。改之前业务类直接依赖具体仓储OrderService ──▶ SqlOrderRepository改之后中间加一层接口两边都靠它对接OrderService ──▶ IOrderRepository ◀── SqlOrderRepositoryOrderService依赖的是接口不是 Sql 类SqlOrderRepository去实现这个接口。依赖倒置说的不是箭头在图上反着画OrderService照样是用仓储调用方向没变。真正变的是谁更稳定、谁应该迁就谁。订单查询、订单规则这类业务逻辑通常比 SQL Server、表结构、EF 还是 Dapper 更稳定。原来的写法是业务代码直接依赖数据库细节数据库一换业务跟着改改造后IOrderRepository应该是业务侧定义出来的抽象表达的是业务需要什么能力比如按订单 id 查询订单。至于 SQL、内存、Redis 怎么实现是细节去适配这个抽象。所以倒置的不是调用方向而是依赖关系里的控制权以前是业务适应数据库实现现在是数据库实现适应业务抽象。换内存仓储改的是SqlOrderRepository那一侧OrderService不动。这里还没有 DI 容器只是加接口 构造函数传参。这一步还带来另一个变化以前谁 new 仓储是OrderService说了算在方法里new现在是调用方决定 new Sql 还是 new InMemory再传给构造函数。创建对象的权力从类里面挪到了类外面这叫控制反转。外面把依赖传进来就是依赖注入。把这几个概念放在一起看DIP为什么这么设计让业务依赖抽象细节适配抽象 DI怎么把依赖给进去通过构造函数等方式从外部传入 DI 容器谁帮我自动传.NET 容器按注册关系自动创建和注入手动new OrderService(new SqlOrderRepository())已经算注入后面的 .NET 容器只是把这段组装自动化。第二个问题单元测试很难写还是最开始的写法publicclassOrderService{publicOrderDtoGet(intid){varreponewSqlOrderRepository();// 写死了returnrepo.Find(id);}}测试想验证查到订单就返回 DTO但一跑测试就会去连真实数据库慢、不稳定CI 上可能没有库。用上面的构造函数注入后[Test]publicvoidGet_returns_order(){varfakeReponewFakeOrderRepository();// 测试专用返回固定数据varservicenewOrderService(fakeRepo);vardtoservice.Get(1);Assert.Equal(测试订单,dto.Name);}DI 要解决的第二件事测试里能轻松换成 Fake/Mock不用改被测类的源码。第三个问题一层 new 一层创建代码散落各处Controller 也要用OrderService若每层自己 newpublicclassOrderController:ControllerBase{[HttpGet({id})]publicOrderDtoGet(intid){varreponewSqlOrderRepository();varservicenewOrderService(repo);returnservice.Get(id);}}仓储若还要连接串、日志会变成varconnnewSqlConnection(Server...);varloggernewConsoleLogger();varreponewSqlOrderRepository(conn,logger);varservicenewOrderService(repo);每个 Controller、每个入口都要写一遍漏改一处就出错。谁创建、谁释放、创建顺序全散落在业务入口里。第一步改进里组装已经移到OrderService外面但每个入口各自组装一份问题从业务类内部 new变成了到处重复 new。第二步是把组装收到一个地方统一做——通常叫组合根在 ASP.NET Core 里就是Program.cs全应用只在这里决定用 Sql 仓储还是内存仓储Controller 只写构造函数要什么不再出现new OrderService(...)。业务代码只认接口具体实现和怎么串对象集中在启动时配置。引入 DI 容器登记一次到处注入组合根里若仍手写整条组装链类型一多、依赖一深维护成本会上去。.NET DI 容器把组合根里的手工new换成登记 自动解析在Program.cs用IServiceCollection登记接口与实现、生命周期运行时IServiceProvider按构造函数递归创建并传入在 Scope 结束或应用关闭时释放它负责的对象Program.cs里登记注册builder.Services.AddScopedIOrderRepository,SqlOrderRepository();builder.Services.AddScopedOrderService();Controller 只写需要什么容器自动传入publicclassOrderController:ControllerBase{privatereadonlyOrderService_service;publicOrderController(OrderServiceservice){_serviceservice;}[HttpGet({id})]publicOrderDtoGet(intid)_service.Get(id);}请求进来时容器大致会做要创建OrderController发现构造函数需要OrderService要创建OrderService发现需要IOrderRepository查登记表IOrderRepository→SqlOrderRepository按生命周期创建或取出实例依次 new 好把OrderService传给OrderController你不再手写new OrderService(new SqlOrderRepository())。换实现只改注册一行builder.Services.AddScopedIOrderRepository,InMemoryOrderRepository();DI 容器主要干两件事帮你按构造函数把对象拼起来解析依赖按你登记的生命周期决定这个类型 new 一次还是每次 new下面单独说容器怎么知道该 new 谁、怎么 new——不用看源码知道流程即可。容器怎么把对象拼出来登记时builder.Services.AddScopedIOrderRepository,SqlOrderRepository();builder.Services.AddScopedOrderService();容器内部相当于记下一张表IOrderRepository → SqlOrderRepository → Scoped OrderService → OrderService → Scoped运行时有人要OrderService例如创建 Controller 时容器会需要 OrderService → 看构造函数要 IOrderRepository → 查表IOrderRepository 的实现是 SqlOrderRepository → 若当前 Scope 里还没有就 new 一个 SqlOrderRepository → 用反射调用构造函数new OrderService(repo) → 返回拼好的 OrderService本质就是查表 递归看构造函数要什么 按生命周期决定是 new 还是从缓存取。最后一步通常是Activator.CreateInstance一类机制调用构造函数。你手写的话也是先 new 依赖、再 new 外层类容器只是把这件事自动化并在 Scope 结束或应用关闭时负责 Dispose 该它管的对象。三种生命周期同一个类注册不同行为不同下面用同一个OrderService理解三种注册方式的区别。登记时多一个参数活多久。Transient每次要每次 newbuilder.Services.AddTransientIOrderRepository,SqlOrderRepository();同一请求里两处注入IOrderRepositorypublicclassA{publicA(IOrderRepositoryr){}}publicclassB{publicB(IOrderRepositoryr){}}A和B通常情况下会拿到两个不同的SqlOrderRepository实例每次向容器解析IOrderRepository都会 new。个别特殊注册方式下行为可能不同日常开发按Transient 不要默认共享实例理解即可。适合轻、无状态、创建便宜的小对象。Scoped一个 Scope 里共用一份builder.Services.AddScopedIOrderRepository,SqlOrderRepository();Scope可以理解成一段边界在这个边界内第一次解析IOrderRepository会 new之后在同一边界内再解析拿到同一个实例换一个新 Scope会 new 新的。在 ASP.NET Core 里一次 HTTP 请求 ≈ 一个 Scope。所以常说一个请求里共用一份——同一请求里的 Controller 和 Service 注入的IOrderRepository是同一个下一个请求是新 Scope会是新实例。后台任务里也可以用IServiceScopeFactory.CreateScope()手动开 Scope道理相同。适合跟当前 Scope 绑定的状态最典型是 EF Core 的DbContext——这次 Scope 里跟踪的实体不能和下个 Scope 混在一起。Singleton整个应用同一 ServiceProvider共用一份builder.Services.AddSingletonICacheService,MemoryCacheService();默认情况第一次有人向容器解析ICacheService时创建实例之后在同一个IServiceProvider生命周期内始终返回这一实例直到应用关闭。若注册时直接传入实例builder.Services.AddSingletonICacheService(newMemoryCacheService());则登记时就已经创建不是等到第一次解析。适合缓存、配置包装等无 Scope 级状态、且要线程安全的东西。一张表对照注册方式简单理解常见例子Transient每次解析通常 new 新的轻量工具类Scoped一个 Scope 里同一个Web 里常等于一次请求DbContext、RepositorySingleton同一 ServiceProvider 内同一个内存缓存、配置生命周期不能乱配Singleton 里不能注入 Scoped错误示例builder.Services.AddSingletonBadService();builder.Services.AddScopedIOrderRepository,SqlOrderRepository();publicclassBadService{publicBadService(IOrderRepositoryrepo){}// repo 是 Scoped}BadService只创建一次Singleton但构造函数里的IOrderRepository是按请求创建的 Scoped。结果第一个请求的 DbContext 被 Singleton 一直攥在手里第二个请求还在用第一个请求的上下文——用户数据串了、EF 报错。规则记一句就行活得更长的不能依赖活得更短的。Singleton 不能直接构造函数注入 Scoped。后台任务BackgroundService本身是 Singleton要用 Scoped 服务时应注入IServiceScopeFactory在方法里临时开 Scopeusingvarscope_scopeFactory.CreateScope();varreposcope.ServiceProvider.GetRequiredServiceIOrderRepository();// 用完 scope 释放Scoped 对象随 scope 销毁Web 请求里实际怎么走一次 API 请求本质上就是容器为这次请求创建了一个 ScopeASP.NET Core 为这次请求开一个Scope在这个 Scope 里创建 Controller并注入它要的 Service、Repository执行完 Action请求结束关闭 Scope该 Scope 内由容器创建并负责释放的Scoped 对象以及应随 Scope 释放的 Transient在这里 Dispose——例如DbContext所以 Web 项目里DbContext、Repository、业务 Service 多用 Scoped全应用共享的缓存用 Singleton确实每次都要新实例的轻量类用 Transient。补充HttpClient 不要循环里 new错误写法while(true){usingvarclientnewHttpClient();awaitclient.GetAsync(url);}看起来using会释放对象但底层TCP/Socket可能不能及时回收严重时端口耗尽。正确做法builder.Services.AddHttpClientIWeatherClient,WeatherClient();IHttpClientFactory注册为 Singleton 时管理的重点不是只 new 一个 HttpClient 对象而是HttpMessageHandler的生命周期和连接复用WeatherClient类型常按 Transient 注册每次解析可能是新的包装类但底层的 Handler 池、Socket 连接由工厂统一复用。这样既避免频繁new HttpClient()的问题也避免单个 HttpClient 实例长期不刷新 DNS 等隐患。和 Service Locator 的区别推荐构造函数里写清楚要什么上面 Controller 的写法。不推荐在方法里临时去容器里掏varrepoHttpContext.RequestServices.GetRequiredServiceIOrderRepository();依赖被藏起来测试和阅读都变难。GetRequiredService只适合框架边界或CreateScope()里这类场景。小结阶段做法痛点1类内部new具体类型换实现要改业务代码2构造函数传入接口测试能换 Fake但创建代码散落3IServiceCollection登记 容器注入创建集中、可换实现、可管生命周期依赖注入DI 依赖从外部通过构造函数等方式传入不在类内部new具体实现。依赖倒置DIP 业务定义抽象并依赖抽象数据库等技术细节去适配抽象换实现不改业务类。控制反转IoC 谁new、用哪个实现由类外部决定容器是 IoC 的自动化工具。三种生命周期 容器对解析几次、在多长范围内复用同一实例的规则。先记住Scoped 一个 Scope 一份Web 里常等于一次请求、DbContext 必须 Scoped、Singleton 不能直接要 Scoped再回头看三个Add*API就不会只是死记名字。参考Dependency injection in .NETService lifetimesDependency injection in ASP.NET Core