ASP.NET Core DI最佳实践

Dependency Injection

本文主要分享在ASP.NETCore中使用依赖注入的经验和建议,主要有以下作用:

  • 有效设计服务及其依赖项
  • 防止多线程问题
  • 防止内存泄漏
  • 防止潜在的错误

官方文档Dependency injection in ASP.NET Core | Microsoft Docs

基本

构造函数注入(Constructor injection)

构造函数注入用于声明和获取服务对服务构造的依赖关系。例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ProductService
{
private readonly IProductRepository _productRepository;

public ProductService(IProductRepository productRepository)
{
_productRepository = productRepository;
}

public void Delete(int id)
{
_productRepository.Delete(id);
}
}

ProductServiceIProductRepository 作为其构造函数中的依赖项注入,然后在Delete方法中使用它。

最佳实践:

  • 在服务构造函数中显示定义所需的依赖项,如果没有其依赖项,就无法构造服务
  • 将注入的依赖项分配给只读字段/属性(防止在使用过程中意外赋值)

属性注入(Property Injection)

ASP.NETCore自带的容器(Microsoft.Extensions.DependencyInjection)不支持属性注入,可以使用其他支持属性注入的容器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace MyApp
{
public class ProductService
{
public ILogger<ProductService> Logger { get; set; }
private readonly IProductRepository _productRepository;

public ProductService(IProductRepository productRepository)
{
_productRepository = productRepository;
Logger = NullLogger<ProductService>.Instance;
}

public void Delete(int id)
{
_productRepository.Delete(id);
Logger.LogInformation(
$"Deleted a product with id = {id}");
}
}
}

ProductService 使用公共 setter 声明一个 Logger 属性。依赖注入容器可以设置Logger,如果它是可用的(之前注册给DI容器)。

最佳实践:

  • 仅对可选依赖项使用属性注入,服务可以在不提供这些依赖项的情况下正常工作。
  • 使用Null对象模式,或者在使用时检查null

服务定位器(Service Locator)

服务定位器模式是获取依赖项的另一种方法。

IServiceProvider 接口 (System) | Microsoft Docs

例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ProductService
{
private readonly IProductRepository _productRepository;
private readonly ILogger<ProductService> _logger;
public ProductService(IServiceProvider serviceProvider)
{
_productRepository = serviceProvider
.GetRequiredService<IProductRepository>();
_logger = serviceProvider
.GetService<ILogger<ProductService>>() ??
NullLogger<ProductService>.Instance;
}
public void Delete(int id)
{
_productRepository.Delete(id);
_logger.LogInformation($"Deleted a product with id = {id}");
}
}

ProductService 注入 IServiceProvider 并使用它来解析依赖关系。

如果之前未注册所请求的依赖项,GetRequiredService 将引发异常。另一方面,在这种情况下,GetService 只返回 null

构造函数中解析服务时,它们会在服务被释放时被释放。因此,不需要关心释放/处置在构造函数中解析的服务(就像构造函数和属性注入一样)

最佳实践

  • 尽可能不要使用服务定位器模式,因为它使依赖关系隐含起来。在创建服务实例时不能看到依赖关系,影响单元测试。
  • 在服务构造函数中解决依赖关系,在服务方法中解决会使你的应用程序更加复杂和容易出错

服务生命周期

生命周期

ASP.NET Core依赖注入中有三种服务生命周期:

  • Transient

    瞬时服务在每次注入或请求服务时都会创建服务

  • Scoped

    作用域服务是按作用域创建的。在 Web 应用程序中,每个 Web 请求都会创建一个新的分隔服务作用域,根据 Web 请求创建作用域服务。

  • Singleton

    单例服务是按 DI 容器创建的。这通常意味着每个应用程序只创建一次,然后在整个应用程序生命周期内使用.

.NET | 中的依赖关系注入微软文档 (microsoft.com)

DI容器保持对所有已解决的服务的跟踪。服务在其生命周期结束时被释放和处置。

  • 如果服务具有依赖项,则还会自动释放和释放这些依赖项
  • 如果服务实现了IDisposable接口,Dispose方法会在服务释放时被自动调用。

最佳实践

  • 尽可能地将服务注册为瞬时服务。因为设计瞬时服务很简单。一般不关心多线程内存泄漏,服务的生命周期很短。
  • 谨慎使用作用域服务生命周期,因为如果创建子服务作用域或从非 Web 应用程序使用这些服务可能存在问题。
  • 谨慎使用单例生命周期,需要处理多线程和潜在的内存泄漏问题。
  • 不要依赖单例服务中瞬时范围服务。 因为当单例服务注入瞬时服务时,瞬态服务会变成单例实例,如果瞬时服务不是为支持这种情况而设计的,则可能会导致问题。 在这种情况下,ASP.NET Core 的默认 DI 容器已经抛出异常。

在方法中解析服务

在某些情况下,可能需要在服务的方法中解析另一个服务。

在这种情况下,请确保在使用后释放服务。

最佳方法是创建服务范围(Scope)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PriceCalculator
{
private readonly IServiceProvider _serviceProvider;

public PriceCalculator(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

public float Calculate(Product product, int count,
Type taxStrategyServiceType)
{
using (var scope = _serviceProvider.CreateScope())
{
var taxStrategy = (ITaxStrategy)scope.ServiceProvider
.GetRequiredService(taxStrategyServiceType);
var price = product.Price * count;
return price + taxStrategy.CalculateTax(price);
}
}
}

PriceCalculator 在其构造函数中注入IServiceProvider并将其分配给一个字段。 PriceCalculator 然后在Calculate 方法中使用它来创建子服务范围。 它使用 scope.ServiceProvider 来解析服务,而不是注入的 _serviceProvider 实例。 因此,从范围解析的所有服务都会在 using 语句的末尾自动释放

最佳实践

  • 如果要解析方法体中的服务,请始终创建子服务作用域,以确保正确释放已解析的服务
  • 如果一个方法获取 IServiceProvider 作为参数,那么可以直接从中解析服务,而无需关心释放。 创建/管理服务范围是调用方法的代码的责任。 遵循这个原则可以让代码更干净。
  • 不要保留对已解析服务的引用!否则,它可能会导致内存泄漏,并且稍后使用对象引用时将访问已释放的服务(除非已解析的服务是单例)。

单例服务(Singleton Services)

单例服务通常旨在保持应用程序状态。 缓存是应用程序状态的一个很好的例子。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FileService
{
private readonly ConcurrentDictionary<string, byte[]> _cache;
public FileService()
{
_cache = new ConcurrentDictionary<string, byte[]>();
}
public byte[] GetFileContent(string filePath)
{
return _cache.GetOrAdd(filePath, _ =>
{
return File.ReadAllBytes(filePath);
});
}
}

文件服务只是缓存文件内容以减少磁盘读取。此服务应注册为单例,否则,缓存将无法按预期工作。

最佳实践:

  • 如果服务保持状态,则应以线程安全的方式访问该状态。因为所有请求同时使用同一服务实例。使用 ConcurrentDictionary 而不是 Dictionary 来确保线程安全。

  • 不要使用来自单例服务的作用域或瞬态服务。因为,瞬时服务可能未设计为线程安全。如果必须使用它们,在使用这些服务时注意多线程(例如使用锁)。

  • 内存泄漏通常是由单例服务引起的。在应用程序结束之前,它们不会释放。因此,如果它们实例化(或注入)类但不释放放它们,它们将保留在内存中,直到应用程序结束。确保在正确的时间释放它们。

  • 如果缓存数据(示例中为文件内容),则应创建一种机制,以便在原始数据源更改时(当磁盘上的缓存文件发生更改时)更新/使缓存数据失效。

作用域服务(Scoped Services)

作用域生存期首先似乎是存储每个 Web 请求数据的良好候选者。

因为 ASP.NET Core为每个 Web 请求创建一个服务范围。因此,如果将服务注册为作用域,则可以在 Web 请求期间共享该服务。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class RequestItemsService
{
private readonly Dictionary<string, object> _items;
public RequestItemsService()
{
_items = new Dictionary<string, object>();
}
public void Set(string name, object value)
{
_items[name] = value;
}
public object Get(string name)
{
return _items[name];
}
}

如果将 RequestItemsService 注册为作用域,并将其注入到两个不同的服务中,则可以获取从另一个服务添加的项,因为它们将共享同一个RequestItemsService实例。这是我们对作用域服务的猜想。

但并不是这样,如果创建子服务作用域并从子作用域解析 RequestItemsService,将获得 RequestItemsService 的新实例,并且它不会按预期工作。

因此,作用域服务并不意味着每个 Web 请求的实例。

你可能认为你没有犯这么明显的错误(在一个子的作用域内解决一个作用域)。 但是,这不是一个错误(一种非常常见的用法),而且情况可能并不那么简单。 如果服务之间存在很大的依赖关系图,无法知道是否有人创建了子作用域并解析了注入另一个服务的服务……最终注入了作用域服务。

最佳实践

  • 可以将作用域服务视为一种优化,它在 Web 请求中被太多服务注入。 因此,所有这些服务将在同一个 Web 请求期间使用该服务的单个实例。

  • 作用域内服务不需要设计为线程安全。因为它们通常应由单个Web请求/线程使用。但在这种情况下,不应在不同线程之间共享服务作用域

  • 如果设计一个范围服务以在 Web 请求中的其他服务之间共享数据(如上所述),可以将每个 Web 请求数据存储在 HttpContext 中(注入 IHttpContextAccessor 以访问它),这是更安全的方法。 HttpContext 的生命周期没有作用域。 实际上,它根本没有注册到 DI(这就是为什么不注入它,而是注入 IHttpContextAccessor 的原因)。 HttpContextAccessor 实现使用 AsyncLocal 在 Web 请求期间共享相同的 HttpContext

HttpAbstractions/HttpContextAccessor.cs at master · aspnet/HttpAbstractions (github.com)

结论

依赖关系注入似乎很容易使用,但是如果不遵循一些严格的原则,则存在潜在的多线程和内存泄漏问题。

作者:hikalkan (Halil İbrahim Kalkan) (github.com)

出处:ASP.NET Core Dependency Injection Best Practices, Tips & Tricks | by Halil İbrahim Kalkan | Volosoft | Medium