.NET 依赖注入入门

简介: 本文系统讲解.NET依赖注入(DI)从概念到实战:厘清“依赖”本质,详解构造函数/属性/方法三种注入方式,剖析Transient、Scoped、Singleton生命周期选型要点与典型陷阱,并结合SOLID原则、团队协作与单元测试,阐明DI如何真正实现解耦、可测与可维护。

别再写“牵一发而动全身”的代码了:.NET 依赖注入从入门到实战

写在前面

你有没有遇到过这种场景?

一个类里面写了 new DatabaseService(),另一个类里面写了 new Logger()。突然有一天,数据库连接方式要从 MySQL 换成 PostgreSQL,或者日志组件要从 log4net 换成 Serilog。你打开代码一看,几十个文件里都散落着 new 的调用。

改一个,测一个,改到怀疑人生。

这就是典型的“硬编码依赖”问题。而 .NET 的依赖注入(Dependency Injection,简称 DI),正是为了解决这个问题而生的。它不是花哨的“高级技巧”,而是现代 .NET 开发的默认姿势。这篇文章,我们就把 DI 这个东西从头到尾讲清楚。

一、先搞懂“依赖”到底是什么

在写代码时,一个类往往需要另一个类才能完成工作。比如:

public class OrderService
{
   
    private readonly EmailSender _emailSender = new EmailSender();

    public void PlaceOrder(Order order)
    {
   
        // 处理订单...
        _emailSender.Send(order.CustomerEmail, "订单已确认");
    }
}

这里,OrderService 就“依赖”于 EmailSender。它自己 new 了一个 EmailSender,这就是硬编码依赖。

问题出在哪?

第一,无法替换。 如果以后要改成发短信通知,你得改 OrderService 的代码。

第二,无法测试。 写单元测试时,你不想真的发邮件,但你没法把 EmailSender 换成一个假的。

第三,职责不清。 OrderService 本来只管订单逻辑,现在还要负责创建 EmailSender。

依赖注入的核心思路很简单:不要在类内部创建依赖,而是让外部把依赖“送进来” 。用官方的话说,DI 是一种设计模式,用于消除硬编码的依赖关系,让应用程序更易于维护和测试。

二、依赖注入的三种实现方式

在 .NET 中,依赖注入主要有三种实现方式。

2.1 构造函数注入(最推荐)

public class OrderService
{
   
    private readonly IEmailSender _emailSender;

    public OrderService(IEmailSender emailSender)  // 通过构造函数传入
    {
   
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
   
        _emailSender.Send(order.CustomerEmail, "订单已确认");
    }
}

依赖变成了接口 IEmailSender,具体用哪个实现,由外部决定。这是最常用的注入方式——当实例化类的时候,通过给类的构造函数提供依赖项来实现依赖注入,注入的依赖可以在类的任何地方直接使用,适用于类需要一个或多个依赖时。

2.2 属性注入

public class OrderService
{
   
    public IEmailSender EmailSender {
    get; set; }

    public void PlaceOrder(Order order)
    {
   
        EmailSender?.Send(order.CustomerEmail, "订单已确认");
    }
}

适用于类需要可选的依赖时,或者需要可交换的实现时。使用时需要做 null 检查,这种方式不需要增加或修改构造函数。

2.3 方法注入

依赖作为方法参数传入。这种方式注入依赖到单一的方法,该依赖仅仅被注入的方法使用,适用于整个类不需要依赖项、而仅仅某个方法需要的情况。

最佳实践:优先使用构造函数注入。它强制你在创建对象时就明确所有依赖,代码更清晰,也更安全。

三、.NET 内置 DI 容器怎么用

.NET 内置了一个轻量级 DI 容器,位于 Microsoft.Extensions.DependencyInjection 包中。核心概念只有三个:

  • IServiceCollection:定义服务描述符集合的契约,用来注册服务
  • IServiceProvider:定义用于检索服务对象的机制,用来解析服务
  • ServiceDescriptor:描述一个服务的注册信息(服务类型、实现类型、生命周期)

3.1 基本用法

第一步:注册服务

var builder = WebApplication.CreateBuilder(args);

// 注册服务:把接口和实现关联起来
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<IOrderService, OrderService>();

var app = builder.Build();

第二步:解析服务

注册完成后,在控制器或任何由容器管理的类中,直接通过构造函数声明依赖:

[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
   
    private readonly IOrderService _orderService;

    public OrderController(IOrderService orderService)
    {
   
        _orderService = orderService;
    }

    [HttpPost]
    public IActionResult PlaceOrder(Order order)
    {
   
        _orderService.PlaceOrder(order);
        return Ok();
    }
}

框架会自动从容器中找到 IOrderService 的实现并注入进来,你不需要手动 new 任何东西。

四、三种服务生命周期:用错会出大问题

这是 DI 最容易踩坑的地方。每次注册服务时,都必须指定生命周期,它决定了实例什么时候创建、什么时候销毁、被谁共享。

生命周期 注册方法 实例创建时机 共享范围
Transient AddTransient 每次从容器请求都创建新实例 不共享
Scoped AddScoped 每个作用域(如 HTTP 请求)创建一次 同一作用域内共享
Singleton AddSingleton 应用启动时创建一次(或首次使用时) 整个应用共享

4.1 Transient:用完就扔

每次从服务容器请求服务时,都会创建具有暂时性生存期的服务。在处理请求的应用中,在请求结束时会释放暂时服务。此生命周期会导致每次请求的分配,因为每次都会解析和构建服务。适合轻量级、无状态的服务。

4.2 Scoped:一次请求一个

对于 Web 应用程序,作用域生存期是指服务在每个客户端请求(连接)中创建一次。在处理请求的应用程序中,作用域服务将在请求结束时被释放。

数据库上下文(DbContext)默认就是 Scoped,这是最典型的用法。同一个请求内复用同一个 DbContext,才能保证事务一致性,避免实体跟踪混乱。

4.3 Singleton:全局唯一

单例生命周期服务在首次被请求时创建,后续每次请求都使用同一个实例。单例服务必须是线程安全的,并且通常情况下用于无状态服务。由于内存在应用关闭之前不会被释放,因此在使用单例服务时请仔细考虑内存使用。

4.4 选型口诀

  • 跨请求共享、需复用 → Singleton(线程安全要做好)
  • 请求内共享、事务一致性 → Scoped
  • 一次性、无状态、轻量 → Transient

4.5 一个经典错误:Scoped 服务注入到 Singleton 中

// 错误示例:Singleton 中注入 Scoped 服务
builder.Services.AddSingleton<ReportService>();     // 全局唯一
builder.Services.AddScoped<AppDbContext>();          // 请求级

public class ReportService
{
   
    public ReportService(AppDbContext db) {
    }  // 危险!
}

请勿通过构造函数注入或在单一实例中请求 IServiceProvider,直接解析作用域服务。这样做会导致作用域服务表现为单例,进而在处理后续请求时可能导致状态不正确。

重要说明:.NET 在开发环境下会主动检测这种错误并抛出异常。默认情况下,在开发环境中,从具有更长生命周期的服务解析另一服务会抛出异常。这是一个安全网,帮助你尽早发现配置错误。

五、DI 真正解决的问题:多人协作中的解耦

前面讲的是“怎么用”,现在聊“为什么值得用”。特别是在多人维护的项目中,DI 的价值非常具体。

5.1 问题一:层与层之间“粘”在一起

传统三层架构中,业务层直接依赖数据访问层的具体实现,数据访问层又直接依赖具体的数据库驱动。换一个数据库,可能要动半个项目。

用了 DI 之后,业务层只认接口:

public class UserService
{
   
    private readonly IUserRepository _repo;

    public UserService(IUserRepository repo)  // 接口,不是具体实现
    {
   
        _repo = repo;
    }
}

IUserRepository 的实现可以是 EF Core,可以是 Dapper,可以是任何东西。业务层完全不知道底层用什么。换实现时,只需要改 Program.cs 里的一行注册代码。

有实际案例验证了这一点。一项针对 .NET 生产应用的研究显示,采用依赖倒置和低耦合的三层架构(API、Core、Infrastructure)后,数据源切换时间从平均 4.2 天降至 1 天,.NET 版本迁移时间从 1 周降至 1 天,空引用和映射事故从每季度 23 次降至 3 次。该架构遵循依赖倒置原则:基础设施层依赖于应用核心中定义的抽象,而不是业务逻辑依赖于数据库或数据框架。

5.2 问题二:团队成员互相“踩脚”

在一个多人维护的项目里,如果 A 负责的模块和 B 负责的模块通过 new 直接耦合,A 改了自己的类,B 的代码就可能编译不过。沟通成本高,回归风险大。

DI 把“接口”和“实现”分开了。只要接口不变,实现怎么改都不会影响其他模块。团队可以并行开发:先定义接口,大家各自实现,最后在容器里组装。

5.3 问题三:单元测试难写

没有 DI 时,测试 OrderService 会真的去发邮件、连数据库。测试又慢又不可靠。

用了 DI,测试时传入 mock:

[Fact]
public void PlaceOrder_Should_Send_Email()
{
   
    var mockSender = new Mock<IEmailSender>();
    var service = new OrderService(mockSender.Object);

    service.PlaceOrder(new Order {
    CustomerEmail = "test@test.com" });

    mockSender.Verify(s => s.Send("test@test.com", It.IsAny<string>()), Times.Once);
}

DI 让业务逻辑可以在外部资源被轻松地模拟出来,从而提高可测试性。依赖注入让类变得可测试,这是它最被低估的价值之一。

六、DI 与 SOLID 原则的关系

理解 DI 的另一个角度是把它放在 SOLID 原则的框架里看。

DI 是依赖倒置原则(Dependency Inversion Principle,DIP)的一种实现方式。DIP 的核心思想是:

  • 高层模块不应该依赖低层模块,它们都应该依赖于抽象。
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

依赖注入是一种在类及其依赖项之间实现松耦合的技术。不是直接实例化协作者或使用静态引用(即使用 new...),而是将类执行其操作所需的依赖项注入到类中。

DI 通常还引导开发者遵循单一职责原则——如果你通过构造函数使用 DI,可以轻松通过查看构造函数的参数数量来判断。如果注入的依赖太多,这通常是类试图做太多事情的一个信号,可能违反了单一职责原则。这被称为“代码气味”,提示你应该重构。

七、一个容易忽略的细节:服务释放

DI 容器负责清理它创建的类型,并在 IDisposable 实例上调用 Dispose。这是一个容易忽略但非常重要的细节:

  • 瞬时和范围服务在解析范围结束时释放。在处理请求的应用中,这通常是在请求结束时。
  • 单例服务在释放服务容器时释放,通常是在应用程序关闭时。

关键原则:从容器中解析的服务绝对不应由开发者释放。容器根据服务的生存期自动释放服务。如果你手动释放了从容器解析的服务,可能会导致双重释放或访问已释放对象的问题。

八、一句话总结

依赖注入的核心思想就一句话:不要在类内部创建依赖,让外部送进来。

它的好处可以归纳为三点:

  1. 解耦:接口和实现分离,换实现不改调用方
  2. 可测试:依赖可以替换为 mock,单元测试变得简单
  3. 可维护:多人协作时,模块之间互不干扰,各自独立演进

在 .NET 中,DI 已经是“一等公民”——框架内置、模板默认集成。它不是可选项,而是现代 .NET 开发的默认姿势。

如果你还在手写 new 来创建依赖,不妨从下一个新模块开始,试着用 DI 改造一下。你可能会发现,代码变“松”了,反而更“稳”了。

相关文章
|
14天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
8067 15
|
13天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
2076 12
|
12天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
1798 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
11天前
|
人工智能 编解码 并行计算
MiniMax-H3 一键整合包技术文档:8G 显存运行 AI 漫剧制作 —— 角色替换 / 动作迁移 / 文图生视频部署与调参指南
MiniMax H3 是 MiniMax 开源的全模态视频生成模型,支持文/图/音/视多条件输入,输出最高2K、15秒带双声道音频视频。本文档详述其Int8量化版在8GB显存下的本地一键部署、三段式工作流(EDIT/REPLACE/CONTINUE)、参数调优及常见问题排查。(239字)
|
7天前
|
人工智能 Linux 开发者
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
Codex是OpenAI推出的AI编程智能体,可读取本地项目、理解需求并自动修改代码。支持桌面GUI、命令行(CLI)及VS Code/Cursor插件三种形态,覆盖可视化操作、终端高效开发与编辑器无缝集成场景,助开发者用自然语言驱动编码全流程。(239字)
【2026国内使用】Codex安装过程一篇讲透(Win/Mac/Linux全支持)
|
26天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
3843 10
|
20天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
2187 1

热门文章

最新文章