如何理解 C#/.NET 的依赖注入与生命周期
从问题引入 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 ◀── 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){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);}请求进来时,容器大致会做:
- 要创建
OrderController,发现构造函数需要OrderService - 要创建
OrderService,发现需要IOrderRepository - 查登记表:
IOrderRepository→SqlOrderRepository,按生命周期创建或取出实例 - 依次 new 好,把
OrderService传给OrderController
你不再手写new OrderService(new SqlOrderRepository())。换实现只改注册一行:
builder.Services.AddScoped<IOrderRepository,InMemoryOrderRepository>();DI 容器主要干两件事:
- 帮你按构造函数把对象拼起来(解析依赖)
- 按你登记的生命周期决定:这个类型 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):
- ASP.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){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,但创建代码散落 |
| 3 | IServiceCollection登记 + 容器注入 | 创建集中、可换实现、可管生命周期 |
依赖注入(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