从问题引入 DI 解决方案:先看几段能跑但会越写越乱的代码,再讲依赖怎么从外部传入、容器如何自动组装,以及 Singleton / Scoped / Transient 三种生命周期。
第一个问题:实现写死了,想换实现就要改业务代码
一开始订单服务直接 new 仓储,能跑:
csharp
public class OrderService
{
public OrderDto Get(int id)
{
var repo = new SqlOrderRepository();
return repo.Find(id);
}
}
产品说:测试环境别连真库,换成内存版。你只能改 OrderService 里的实现类:
csharp
public OrderDto Get(int id)
{
var repo = new InMemoryOrderRepository(); // 改这里
return repo.Find(id);
}
问题 :业务类 OrderService 和具体仓储 SqlOrderRepository 绑死了。换实现 = 改业务代码。依赖越多,改的地方越多。
第一步改进 :不要自己在内部 new,让外面传进来 (这叫构造函数注入,还没用到 DI 容器):
csharp
public interface IOrderRepository
{
OrderDto Find(int id);
}
public class OrderService
{
private readonly IOrderRepository _repo;
public OrderService(IOrderRepository repo)
{
_repo = repo;
}
public OrderDto Get(int id) => _repo.Find(id);
}
生产环境手动组装:
csharp
IOrderRepository repo = new SqlOrderRepository();
var service = new OrderService(repo);
测试环境:
csharp
IOrderRepository repo = new InMemoryOrderRepository();
var service = new OrderService(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 容器只是把这段组装自动化。
第二个问题:单元测试很难写
还是最开始的写法:
csharp
public class OrderService
{
public OrderDto Get(int id)
{
var repo = new SqlOrderRepository(); // 写死了
return repo.Find(id);
}
}
测试想验证查到订单就返回 DTO,但一跑测试就会去连真实数据库,慢、不稳定,CI 上可能没有库。
用上面的构造函数注入后:
csharp
[Test]
public void Get_returns_order()
{
var fakeRepo = new FakeOrderRepository(); // 测试专用,返回固定数据
var service = new OrderService(fakeRepo);
var dto = service.Get(1);
Assert.Equal("测试订单", dto.Name);
}
DI 要解决的第二件事 :测试里能轻松换成 Fake/Mock,不用改被测类的源码。
第三个问题:一层 new 一层,创建代码散落各处
Controller 也要用 OrderService,若每层自己 new:
csharp
public class OrderController : ControllerBase
{
[HttpGet("{id}")]
public OrderDto Get(int id)
{
var repo = new SqlOrderRepository();
var service = new OrderService(repo);
return service.Get(id);
}
}
仓储若还要连接串、日志,会变成:
csharp
var conn = new SqlConnection("Server=...");
var logger = new ConsoleLogger();
var repo = new SqlOrderRepository(conn, logger);
var service = new OrderService(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 里登记(注册):
csharp
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
builder.Services.AddScoped<OrderService>();
Controller 只写需要什么,容器自动传入:
csharp
public class OrderController : ControllerBase
{
private readonly OrderService _service;
public OrderController(OrderService service)
{
_service = service;
}
[HttpGet("{id}")]
public OrderDto Get(int id) => _service.Get(id);
}
请求进来时,容器大致会做:
- 要创建
OrderController,发现构造函数需要OrderService - 要创建
OrderService,发现需要IOrderRepository - 查登记表:
IOrderRepository→SqlOrderRepository,按生命周期创建或取出实例 - 依次 new 好,把
OrderService传给OrderController
你不再手写 new OrderService(new SqlOrderRepository())。换实现只改注册一行:
csharp
builder.Services.AddScoped<IOrderRepository, InMemoryOrderRepository>();
DI 容器主要干两件事:
- 帮你按构造函数把对象拼起来(解析依赖)
- 按你登记的生命周期决定:这个类型 new 一次还是每次 new
下面单独说容器怎么知道该 new 谁、怎么 new------不用看源码,知道流程即可。
容器怎么把对象拼出来
登记时:
csharp
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
csharp
builder.Services.AddTransient<IOrderRepository, SqlOrderRepository>();
同一请求里,两处注入 IOrderRepository:
csharp
public class A { public A(IOrderRepository r) { } }
public class B { public B(IOrderRepository r) { } }
A 和 B 通常情况下 会拿到 两个不同的 SqlOrderRepository 实例(每次向容器解析 IOrderRepository 都会 new)。个别特殊注册方式下行为可能不同,日常开发按Transient = 不要默认共享实例理解即可。
适合:轻、无状态、创建便宜的小对象。
Scoped:一个 Scope 里共用一份
csharp
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)共用一份
csharp
builder.Services.AddSingleton<ICacheService, MemoryCacheService>();
默认情况 :第一次有人向容器解析 ICacheService 时创建实例,之后在 同一个 IServiceProvider 生命周期内 始终返回这一实例,直到应用关闭。
若注册时直接传入实例:
csharp
builder.Services.AddSingleton<ICacheService>(new MemoryCacheService());
则 登记时就已经创建,不是等到第一次解析。
适合:缓存、配置包装等 无 Scope 级状态、且要线程安全 的东西。
一张表对照
| 注册方式 | 简单理解 | 常见例子 |
|---|---|---|
| Transient | 每次解析通常 new 新的 | 轻量工具类 |
| Scoped | 一个 Scope 里同一个(Web 里常等于一次请求) | DbContext、Repository |
| Singleton | 同一 ServiceProvider 内同一个 | 内存缓存、配置 |
生命周期不能乱配:Singleton 里不能注入 Scoped
错误示例:
csharp
builder.Services.AddSingleton<BadService>();
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
public class BadService
{
public BadService(IOrderRepository repo) { } // repo 是 Scoped
}
BadService 只创建 一次 (Singleton),但构造函数里的 IOrderRepository 是 按请求 创建的 Scoped。结果:第一个请求的 DbContext 被 Singleton 一直攥在手里,第二个请求还在用第一个请求的上下文------用户数据串了、EF 报错。
规则记一句就行:活得更长的,不能依赖活得更短的。
Singleton 不能直接构造函数注入 Scoped。
后台任务 BackgroundService 本身是 Singleton,要用 Scoped 服务时,应注入 IServiceScopeFactory,在方法里临时开 Scope:
csharp
using var scope = _scopeFactory.CreateScope();
var repo = 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
错误写法:
csharp
while (true)
{
using var client = new HttpClient();
await client.GetAsync(url);
}
看起来 using 会释放对象,但底层 TCP/Socket 可能不能及时回收,严重时 端口耗尽。正确做法:
csharp
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>();
IHttpClientFactory 注册为 Singleton 时,管理的重点不是只 new 一个 HttpClient 对象,而是 HttpMessageHandler 的生命周期和连接复用 :WeatherClient 类型常按 Transient 注册(每次解析可能是新的包装类),但底层的 Handler 池、Socket 连接由工厂统一复用。这样既避免频繁 new HttpClient() 的问题,也避免单个 HttpClient 实例长期不刷新 DNS 等隐患。
和 Service Locator 的区别
推荐:构造函数里写清楚要什么(上面 Controller 的写法)。
不推荐:在方法里临时去容器里掏:
csharp
var repo = 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,就不会只是死记名字。