依赖注入(Dependency Injection,简称DI)并不是.NET独有的概念,它是实现控制反转(IoC)的一种具体手段。简单来说,当一个类需要使用另一个对象时,不再自己new出来,而是由外部容器创建好之后“注入”进来。ASP.NET Core从设计之初就把DI作为一等公民,框架内部的路由、日志、配置、数据库上下文等几乎全部通过容器管理。理解DI的工作原理并掌握正确的配置方式,是写出高质量.NET应用的基础。

一、依赖注入的核心原理:为什么要解耦
先看一段典型的紧耦合代码。假设有一个订单处理服务,直接在内部实例化发邮件的组件:
public class OrderService
{
private readonly EmailSender _emailSender;
public OrderService()
{
// 直接new,类与具体实现强绑定
_emailSender = new EmailSender();
}
public void PlaceOrder()
{
_emailSender.Send("订单已创建");
}
}这种写法的问题很明显:OrderService依赖的是具体的EmailSender类而不是抽象,一旦需求变化(比如换成短信通知),就必须修改OrderService的源码;同时单元测试时无法替换成假实现,只能被迫连真实邮件服务器一起测。这就是所谓的“依赖具体实现”,是代码走向僵硬、脆弱的根源。
依赖注入的解法分两步:第一,把依赖提取为接口,让类依赖抽象;第二,通过构造函数把依赖“传进来”,而不是自己创建。改造后的代码如下:
public interface INotificationService
{
void Send(string message);
}
public class EmailSender : INotificationService
{
public void Send(string message)
{
Console.WriteLine("发送邮件:" + message);
}
}
// 依赖抽象,且由外部传入
public class OrderService
{
private readonly INotificationService _notification;
public OrderService(INotificationService notification)
{
_notification = notification;
}
public void PlaceOrder()
{
_notification.Send("订单已创建");
}
}控制权的转移正是“控制反转”一词的由来:对象的创建和装配从类的内部转移到了外部容器。DI容器本质上是一个“类型注册表 + 对象工厂”,你告诉它“当有人请求INotificationService时,请给我一个EmailSender实例”,剩下的创建、注入、销毁工作全部由容器代劳。
二、三种服务生命周期:Transient、Scoped与Singleton
在ASP.NET Core中注册服务时,必须指定生命周期,这决定了容器如何管理和复用对象实例。三种生命周期对应三个扩展方法:AddTransient、AddScoped和AddSingleton。
Transient(瞬时):每次请求服务时都创建一个全新实例。适合轻量、无状态的服务,比如工具类、验证器。缺点是频繁创建对象会带来少量性能开销。
Scoped(作用域):在同一个作用域内共享同一个实例,作用域通常对应一次HTTP请求。最典型的例子是EF Core的DbContext:一次请求内多个组件可以共享同一个上下文,请求结束后实例被释放。
Singleton(单例):整个应用生命周期内只有一个实例,所有请求共享。适合全局配置、缓存服务等,但必须保证线程安全,并且不能把Scoped服务注入到Singleton中(否则会造成Captured Dependency问题)。
三者的对比可以总结为下表:
| 生命周期 | 实例创建时机 | 典型场景 | 注意事项 |
|---|---|---|---|
| Transient | 每次注入都新建 | 无状态轻量服务 | 高频调用有GC压力 |
| Scoped | 每个HTTP请求一个 | DbContext、用户上下文 | 不能被Singleton引用 |
| Singleton | 应用启动后全局唯一 | 配置、内存缓存 | 必须线程安全,慎用可变状态 |
选错生命周期的后果往往不是编译错误,而是隐蔽的运行时问题。例如把DbContext注册成Singleton,多个请求并发使用同一个上下文会导致数据混乱甚至崩溃;把有状态的服务注册成Transient则可能造成状态不共享的逻辑错误。原则很简单:默认用Transient,涉及数据库或请求级状态用Scoped,全局共享且线程安全的服务才用Singleton。
三、在ASP.NET Core中完成完整配置
以.NET 6及以上的最小托管模型为例,所有服务注册都集中在Program.cs中完成。下面是一个完整的配置示例,包含接口定义、多个实现、以及控制器的构造函数注入:
var builder = WebApplication.CreateBuilder(args); // 向容器注册服务 builder.Services.AddTransient<INotificationService, EmailSender>(); builder.Services.AddScoped<IOrderRepository, OrderRepository>(); builder.Services.AddSingleton<ICacheService, MemoryCacheService>(); // 注册控制器相关服务 builder.Services.AddControllers(); var app = builder.Build(); app.MapControllers(); app.Run();
注册完成后,在控制器中直接通过构造函数声明依赖即可,框架会自动解析并传入:
[ApiController]
[Route("api/[controller]")]
public class OrderController : ControllerBase
{
private readonly INotificationService _notification;
private readonly IOrderRepository _repository;
// 构造函数注入,容器自动装配
public OrderController(
INotificationService notification,
IOrderRepository repository)
{
_notification = notification;
_repository = repository;
}
[HttpPost]
public IActionResult Create()
{
_repository.Save();
_notification.Send("订单创建成功");
return Ok();
}
}当同一个接口有多个实现时,可以使用工厂委托来决定返回哪一个,注册方式也很灵活:
// 工厂注册,根据条件返回不同实现
builder.Services.AddTransient<INotificationService>(sp =>
{
var config = sp.GetRequiredService<IConfiguration>();
return config["Notify:Channel"] == "Sms"
? new SmsSender()
: new EmailSender();
});
// 一个接口注册多个实现,通过IEnumerable获取全部
builder.Services.AddTransient<INotificationService, EmailSender>();
builder.Services.AddTransient<INotificationService, SmsSender>();
// 构造函数中注入 IEnumerable<INotificationService> 可拿到两个实例
// TryAdd系列:只有未注册过才注册,常用于类库避免覆盖宿主配置
builder.Services.TryAddTransient<INotificationService, EmailSender>();如果项目规模变大,建议把注册逻辑按业务域拆分成扩展方法,例如编写一个静态类OrderServiceCollectionExtensions,内部提供AddOrderModule方法,让Program.cs保持整洁。这也是官方推荐的做法:一个模块一个扩展方法,职责清晰且易于测试。
四、常见坑点与最佳实践
第一个高频问题是循环依赖。A的构造函数需要B,B的构造函数又需要A,容器在解析时会直接抛出循环依赖异常。解决办法通常是重构设计,把公共逻辑提取到第三个服务C中,让A和B都依赖C,或者把其中一个依赖改为方法内解析(通过IServiceProvider获取),但后者只是权宜之计。
第二个坑是Captured Dependency:在Singleton服务中注入了Scoped服务。此时Scoped服务会被Singleton长期持有,实际上变成了“错误的单例”,DbContext场景下就会出现并发访问异常。正确做法是在Singleton中注入IServiceScopeFactory,需要时手动创建作用域:
public class BackgroundWorker : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task StartAsync(CancellationToken token)
{
// 手动创建作用域,安全地使用Scoped服务
using (var scope = _scopeFactory.CreateScope())
{
var repo = scope.ServiceProvider
.GetRequiredService<IOrderRepository>();
await repo.CleanExpiredAsync();
}
}
public Task StopAsync(CancellationToken token) => Task.CompletedTask;
}第三点建议是始终依赖接口而非实现,并优先使用构造函数注入而不是Service Locator模式(到处注入IServiceProvider手动GetService)。构造函数注入的好处是依赖关系一目了然,缺依赖会在启动时就暴露,而Service Locator把依赖藏在了方法体内部,难以测试也难以发现。
最后,善用框架提供的服务替换能力:例如通过实现ITransientDisposable约定自动注册所有瞬时可释放服务,或使用Scrutor等第三方库实现程序集批量扫描注册,都能显著减少手写注册代码的工作量。掌握这些实践后,DI不仅能解耦代码,还会成为你做单元测试和模块化架构时最得力的工具。
依赖注入ASP.NET Core.NET修改时间:2026-08-31 16:03:10