C#_依赖注入(DI)
4.1 DI的核心概念与价值(解耦、可测试性)
在深入代码之前,我们必须先理解“为什么”。依赖注入解决了两个最基本的问题:耦合和可测试性。
4.1.1 问题:紧耦合的弊端
让我们回顾一下没有DI的“传统”代码:
public class OrderService {// OrderService tightly coupled to SqlOrderRepositoryprivate readonly SqlOrderRepository _repository = new SqlOrderRepository();public void ProcessOrder(Order order) {// ... business logic ..._repository.Save(order);}
}public class SqlOrderRepository {public void Save(Order order) {// Directly using SqlConnection - coupled to SQL Server!using (var connection = new SqlConnection("HardCodedConnectionString")){// ... save order ...}}
}
这段代码存在多个耦合问题:
OrderService
耦合于SqlOrderRepository
:它直接实例化了一个具体的实现。如果想换成FileOrderRepository
或MockOrderRepository
,必须修改OrderService
的源代码。SqlOrderRepository
耦合于SQL Server:它直接使用了SqlConnection
和硬编码的连接字符串。- 难以测试:如何对
ProcessOrder
方法进行单元测试?测试会不可避免地连接到真实的SQL Server数据库,这变成了一个慢速、脆弱、需要配置的集成测试。你无法将一个“Mock”仓库注入进去。
这种代码被形象地称为“粘合剂代码”(glue code)——组件自己创建和管理它所依赖的对象,导致它们无法被拆开。
4.1.2 解决方案:依赖注入
依赖注入通过一种称为“控制反转(IoC)”的机制来解决这个问题。控制反转是一个更广泛的原则,指将程序流程的控制权从应用代码转移给一个外部框架或容器(在这是DI容器)。依赖注入是实现IoC的一种特定技术。
DI的核心思想非常简单:一个类不应该自己创建它的依赖项;相反,它的依赖项应该被“注入”到它里面。
重构后的代码:
// 1. 依赖于抽象
public class OrderService {private readonly IOrderRepository _repository;// 2. 依赖通过构造函数“注入”public OrderService(IOrderRepository repository) {_repository = repository; // Assignment is done by an external component}public void ProcessOrder(Order order) {// ... business logic ..._repository.Save(order);}
}// 3. 抽象接口
public interface IOrderRepository {void Save(Order order);
}// 4. 具体实现
public class SqlOrderRepository : IOrderRepository {private readonly string _connectionString;// 5. 它的依赖(连接字符串)也可以被注入!public SqlOrderRepository(string connectionString) {_connectionString = connectionString;}public void Save(Order order) { ... }
}
4.1.3 DI带来的核心价值
-
解耦(Loose Coupling):
OrderService
现在只依赖于IOrderRepository
接口。它完全不知道也不关心接口是如何实现的。这意味着你可以轻松替换实现,而无需修改OrderService
的代码。这是依赖倒置原则(DIP) 和开放/封闭原则(OCP) 的直接体现。 -
可测试性(Testability):这是DI带来的最立竿见影的好处。在单元测试中,你可以注入一个模拟对象(Mock)。
// 使用 Moq 框架进行测试 [Test] public void ProcessOrder_Should_Call_Repository_Save() {// Arrangevar mockRepo = new Mock<IOrderRepository>();var orderService = new OrderService(mockRepo.Object); // Inject the mockvar testOrder = new Order();// ActorderService.ProcessOrder(testOrder);// AssertmockRepo.Verify(r => r.Save(testOrder), Times.Once); // Verify Save was called }
这个测试快速、可靠,且不依赖任何外部基础设施。它只测试
OrderService
的逻辑是否正确调用了仓库。 -
可维护性和灵活性:应用程序的配置和组件的组装被集中到了应用程序的入口点(“组合根”)。更改应用程序的行为(例如,为不同的环境切换不同的实现)变得非常简单和清晰。
-
更好的代码组织:明确的依赖关系迫使开发者思考类的职责和边界,通常会导致更好的API设计和更清晰的架构。
架构师视角:
采用依赖注入不仅仅是一种技术选择,更是一种文化和技术决策。它要求团队遵循一套明确的规则(如“所有服务都通过构造函数注入”),但这套规则所带来的长期好处——可测试性、可维护性和灵活性——远远超过初期的学习成本。作为架构师,你应在项目伊始就强制推行DI,并将其作为所有架构讨论的基础。
4.2 .NET内置DI容器详解与生命周期管理
虽然你可以手动实现依赖注入(称为“Pure DI”),但对于大型项目,使用一个DI容器(或称IoC容器)来自动化管理对象的创建和生命周期是更高效的选择。ASP.NET Core自带了一个轻量级、高性能且功能齐全的DI容器。
4.2.1 服务注册(Service Registration)
在应用程序启动时(通常在 Program.cs
或 Startup.cs
中),你需要向容器注册所有服务及其对应的抽象。
// 在 Program.cs 中构建 Host 或 Application Builder
var builder = WebApplication.CreateBuilder(args);// 在 IServiceCollection 上调用方法进行服务注册
builder.Services.AddScoped<IOrderService, OrderService>(); // Registering a service
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();// 注册其他服务...
// builder.Services.AddSingleton<IMySingletonService, MySingletonService>();
// builder.Services.AddTransient<IMyTransientService, MyTransientService>();var app = builder.Build();
.AddScoped<TService, TImplementation>()
是注册方法之一。.AddSingleton<>
和 .AddTransient<>
是另外两个,它们定义了服务的生命周期。
4.2.2 服务生命周期(Service Lifetimes)
理解并正确选择服务生命周期是使用DI容器的关键。.NET DI容器提供了三种基本生命周期:
生命周期 | 注册方法 | 描述 |
---|---|---|
瞬时(Transient) | AddTransient<T>() | 每次请求都会创建一个新的实例。最适合轻量级、无状态的(Stateless)服务。 |
作用域(Scoped) | AddScoped<T>() | 每个客户端请求(作用域) 创建一个实例。在Web应用中,一个HTTP请求就是一个作用域。这是处理“每个请求一个上下文”(如Entity Framework Core的 DbContext )的理想选择。 |
单例(Singleton) | AddSingleton<T>() | 整个应用程序生命周期内只创建一个实例。所有请求共享同一个实例。用于创建全局、线程安全的共享服务(如配置、日志器、缓存)。 |
生命周期选择指南:
AddTransient
:用于无状态的服务。如果服务很简单,并且不依赖任何特定于请求的状态,通常选择这个。例如,一个简单的计算器服务、DTO映射器。AddScoped
:这是最常用的生命周期,用于代表一个工作单元的服务。最重要的例子是Entity Framework Core的DbContext
。在一个Web请求中,所有类都共享同一个DbContext
实例,这确保了所有操作在同一个逻辑事务中进行,并且变更跟踪保持一致。AddSingleton
:用于真正的应用程序全局单例。必须确保该服务是线程安全的,因为多个请求会同时访问它。常用于缓存、应用程序配置、日志器(如ILogger<T>
本身就是一个单例)。
重要警告:避免 captive dependency!即,一个生命周期长的服务(如Singleton)不应该依赖于一个生命周期短的服务(如Scoped或Transient)。这会导致短生命周期服务的行为像单例一样,可能引起难以发现的bug。容器在运行时通常会对此抛出异常。
4.2.3 服务解析(Service Resolution)
服务通常在构造函数中自动解析(“构造函数注入”)。这是最推荐的方式。
// Controller中自动解析
[ApiController]
public class OrderController : ControllerBase {private readonly IOrderService _orderService; // 依赖// 容器会自动注入所需的IOrderService实现public OrderController(IOrderService orderService) {_orderService = orderService;}[HttpPost]public IActionResult CreateOrder(Order order) {_orderService.ProcessOrder(order);return Ok();}
}
在某些边缘情况下(如在 Program.cs
的早期配置或是在静态方法中),你可能需要手动从容器中解析服务。可以通过 IServiceProvider
来实现。
// 在构建完app之后,可以获取根服务提供器(谨慎使用!)
var app = builder.Build();// 创建一个短暂的作用域来解析Scoped服务
using (var serviceScope = app.Services.CreateScope()) {var scopedService = serviceScope.ServiceProvider.GetRequiredService<IMyScopedService>();scopedService.DoSomething();
}
注意:应尽量避免手动解析(称为“服务定位器模式”),因为它会隐藏类的依赖关系,使代码更难理解和测试。始终优先使用构造函数注入。
4.2.4 其他注册方式
容器提供了灵活的注册API。
// 1. 注册现有实例(常用于选项模式)
var myInstance = new MySingletonService();
builder.Services.AddSingleton<IMySingletonService>(myInstance);// 2. 注册工厂方法(用于复杂的初始化逻辑)
builder.Services.AddScoped<IService>(serviceProvider => {var otherService = serviceProvider.GetService<IOtherService>();return new ServiceImpl(otherService, "some parameter");
});// 3. 注册泛型接口
// 假设有 IRepository<T>
builder.Services.AddScoped(typeof(IRepository<>), typeof(GenericRepository<>));
.NET内置容器总结:
对于绝大多数应用程序,.NET内置的DI容器已经完全够用。它性能优异、与框架无缝集成、API简单直观。它的设计哲学是“简单够用”,避免了更高级容器可能带来的复杂性。