在 C# 里谈依赖注入,最常落地的形式就是基于接口的注入:类不再直接创建具体依赖对象,而是通过构造函数接收一个接口,由容器在运行时把合适的实现装配进去。这样做的好处不只是少写几个 new,更关键的是让高层模块与低层实现彻底分离。比如业务层只认识 IUserRepository,至于背后是 SQL Server、MySQL 还是内存集合,完全由注册配置决定。替换实现时,调用方代码可以保持不变。

要想真正用好基于接口的注入,需要解决两个问题:接口和实现如何注册;容器如何根据构造函数解析并注入。微软在 Microsoft.Extensions.DependencyInjection 中提供了一套轻量级容器,控制台、ASP.NET Core、Worker Service 都可以直接使用。下面通过一个完整的控制台示例把流程串起来。
一、为什么优先选择基于接口的注入
依赖注入本身有三种常见形式:构造函数注入、属性注入和方法注入。在 C# 中,构造函数注入配合接口几乎是默认选择。原因在于构造函数可以明确声明一个类正常工作所必需的全部依赖,创建对象时如果缺少某个接口实现,容器会直接报错,而不是运行到一半才出现空引用。属性注入看似灵活,但容易隐藏必需依赖,测试时也常忘记赋值。
基于接口的注入还有利于单元测试。测试代码可以传入 Mock 或 Stub 实现,不需要准备真实数据库或外部服务。例如 IUserService 的测试替身只需要返回固定用户数据,测试目标类完全不受影响。这种替换能力来自接口的抽象,而不是来自容器本身。容器只是把这种能力自动化了。
另外,使用接口还能让代码更容易适应变化。当业务规则变化时,新增一个实现并修改注册即可;如果直接依赖具体类,哪怕只是换一个构造函数参数,都可能引发连锁修改。因此,接口注入并不是形式上的约束,而是降低耦合、提升可测试性的关键手段。
二、用内置容器完成注册与解析
以控制台程序为例,先创建一个接口 INotificationService 和两个实现:EmailNotificationService、SmsNotificationService。接口只定义发送通知的行为,业务类 OrderService 通过构造函数接收该接口。具体代码如下:
public interface INotificationService
{
void Send(string message);
}
public class EmailNotificationService : INotificationService
{
public void Send(string message)
{
Console.WriteLine($"邮件发送:{message}");
}
}
public class SmsNotificationService : INotificationService
{
public void Send(string message)
{
Console.WriteLine($"短信发送:{message}");
}
}
public class OrderService
{
private readonly INotificationService _notification;
public OrderService(INotificationService notification)
{
_notification = notification;
}
public void CompleteOrder()
{
_notification.Send("订单已完成");
}
}
接下来在 Main 方法中创建 ServiceCollection,注册接口与实现,再通过 BuildServiceProvider 创建容器并解析 OrderService。注册时可以使用泛型扩展方法,也可以用 AddSingleton、AddScoped、AddTransient 指定生命周期。
using Microsoft.Extensions.DependencyInjection; var services = new ServiceCollection(); services.AddTransient<INotificationService, EmailNotificationService>(); services.AddTransient<OrderService>(); ServiceProvider provider = services.BuildServiceProvider(); OrderService orderService = provider.GetRequiredService<OrderService>(); orderService.CompleteOrder();
这段代码里,容器先根据注册信息构造 EmailNotificationService,再把它作为参数传给 OrderService 的构造函数,整个过程自动完成。如果以后想切换到短信通知,只需要把注册改成 SmsNotificationService,OrderService 不需要任何改动。这就是基于接口注入最直接的收益。
需要特别说明的是,ServiceProvider 一旦创建,注册表就固定下来,不应再修改。动态添加服务的需求可以通过工厂委托或配置源解决,不建议反复修改容器。另外,解析服务时优先使用 GetRequiredService,它能在缺失依赖时快速失败,避免用 GetService 得到 null 后再手动判断。
三、多实现场景与生命周期选择
实际项目中,同一个接口可能对应多个实现,比如通知服务同时支持邮件、短信和推送。容器支持把多个实现注册到同一接口下,解析时用 IEnumerable<INotificationService> 获取全部实现。注册方式如下:
services.AddTransient<INotificationService, EmailNotificationService>(); services.AddTransient<INotificationService, SmsNotificationService>(); services.AddTransient<INotificationService, PushNotificationService>();
业务代码可以通过构造函数注入 IEnumerable<INotificationService>,然后遍历发送:
public class NotificationManager
{
private readonly IEnumerable<INotificationService> _services;
public NotificationManager(IEnumerable<INotificationService> services)
{
_services = services;
}
public void NotifyAll(string message)
{
foreach (var service in _services)
{
service.Send(message);
}
}
}
这种做法适合责任链或广播通知,但要注意解析顺序。内置容器默认按注册顺序返回实现,如果顺序影响业务结果,需要显式设计。.NET 8 开始还提供了键控服务,可以通过 [FromKeyedServices] 或 AddKeyedTransient 指定 key 来解析特定实现,避免遍历全部。
生命周期也是接口注入里容易出问题的地方。Transient 每次解析创建新实例;Scoped 在一个作用域内复用实例,适合 Web 请求;Singleton 全局只有一个实例。接口注册的生命周期会影响依赖它的服务。比如一个 Singleton 服务不能直接依赖 Scoped 服务,否则后者会被提升为单例,造成数据串用。可以通过表格快速对比:
| 生命周期 | 创建时机 | 典型使用场景 |
|---|---|---|
| Transient | 每次解析 | 轻量级无状态服务 |
| Scoped | 每个作用域一次 | 数据库上下文、请求级服务 |
| Singleton | 整个容器一次 | 配置读取、缓存、日志服务 |
如果发现某个 Singleton 依赖了 Scoped 服务,启动时不一定报错,但运行一段时间可能出现数据混用问题。排查时可以先检查注册生命周期,再检查构造函数依赖。将依赖改为可传递的作用域或调整生命周期,通常能解决问题。
四、避免服务定位器与接口设计注意事项
基于接口注入虽然好用,但也要避免走向另一个极端:把 IServiceProvider 注入到业务类中,然后在方法里手动 GetService。这种做法叫服务定位器模式,会隐藏类真实依赖,单元测试时还需要模拟容器。更好的做法是让构造函数明确列出所有接口依赖,保持依赖可见。
接口设计上也要克制。不要为一个具体类机械地提取超大型接口,而是按照调用方的需求定义小型接口。比如订单模块需要保存数据,可以定义 IOrderSaver,而不是把所有数据访问方法塞进 IRepository。这符合接口隔离原则,也能减少容器解析时的复杂度。
public interface IOrderSaver
{
void Save(Order order);
}
public interface IOrderReader
{
Order? Get(int id);
}
如果你正在维护一个既有项目,不必一次性把所有依赖改成接口注入。可以从变化最频繁、测试需求最强的模块开始,逐步引入容器。即使只在少数关键服务中实现基于接口注入,也能明显降低耦合度,让后续修改和测试变得容易很多。