十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

.NET 8依赖注入实战:从核心原理到工程最佳实践

.NET 8依赖注入实战:从核心原理到工程最佳实践 如果你在 .NET 开发中遇到过这些问题一个简单的业务逻辑改动却需要修改十几个地方的new关键字单元测试时因为类之间的强耦合而无法独立测试或者想替换一个底层服务比如从本地文件存储换成云存储却发现牵一发而动全身——那么依赖注入Dependency Injection, DI就是你正在寻找的答案。但依赖注入远不止是“不用new”那么简单。在 .NET 8 和 ASP.NET Core 的语境下它已经从一个可选的“最佳实践”演变为整个框架的基石。很多人以为学会了在Startup.cs或Program.cs里写services.AddScopedIMyService, MyService()就算掌握了依赖注入这恰恰是最大的误区。真正的挑战在于如何设计松耦合的接口如何管理不同生命周期的服务如何在复杂场景如后台任务、中间件、HttpClient工厂中正确使用它以及当系统出现InvalidOperationException: Cannot resolve scoped service...这类错误时如何快速定位和解决本文不会重复教科书式的定义。我们将直接切入 .NET 8 和 ASP.NET Core 中依赖注入的实战核心通过清晰的场景、对比和代码帮你建立一套可立即落地的依赖注入使用与设计准则。你将了解到为什么现代 .NET 开发离不开内置的 DI 容器。如何正确注册和使用具有不同生命周期Singleton, Scoped, Transient的服务。怎样避免常见的陷阱特别是与作用域Scoped生命周期相关的错误。探索一些高级但实用的模式如工厂模式、选项模式Options与 DI 的结合。无论你是刚开始接触 ASP.NET Core还是希望深化对现有项目架构的理解这篇文章都将提供直接的、可操作的指导。1. 依赖注入要解决的核心问题控制反转与单元测试在深入代码之前我们必须先统一思想依赖注入到底解决了什么根本问题想象一个传统的OrderService它直接实例化了一个EmailService来发送订单确认邮件// 传统紧耦合的方式 - 问题代码 public class OrderService { private readonly EmailService _emailService; public OrderService() { // 问题1强耦合。OrderService 牢牢依赖具体的 EmailService。 _emailService new EmailService(); } public void PlaceOrder(Order order) { // 处理订单逻辑... _emailService.SendConfirmation(order); } } public class EmailService { public void SendConfirmation(Order order) { /* 发送邮件 */ } }这段代码存在几个明显缺陷难以测试 要单元测试OrderService.PlaceOrder你无法避免真实的EmailService被调用这可能会发送真实的邮件或依赖外部服务。难以更改 如果未来需要改用SmsService或一个更强大的NotificationService你必须修改OrderService的内部代码。职责过重OrderService不仅要处理订单逻辑还要负责创建其依赖项。依赖注入通过控制反转IoC解决了这些问题。控制反转意味着对象的依赖不再由对象自身创建而是由外部容器在 ASP.NET Core 中就是IServiceProvider来创建和注入。让我们用依赖注入重写上面的例子// 1. 定义接口抽象行为 public interface INotificationService { void SendOrderConfirmation(Order order); } // 2. 实现具体服务 public class EmailNotificationService : INotificationService { public void SendOrderConfirmation(Order order) { /* 发送邮件 */ } } // 3. 服务类通过构造函数声明其依赖 public class OrderService { private readonly INotificationService _notificationService; // 依赖被“注入”进来 public OrderService(INotificationService notificationService) { _notificationService notificationService; // 不再使用 new } public void PlaceOrder(Order order) { // 处理订单逻辑... _notificationService.SendOrderConfirmation(order); } }现在可测试性极大提升 在单元测试中你可以轻松传入一个模拟的INotificationService使用 Moq, NSubstitute 等框架来验证PlaceOrder的逻辑而无需关心真实的邮件发送。扩展性极强 要切换通知方式只需注册另一个INotificationService的实现如SmsNotificationServiceOrderService的代码无需任何改动。职责清晰OrderService只关注订单处理不关心依赖如何创建。ASP.NET Core 内置的 DI 容器就是负责管理这些接口与实现的映射关系并在需要时自动完成“注入”工作的核心组件。2. ASP.NET Core 内置 DI 容器的核心概念.NET 8 的 ASP.NET Core 延续并强化了其内置的轻量级、高性能 DI 容器。理解以下几个核心概念是正确使用它的前提。2.1 服务生命周期Service Lifetime这是最容易出错的部分。生命周期决定了服务实例被创建和销毁的时机。生命周期注册方法实例创建时机适用场景瞬时TransientAddTransientT()每次请求时都会创建一个新实例。无状态、轻量级的服务。例如一个简单的数据转换器IDataFormatter。开销小但频繁请求可能影响性能。作用域ScopedAddScopedT()每个客户端请求HTTP 请求范围内创建一个实例。在同一请求内多次解析得到的是同一个实例。最常用的生命周期用于需要在一个请求内保持状态或共享资源的服务。例如数据库上下文 (DbContext)、仓储、代表当前用户会话的服务。单例SingletonAddSingletonT()整个应用生命周期内只创建一个实例。所有请求共享该实例。全局共享、无状态或线程安全的服务。例如配置对象、缓存服务、日志器如ILoggerT、内存中的查找表。需特别注意线程安全。关键点 一个 Scoped 服务不能被一个 Singleton 服务依赖。因为 Singleton 实例在应用启动时创建并一直存在而它依赖的 Scoped 服务本应在每个请求中创建和销毁这会导致 Scoped 服务实际上也变成了一个“伪单例”可能引发数据混乱如不同的用户看到别人的 DbContext 数据。容器在构建时会检查并抛出异常。2.2 服务注册Service Registration在Program.cs中我们通过IServiceCollection来注册服务。// Program.cs var builder WebApplication.CreateBuilder(args); // 注册服务到 IServiceCollection builder.Services.AddControllers(); // 框架自带的服务 // 注册自定义服务 builder.Services.AddScopedIOrderService, OrderService(); builder.Services.AddTransientIDataFormatter, JsonDataFormatter(); builder.Services.AddSingletonICacheService, MemoryCacheService(); // 也可以注册具体类型而不使用接口较少用不利于测试和替换 builder.Services.AddScopedEmailService(); var app builder.Build(); // ... 后续配置2.3 服务解析Service Resolution服务通常在构造函数中自动注入构造函数注入。这是最推荐的方式。public class MyController : ControllerBase { private readonly IOrderService _orderService; private readonly ILoggerMyController _logger; // 框架的 DI 容器会自动解析并注入这些参数 public MyController(IOrderService orderService, ILoggerMyController logger) { _orderService orderService; _logger logger; } [HttpGet] public IActionResult Get() { _orderService.DoSomething(); return Ok(); } }你也可以在极少需要的地方如Program.cs的早期配置通过ServiceProvider手动解析服务但应尽量避免因为这通常意味着设计有问题。// 不推荐手动解析示例 using (var scope app.Services.CreateScope()) { var scopedService scope.ServiceProvider.GetRequiredServiceIMyScopedService(); // 使用 scopedService... }3. 环境准备与项目创建让我们从一个干净的起点开始。确保你已安装 .NET 8 SDK 。打开终端或命令行创建一个新的 ASP.NET Core Web API 项目dotnet new webapi -n DiDemo -f net8.0 cd DiDemo用你喜欢的 IDE如 Visual Studio 2022, VS Code, Rider打开项目。查看生成的Program.cs文件它已经包含了基本的服务注册和中间件配置。// 生成的 Program.cs 骨架 var builder WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run();builder.Services就是IServiceCollection的实例是我们进行服务注册的入口。4. 实战从零构建一个使用 DI 的完整服务层我们将模拟一个简单的“产品目录”API包含控制器、服务层和仓储层。4.1 定义领域模型和接口首先创建模型和接口这是松耦合设计的基础。// 文件Models/Product.cs namespace DiDemo.Models; public class Product { public int Id { get; set; } public string Name { get; set; } string.Empty; public decimal Price { get; set; } }// 文件Services/IProductService.cs using DiDemo.Models; namespace DiDemo.Services; public interface IProductService { TaskIEnumerableProduct GetAllProductsAsync(); TaskProduct? GetProductByIdAsync(int id); TaskProduct AddProductAsync(Product product); }// 文件Data/IProductRepository.cs using DiDemo.Models; namespace DiDemo.Data; public interface IProductRepository { TaskIEnumerableProduct GetAllAsync(); TaskProduct? GetByIdAsync(int id); TaskProduct AddAsync(Product product); }4.2 实现具体服务我们先实现一个基于内存集合的“假”仓储专注于演示 DI 流程。// 文件Data/InMemoryProductRepository.cs using DiDemo.Models; namespace DiDemo.Data; public class InMemoryProductRepository : IProductRepository { private readonly ListProduct _products new() { new Product { Id 1, Name Laptop, Price 999.99m }, new Product { Id 2, Name Mouse, Price 25.50m }, }; private int _nextId 3; public TaskIEnumerableProduct GetAllAsync() { return Task.FromResult(_products.AsEnumerable()); } public TaskProduct? GetByIdAsync(int id) { var product _products.FirstOrDefault(p p.Id id); return Task.FromResult(product); } public TaskProduct AddAsync(Product product) { product.Id _nextId; _products.Add(product); return Task.FromResult(product); } }注意这个仓储类没有状态除了内存列表但它被多个请求共享。由于ListProduct不是线程安全的如果这是一个真实的多线程应用我们需要用ConcurrentBag或加锁。这里为了简化我们假设是单线程演示。接下来实现服务层它依赖于仓储接口。// 文件Services/ProductService.cs using DiDemo.Data; using DiDemo.Models; namespace DiDemo.Services; public class ProductService : IProductService { private readonly IProductRepository _repository; private readonly ILoggerProductService _logger; // 依赖通过构造函数注入 public ProductService(IProductRepository repository, ILoggerProductService logger) { _repository repository; _logger logger; } public async TaskIEnumerableProduct GetAllProductsAsync() { _logger.LogInformation(Getting all products.); return await _repository.GetAllAsync(); } public async TaskProduct? GetProductByIdAsync(int id) { _logger.LogInformation(Getting product with ID {ProductId}, id); return await _repository.GetByIdAsync(id); } public async TaskProduct AddProductAsync(Product product) { if (product null) throw new ArgumentNullException(nameof(product)); _logger.LogInformation(Adding a new product: {ProductName}, product.Name); return await _repository.AddAsync(product); } }服务层添加了业务逻辑如参数校验和日志记录这是仓储层不应关心的。4.3 注册服务并创建控制器现在我们需要在Program.cs中将这些部分连接起来。// 文件Program.cs (在原有基础上添加) var builder WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // --- 注册我们自定义的服务 --- // 仓储由于是内存存储所有请求共享同一份数据使用 Singleton 是安全的。 // 但如果未来换成 DbContext必须改为 Scoped builder.Services.AddSingletonIProductRepository, InMemoryProductRepository(); // 服务通常使用 Scoped 生命周期与 HTTP 请求生命周期对齐。 builder.Services.AddScopedIProductService, ProductService(); var app builder.Build(); // ... 其余中间件配置保持不变最后创建 API 控制器。// 文件Controllers/ProductsController.cs using DiDemo.Models; using DiDemo.Services; using Microsoft.AspNetCore.Mvc; namespace DiDemo.Controllers; [ApiController] [Route(api/[controller])] public class ProductsController : ControllerBase { private readonly IProductService _productService; public ProductsController(IProductService productService) { _productService productService; } [HttpGet] public async TaskActionResultIEnumerableProduct Get() { var products await _productService.GetAllProductsAsync(); return Ok(products); } [HttpGet({id})] public async TaskActionResultProduct Get(int id) { var product await _productService.GetProductByIdAsync(id); if (product null) { return NotFound(); } return Ok(product); } [HttpPost] public async TaskActionResultProduct Post(Product product) { var createdProduct await _productService.AddProductAsync(product); return CreatedAtAction(nameof(Get), new { id createdProduct.Id }, createdProduct); } }4.4 运行与验证在项目根目录运行dotnet run应用启动后打开浏览器或使用 Postman、curl 等工具测试 APIGEThttps://localhost:5001/api/products– 应返回产品列表。GEThttps://localhost:5001/api/products/1– 应返回 ID 为 1 的笔记本电脑。POSThttps://localhost:5001/api/products– 发送 JSON{ name: Keyboard, price: 75.00 }应返回创建的产品及新 ID。观察控制台日志你会看到ProductService中注入的ILogger输出的信息。整个过程中ProductsController从未直接实例化ProductService或InMemoryProductRepository所有依赖都由 DI 容器自动解析和注入。5. 深入理解生命周期陷阱与最佳实践掌握了基础流程后我们来看几个容易踩坑的高级场景。5.1 陷阱将 Scoped 服务注入 Singleton 服务这是最常见的运行时错误之一。假设我们有一个单例的CacheService它错误地依赖了一个 Scoped 的IUserContext用于获取当前用户。// 错误示例 public interface IUserContext { string CurrentUserId { get; } } public class UserContext : IUserContext { public string CurrentUserId /* 从 HttpContext 获取用户 ID */; } // 注册为 Scoped // builder.Services.AddScopedIUserContext, UserContext(); public class CacheService : ICacheService { private readonly IUserContext _userContext; // Scoped 服务 private readonly IMemoryCache _cache; public CacheService(IUserContext userContext, IMemoryCache cache) { _userContext userContext; _cache cache; } public string GetUserSpecificData() { // 问题Singleton 的 CacheService 在第一个请求时解析了 IUserContext。 // 后续所有请求都会使用这同一个 IUserContext 实例导致数据错乱 var key $data_for_{_userContext.CurrentUserId}; return _cache.Getstring(key); } } // 注册为 Singleton // builder.Services.AddSingletonICacheService, CacheService();解决方案重新设计 检查CacheService是否真的需要成为 Singleton。如果可以将其改为 Scoped。使用工厂方法 在需要时动态解析 Scoped 服务。将依赖项作为方法参数传递 修改GetUserSpecificData(string userId)由调用者提供用户上下文。5.2 使用HttpClientFactory与 DI 集成直接使用new HttpClient()会导致 socket 耗尽和 DNS 更新问题。ASP.NET Core 推荐使用IHttpClientFactory。// 1. 在 Program.cs 注册一个命名的或类型化的 HttpClient builder.Services.AddHttpClient(GitHubClient, client { client.BaseAddress new Uri(https://api.github.com/); client.DefaultRequestHeaders.Add(Accept, application/vnd.github.v3json); client.DefaultRequestHeaders.Add(User-Agent, DiDemoApp); }); // 2. 在服务中使用 public class GitHubService { private readonly IHttpClientFactory _httpClientFactory; public GitHubService(IHttpClientFactory httpClientFactory) { _httpClientFactory httpClientFactory; } public async Taskstring GetUserAsync(string username) { var client _httpClientFactory.CreateClient(GitHubClient); // 由工厂管理生命周期 var response await client.GetStringAsync($/users/{username}); return response; } } // 注册服务 builder.Services.AddScopedGitHubService();IHttpClientFactory会自动管理HttpClient实例的生命周期通常是 Scoped 或 Transient并处理重试、熔断等高级策略。5.3 选项模式Options Pattern与 DI将配置强类型化并注入服务是 .NET 中的最佳实践。定义选项类// 文件Options/ApiSettings.cs namespace DiDemo.Options; public class ApiSettings { public const string SectionName ApiSettings; public string GitHubApiBaseUrl { get; set; } string.Empty; public int TimeoutSeconds { get; set; } 30; }在appsettings.json中配置{ Logging: { ... }, ApiSettings: { GitHubApiBaseUrl: https://api.github.com, TimeoutSeconds: 30 } }在Program.cs中绑定并注册// 读取配置并绑定到强类型选项 builder.Services.ConfigureApiSettings( builder.Configuration.GetSection(ApiSettings.SectionName));在服务中注入IOptionsT或IOptionsSnapshotTpublic class ConfigurableService { private readonly ApiSettings _settings; // IOptionsSnapshot 是 Scoped 生命周期支持配置热更新在Web请求中。 // 如果不需要热更新可以使用 IOptionsSingleton。 public ConfigurableService(IOptionsSnapshotApiSettings options) { _settings options.Value; // 获取配置值 } public void PrintSettings() { Console.WriteLine($BaseUrl: {_settings.GitHubApiBaseUrl}, Timeout: {_settings.TimeoutSeconds}s); } }6. 常见问题与排查思路问题现象可能原因排查方式解决方案InvalidOperationException: Cannot resolve scoped service X from root provider.在应用启动时如Program.cs的app.Run()之前或从一个 Singleton 服务中尝试解析一个 Scoped 服务。检查堆栈跟踪找到错误解析服务的位置。查看该服务或它的依赖的生命周期。1. 避免在启动代码中直接解析 Scoped 服务。如需使用CreateScope()。2. 确保 Singleton 服务不依赖 Scoped 服务。重新设计生命周期。System.InvalidOperationException: No service for type X has been registered.尝试解析一个未在IServiceCollection中注册的服务。1. 检查Program.cs中是否遗漏了AddScoped/AddTransient/AddSingleton调用。2. 检查服务接口和实现类名是否拼写错误。在Program.cs中添加对应的服务注册。服务行为异常不同请求间状态混乱将本应为 Scoped如 DbContext的服务注册为 Singleton导致数据交叉污染。审查服务注册代码确认每个服务的生命周期是否符合其设计用途。将生命周期从 Singleton 改为 Scoped。对于无状态、线程安全的工具类才使用 Singleton。HttpClient请求出现SocketException或 DNS 问题错误地使用new HttpClient()或在 Singleton 服务中注入HttpClient。检查代码中创建HttpClient的方式。改用IHttpClientFactory来创建和管理HttpClient实例。单元测试时无法模拟Mock依赖被测试的类直接依赖具体实现而不是接口。检查类的构造函数是否注入了接口如IMyService而非具体类MyService。遵循“依赖抽象而非具体”的原则为服务定义接口并通过构造函数注入接口。7. 最佳实践与工程建议面向接口编程 这是实现松耦合和可测试性的基石。服务应依赖接口IMyService而不是具体实现MyService。构造函数注入是首选 它使依赖关系明确并且便于单元测试。避免使用属性注入或从HttpContext.RequestServices中解析服务除非在中间件等特定场景。谨慎选择生命周期默认使用 Scoped 对于大多数与请求相关的服务如业务逻辑服务、仓储。无状态工具类用 Singleton 如配置类、映射器AutoMapper 的IMapper、缓存客户端。轻量级、无状态且创建开销小的用 Transient 如简单的数据验证器、转换器。保持服务简单 每个服务应具有单一的、明确的职责。避免创建“上帝服务”。利用选项模式管理配置 将配置值绑定到强类型对象并通过IOptionsT注入避免在代码中硬编码字符串和魔法数字。使用IHttpClientFactory 永远不要直接new HttpClient()。在Program.cs中集中注册服务 使依赖关系一目了然。对于大型项目可以使用扩展方法如services.AddMyModuleServices()来分组注册。编写可测试的代码 依赖注入的最终目的之一就是便于测试。确保你的服务可以轻松地用模拟对象进行单元测试。依赖注入是构建可维护、可测试、松耦合的现代 .NET 应用程序的核心技术。ASP.NET Core 将其内置并深度集成使得开发者无需引入第三方容器如 Autofac即可应对绝大多数场景。关键在于理解其思想控制反转掌握其机制生命周期、注册、解析并在实践中遵循最佳实践。从今天开始审视你的项目将那些隐藏在代码各处的new关键字找出来思考它们是否可以被一个清晰的接口和一次服务注册所替代这将是迈向更高质量代码的第一步。
返回列表