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

从问题引入 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.csIServiceCollection 登记接口与实现、生命周期
  • 运行时 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);
}

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

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

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

csharp 复制代码
builder.Services.AddScoped<IOrderRepository, InMemoryOrderRepository>();

DI 容器主要干两件事

  1. 帮你按构造函数把对象拼起来(解析依赖)
  2. 按你登记的生命周期决定:这个类型 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) { } }

AB 通常情况下 会拿到 两个不同的 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):

  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

错误写法:

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,就不会只是死记名字。


参考

相关推荐
LibraJM25 分钟前
一种适合程序员的 Agent 协作方式的实践
c#·copilot·agents
旋律翼25 小时前
Qt Bridges for C# 深度技术解析
开发语言·qt·c#
吴可可1236 小时前
判断多段线方向的鞋带公式法
c#
qq_454245036 小时前
BasicMethod.Map 设计框架:8 个并行基础模块的数学根基与实现
数据结构·算法·c#
天天代码码天天7 小时前
工控机还是 Win7?我把 PP-OCRv6 做成了 14MB 纯 CPU DLL,.NET 3.5 也能直接调用
.net·win7·pp-ocrv6
Byron Loong7 小时前
【c#】Bitmap释放之 大象与蚊子
开发语言·c#
找死的豆腐7 小时前
基于.NET的Windows窗体编程之WinForms图像控件
windows·.net
临风细雨8 小时前
从 Bun 的 Rust 重写,看 C# 如何重建 AI 基础设施层
人工智能·rust·c#
吴可可1239 小时前
C# CAD二次开发自定义实体实现
c#
z落落10 小时前
C# WinForm 线程池与事件等待+进度条暂停恢复实战案例
开发语言·c#