别再写"牵一发而动全身"的代码了:.NET 依赖注入从入门到实战
写在前面
你有没有遇到过这种场景?
一个类里面写了 new DatabaseService(),另一个类里面写了 new Logger()。突然有一天,数据库连接方式要从 MySQL 换成 PostgreSQL,或者日志组件要从 log4net 换成 Serilog。你打开代码一看,几十个文件里都散落着 new 的调用。
改一个,测一个,改到怀疑人生。
这就是典型的"硬编码依赖"问题。而 .NET 的依赖注入(Dependency Injection,简称 DI),正是为了解决这个问题而生的。它不是花哨的"高级技巧",而是现代 .NET 开发的默认姿势。这篇文章,我们就把 DI 这个东西从头到尾讲清楚。
一、先搞懂"依赖"到底是什么
在写代码时,一个类往往需要另一个类才能完成工作。比如:
csharp
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 构造函数注入(最推荐)
csharp
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 属性注入
csharp
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 基本用法
第一步:注册服务
csharp
var builder = WebApplication.CreateBuilder(args);
// 注册服务:把接口和实现关联起来
builder.Services.AddScoped<IEmailSender, SmtpEmailSender>();
builder.Services.AddTransient<IOrderService, OrderService>();
var app = builder.Build();
第二步:解析服务
注册完成后,在控制器或任何由容器管理的类中,直接通过构造函数声明依赖:
csharp
[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 中
csharp
// 错误示例:Singleton 中注入 Scoped 服务
builder.Services.AddSingleton<ReportService>(); // 全局唯一
builder.Services.AddScoped<AppDbContext>(); // 请求级
public class ReportService
{
public ReportService(AppDbContext db) { } // 危险!
}
请勿通过构造函数注入或在单一实例中请求 IServiceProvider,直接解析作用域服务。这样做会导致作用域服务表现为单例,进而在处理后续请求时可能导致状态不正确。
重要说明:.NET 在开发环境下会主动检测这种错误并抛出异常。默认情况下,在开发环境中,从具有更长生命周期的服务解析另一服务会抛出异常。这是一个安全网,帮助你尽早发现配置错误。
五、DI 真正解决的问题:多人协作中的解耦
前面讲的是"怎么用",现在聊"为什么值得用"。特别是在多人维护的项目中,DI 的价值非常具体。
5.1 问题一:层与层之间"粘"在一起
传统三层架构中,业务层直接依赖数据访问层的具体实现,数据访问层又直接依赖具体的数据库驱动。换一个数据库,可能要动半个项目。
用了 DI 之后,业务层只认接口:
csharp
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:
csharp
[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。这是一个容易忽略但非常重要的细节:
- 瞬时和范围服务在解析范围结束时释放。在处理请求的应用中,这通常是在请求结束时。
- 单例服务在释放服务容器时释放,通常是在应用程序关闭时。
关键原则:从容器中解析的服务绝对不应由开发者释放。容器根据服务的生存期自动释放服务。如果你手动释放了从容器解析的服务,可能会导致双重释放或访问已释放对象的问题。
八、一句话总结
依赖注入的核心思想就一句话:不要在类内部创建依赖,让外部送进来。
它的好处可以归纳为三点:
- 解耦:接口和实现分离,换实现不改调用方
- 可测试:依赖可以替换为 mock,单元测试变得简单
- 可维护:多人协作时,模块之间互不干扰,各自独立演进
在 .NET 中,DI 已经是"一等公民"------框架内置、模板默认集成。它不是可选项,而是现代 .NET 开发的默认姿势。
如果你还在手写 new 来创建依赖,不妨从下一个新模块开始,试着用 DI 改造一下。你可能会发现,代码变"松"了,反而更"稳"了。