为什么需要依赖注入
写代码时,一个类往往要用到另一个类:订单服务要调用仓储,仓储要连数据库。如果在类内部直接 new 出它依赖的对象,类与实现就死死绑在一起——想换一个仓储做测试,几乎无从下手。依赖注入(Dependency Injection,DI)的核心思路是:对象不自己创建依赖,而是由外部把依赖「递」进来。
依赖抽象,而不是依赖具体实现;这是 DI 最重要的一句话。
好处是显而易见的:换实现只要换一个注册,测试时传入假对象即可,业务代码一行不用改。.NET 从底层就内置了 DI 容器,不需要第三方库就能用。
- 解耦依赖面向接口,替换实现不影响使用方。
- 可测单元测试时注入伪造依赖,脱离真实数据库。
- 统一管理对象的创建与生命周期交给容器集中处理。
DI 容器的注册与生命周期
使用 DI 前要先「注册」:告诉容器「当有人要 IOrderRepository 时,给它 OrderRepository」。.NET 内置容器提供三种生命周期,选错是最常见的 bug 来源。
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddSingleton<IClock, SystemClock>();
services.AddTransient<INotification, SmsNotification>();
- Singleton整个应用一个实例,适合无状态的工具。
- Scoped每一次请求一个实例,Web 里最常用。
- Transient每次取都新建,适合轻量无状态对象。
最容易踩的坑是「把 Scoped 服务注入 Singleton」。单例对象在应用启动时就创建,它捕获的 Scoped 依赖会一直活着,造成数据错乱。记住一条规则:依赖的生命周期不能比被依赖的更长。
模块化:把功能切成独立单元
项目一大,所有服务都堆在一个容器里,很快就分不清谁是谁。模块化架构的思路是:按业务功能把应用切成独立模块,比如订单模块、用户模块、支付模块。每个模块自己负责注册自己的服务、定义自己的接口。
public class OrderModule {
public void Load(IServiceCollection services) {
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IOrderService, OrderService>();
}
}
这样做的好处是:想删一个功能,只需删掉对应模块的调用;想抽成独立服务,模块边界天然就是拆分线。模块内部的实现对外不可见,只暴露少数接口。
模块之间如何通信
模块之间不能随便互相调用内部实现,否则又回到紧耦合。推荐两种方式:一是通过共享接口,订单模块依赖一个抽象的「支付接口」,具体支付模块去实现;二是用事件——订单创建后发布一个事件,其他模块各自订阅处理,彼此完全不认识。
mediator.Publish(new OrderCreatedEvent(orderId));
// 用户模块、库存模块各自订阅,互不干扰
这种方式让新增一个下游动作变得很轻松:比如以后要加「下单后积分加一」,只要新增一个订阅者,订单模块一行不用改。
从注册到组装的完整示例
把上面的思路拼起来:程序启动时,先创建容器,再逐个调用各模块的 Load 方法完成注册,最后由容器在需要时自动把依赖注入进来。
var services = new ServiceCollection();
new OrderModule().Load(services);
new UserModule().Load(services);
var provider = services.BuildServiceProvider();
using (var scope = provider.CreateScope()) {
var orderService = scope.ServiceProvider
.GetRequiredService<IOrderService>();
await orderService.PlaceAsync(productId, qty);
}
注意要从「Scope 范围」里取服务,而不是直接从根容器取——这样 Scoped 服务的生命周期才正确。业务代码拿到的 orderService 已经装配好了它需要的仓储、时钟、通知等全部依赖。
常见反模式与排坑
第一个反模式是「服务定位器」:在代码里到处用 ServiceProvider 去 GetService。这等于把 DI 又退回了硬编码,依赖变得隐式、难以追踪。正确做法是通过构造函数声明依赖,让容器在创建时自动注入。
第二个反模式是「构造函数注入一堆依赖」。如果一个类的构造函数要接五六个接口,说明它的职责太多,该拆分了。第三个坑是在单例里持有可变状态,多线程下竞态频繁出现;单例里放无状态服务最安全。
// 不好:隐式取依赖,难测试
var repo = provider.GetRequiredService<IOrderRepository>();
// 好:构造函数声明,依赖显式
public OrderService(IOrderRepository repo) { _repo = repo; }
小结与延伸阅读
依赖注入解决的是「对象怎么来」,模块化解决的是「代码怎么分」。两者配合,能让一个项目从能跑、到好改、再到能拆。核心就一句话:依赖抽象、模块自治、显式装配。
- 异步与并发怎么写,看《C# LINQ 与异步编程实战》。
- 后台服务怎么驻留,读《Windows 服务与系统编程实战》。
- 语言底子回顾,看《C# 语言核心:.NET 开发基石》。