尧图网站建设 尧图网络
  • 首页
  • 关于我们
  • 服务项目
  • 案例展示
  • 建站流程
  • 资讯中心
  • 联系我们
首页/资讯中心/详情

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

如何理解 C#/.NET 的依赖注入与生命周期
📅 发布时间:2026/7/11 22:48:23

从问题引入 DI 解决方案:先看几段能跑但会越写越乱的代码,再讲依赖怎么从外部传入、容器如何自动组装,以及 Singleton / Scoped / Transient 三种生命周期。


第一个问题:实现写死了,想换实现就要改业务代码

一开始订单服务直接 new 仓储,能跑:

publicclassOrderService{publicOrderDtoGet(intid){varrepo=newSqlOrderRepository();returnrepo.Find(id);}}

产品说:测试环境别连真库,换成内存版。你只能改OrderService里的实现类:

publicOrderDtoGet(intid){varrepo=newInMemoryOrderRepository();// 改这里returnrepo.Find(id);}

问题:业务类OrderService和具体仓储SqlOrderRepository绑死了。换实现 = 改业务代码。依赖越多,改的地方越多。

第一步改进:不要自己在内部 new,让外面传进来(这叫构造函数注入,还没用到 DI 容器):

publicinterfaceIOrderRepository{OrderDtoFind(intid);}publicclassOrderService{privatereadonlyIOrderRepository_repo;publicOrderService(IOrderRepositoryrepo){_repo=repo;}publicOrderDtoGet(intid)=>_repo.Find(id);}

生产环境手动组装:

IOrderRepositoryrepo=newSqlOrderRepository();varservice=newOrderService(repo);

测试环境:

IOrderRepositoryrepo=newInMemoryOrderRepository();varservice=newOrderService(repo);

OrderService的代码可以固定不动了:它只认识IOrderRepository,不再认识SqlOrderRepository还是InMemoryOrderRepository。换环境时,改的是外面传进来哪一个实现。

对比一下依赖关系。改之前,业务类直接依赖具体仓储:

OrderService ──▶ SqlOrderRepository

改之后,中间加一层接口,两边都靠它对接:

OrderService ──▶ IOrderRepository ◀── SqlOrderRepository

OrderService依赖的是接口,不是 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){varrepo=newSqlOrderRepository();// 写死了returnrepo.Find(id);}}

测试想验证查到订单就返回 DTO,但一跑测试就会去连真实数据库,慢、不稳定,CI 上可能没有库。

用上面的构造函数注入后:

[Test]publicvoidGet_returns_order(){varfakeRepo=newFakeOrderRepository();// 测试专用,返回固定数据varservice=newOrderService(fakeRepo);vardto=service.Get(1);Assert.Equal("测试订单",dto.Name);}

DI 要解决的第二件事:测试里能轻松换成 Fake/Mock,不用改被测类的源码。


第三个问题:一层 new 一层,创建代码散落各处

Controller 也要用OrderService,若每层自己 new:

publicclassOrderController:ControllerBase{[HttpGet("{id}")]publicOrderDtoGet(intid){varrepo=newSqlOrderRepository();varservice=newOrderService(repo);returnservice.Get(id);}}

仓储若还要连接串、日志,会变成:

varconn=newSqlConnection("Server=...");varlogger=newConsoleLogger();varrepo=newSqlOrderRepository(conn,logger);varservice=newOrderService(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.AddScoped<IOrderRepository,SqlOrderRepository>();builder.Services.AddScoped<OrderService>();

Controller 只写需要什么,容器自动传入:

publicclassOrderController:ControllerBase{privatereadonlyOrderService_service;publicOrderController(OrderServiceservice){_service=service;}[HttpGet("{id}")]publicOrderDtoGet(intid)=>_service.Get(id);}

请求进来时,容器大致会做:

  1. 要创建OrderController,发现构造函数需要OrderService
  2. 要创建OrderService,发现需要IOrderRepository
  3. 查登记表:IOrderRepository→SqlOrderRepository,按生命周期创建或取出实例
  4. 依次 new 好,把OrderService传给OrderController

你不再手写new OrderService(new SqlOrderRepository())。换实现只改注册一行:

builder.Services.AddScoped<IOrderRepository,InMemoryOrderRepository>();

DI 容器主要干两件事:

  1. 帮你按构造函数把对象拼起来(解析依赖)
  2. 按你登记的生命周期决定:这个类型 new 一次还是每次 new

下面单独说容器怎么知道该 new 谁、怎么 new——不用看源码,知道流程即可。


容器怎么把对象拼出来

登记时:

builder.Services.AddScoped<IOrderRepository,SqlOrderRepository>();builder.Services.AddScoped<OrderService>();

容器内部相当于记下一张表:

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:每次要,每次 new

builder.Services.AddTransient<IOrderRepository,SqlOrderRepository>();

同一请求里,两处注入IOrderRepository:

publicclassA{publicA(IOrderRepositoryr){}}publicclassB{publicB(IOrderRepositoryr){}}

A和B通常情况下会拿到两个不同的SqlOrderRepository实例(每次向容器解析IOrderRepository都会 new)。个别特殊注册方式下行为可能不同,日常开发按Transient = 不要默认共享实例理解即可。

适合:轻、无状态、创建便宜的小对象。

Scoped:一个 Scope 里共用一份

builder.Services.AddScoped<IOrderRepository,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.AddSingleton<ICacheService,MemoryCacheService>();

默认情况:第一次有人向容器解析ICacheService时创建实例,之后在同一个IServiceProvider生命周期内始终返回这一实例,直到应用关闭。

若注册时直接传入实例:

builder.Services.AddSingleton<ICacheService>(newMemoryCacheService());

则登记时就已经创建,不是等到第一次解析。

适合:缓存、配置包装等无 Scope 级状态、且要线程安全的东西。

一张表对照

注册方式简单理解常见例子
Transient每次解析通常 new 新的轻量工具类
Scoped一个 Scope 里同一个(Web 里常等于一次请求)DbContext、Repository
Singleton同一 ServiceProvider 内同一个内存缓存、配置

生命周期不能乱配:Singleton 里不能注入 Scoped

错误示例:

builder.Services.AddSingleton<BadService>();builder.Services.AddScoped<IOrderRepository,SqlOrderRepository>();publicclassBadService{publicBadService(IOrderRepositoryrepo){}// repo 是 Scoped}

BadService只创建一次(Singleton),但构造函数里的IOrderRepository是按请求创建的 Scoped。结果:第一个请求的 DbContext 被 Singleton 一直攥在手里,第二个请求还在用第一个请求的上下文——用户数据串了、EF 报错。

规则记一句就行:活得更长的,不能依赖活得更短的。
Singleton 不能直接构造函数注入 Scoped。

后台任务BackgroundService本身是 Singleton,要用 Scoped 服务时,应注入IServiceScopeFactory,在方法里临时开 Scope:

usingvarscope=_scopeFactory.CreateScope();varrepo=scope.ServiceProvider.GetRequiredService<IOrderRepository>();// 用完 scope 释放,Scoped 对象随 scope 销毁

Web 请求里实际怎么走

一次 API 请求(本质上就是容器为这次请求创建了一个 Scope):

  1. ASP.NET Core 为这次请求开一个Scope
  2. 在这个 Scope 里创建 Controller,并注入它要的 Service、Repository
  3. 执行完 Action
  4. 请求结束,关闭 Scope,该 Scope 内由容器创建并负责释放的Scoped 对象(以及应随 Scope 释放的 Transient)在这里 Dispose——例如DbContext

所以 Web 项目里:DbContext、Repository、业务 Service 多用 Scoped;全应用共享的缓存用 Singleton;确实每次都要新实例的轻量类用 Transient。


补充:HttpClient 不要循环里 new

错误写法:

while(true){usingvarclient=newHttpClient();awaitclient.GetAsync(url);}

看起来using会释放对象,但底层TCP/Socket可能不能及时回收,严重时端口耗尽。正确做法:

builder.Services.AddHttpClient<IWeatherClient,WeatherClient>();

IHttpClientFactory注册为 Singleton 时,管理的重点不是只 new 一个 HttpClient 对象,而是HttpMessageHandler的生命周期和连接复用:WeatherClient类型常按 Transient 注册(每次解析可能是新的包装类),但底层的 Handler 池、Socket 连接由工厂统一复用。这样既避免频繁new HttpClient()的问题,也避免单个 HttpClient 实例长期不刷新 DNS 等隐患。


和 Service Locator 的区别

推荐:构造函数里写清楚要什么(上面 Controller 的写法)。

不推荐:在方法里临时去容器里掏:

varrepo=HttpContext.RequestServices.GetRequiredService<IOrderRepository>();

依赖被藏起来,测试和阅读都变难。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 .NET
  • Service lifetimes
  • Dependency injection in ASP.NET Core

相关新闻

  • Aria2GUI:macOS上最强大的免费下载管理器终极指南
  • 高精度ADC ADS122U04与PIC18F2515的接口设计与应用
  • 3分钟零成本激活Windows和Office:智能脚本让系统管理变得如此简单

最新新闻

  • Git reset 3种模式深度对比:--hard/--mixed/--soft 对工作区与暂存区的影响
  • TCP/IP 五元组深度解析:从 Wireshark 抓包到 5 种网络安全策略实战
  • 去烟味喷雾分场景选购指南:适配衣物应急、车载常备、家居布艺、办公室需求,根源分解烟味更靠谱 - 行业洞察分析师
  • Tiled地图导入Cocos Creator全流程避坑指南:从报错排查到性能优化
  • UE性能优化实战:从CPU/GPU瓶颈定位到Draw Call与内存优化全解析
  • UE5地形系统全解析:从景观雕刻到网格体地形实战指南

日新闻

  • IX9104 PCIe5.0 高速交换芯片@ACP#完整规格 + 应用场景总结
  • Unity游戏集成Coze智能体:实现NPC智能对话与知识库联动
  • SAP EPIC 建行回单查询:从标准类CL_EPIC_EXAMPLE_CN_CCB_GHTD到Z类的5处关键修改

周新闻

  • IX9104 PCIe5.0 高速交换芯片@ACP#完整规格 + 应用场景总结
  • Unity游戏集成Coze智能体:实现NPC智能对话与知识库联动
  • SAP EPIC 建行回单查询:从标准类CL_EPIC_EXAMPLE_CN_CCB_GHTD到Z类的5处关键修改

月新闻

  • 2026年6月公司网站搭建最新热门渠道测评:四大低成本/零代码平台对比+避坑
  • 【Linux】Linux arm 编译QT程序,出现expected “}“报错
  • 【MATLAB例程】四基站二维AOA定位与距离辅助增强对比仿真。基于角度观测和测距修正的固定目标平面定位精度分析

关于尧图

  • 公司简介
  • 团队介绍
  • 企业文化
  • 荣誉资质

服务项目

  • 定制开发
  • 电商建站
  • UI 设计
  • 运维服务

快速链接

  • 案例展示
  • 建站流程
  • 常见问题
  • 资讯中心

联系方式

  • 📍北京市朝阳区互联网产业园 A 座 10 层
  • 📞400-888-8888
  • ✉️contact@rkmt.cn
  • 🕐周一至周日 9:00-21:00

© 2024 北京尧图网络科技有限公司 版权所有 | 京 ICP 备 XXXXXXXX 号