在這篇文章,我將分享一些在ASP.NET Core程序中使用依賴注入的個人經驗和建議。這些原則背後的動機如下:
- 高效地設計服務和它們的依賴。
- 預防多線程問題。
- 預防內存泄漏。
- 預防潛在的BUG。
這篇文章假設你已經基本熟悉依賴注入和ASP.NET Core。如果不是,則先閱讀文章: 在ASP.NET Core中使用依賴注入
基礎
構造函數注入
構造函數注入常用於在服務構建上定義和獲取服務依賴。例如:
1 public class ProductService
2 {
3 private readonly IProductRepository _productRepository;
4 public ProductService(IProductRepository productRepository)
5 {
6 _productRepository = productRepository;
7 }
8 public void Delete(int id)
9 {
10 _productRepository.Delete(id);
11 }
12 }
ProductService 將 IProductRepository作爲依賴注入到它的構造函數,然後在 Delete 方法內部使用這個依賴。
實踐指南:
- 在服務構造函數中明確地定義必需的依賴。因此該服務在沒有這些依賴時無法被構造。
- 將注入的依賴賦值給只讀(readonly)的字段或屬性(爲了防止在內部方法中意外地賦予其他值)。
屬性注入
ASP.NET Core 的標準依賴注入容器不支持屬性注入。但是你可以使用其他容器支持屬性注入。例如:
1 using Microsoft.Extensions.Logging;
2 using Microsoft.Extensions.Logging.Abstractions;
3 namespace MyApp
4 {
5 public class ProductService
6 {
7 public ILogger<ProductService> Logger { get; set; }
8 private readonly IProductRepository _productRepository;
9 public ProductService(IProductRepository productRepository)
10 {
11 _productRepository = productRepository;
12 Logger = NullLogger<ProductService>.Instance;
13 }
14 public void Delete(int id)
15 {
16 _productRepository.Delete(id);
17 Logger.LogInformation(
18 $"Deleted a product with id = {id}");
19 }
20 }
21 }
ProductService 定義了一個帶公開setter的Logger 屬性。
依賴注入容器可以設置 Logger屬性,如果它可用(已經註冊到DI容器)。
實踐指南:
- 僅對可選依賴使用屬性注入。這意味着你的服務可以在沒有提供這些依賴時正常地工作。
- 如果可能,使用空對象模式(就像這個例子中這樣)。否則,在使用這個依賴時始終檢查是否爲null
服務定位器
服務定位器模式是獲取依賴關係的另外一種方式。例如:
1 public class ProductService
2 {
3 private readonly IProductRepository _productRepository;
4 private readonly ILogger<ProductService> _logger;
5 public ProductService(IServiceProvider serviceProvider)
6 {
7 _productRepository = serviceProvider
8 .GetRequiredService<IProductRepository>();
9 _logger = serviceProvider
10 .GetService<ILogger<ProductService>>() ??
11 NullLogger<ProductService>.Instance;
12 }
13 public void Delete(int id)
14 {
15 _productRepository.Delete(id);
16 _logger.LogInformation($"Deleted a product with id = {id}");
17 }
18 }
ProductService 注入了 IServiceProvider 來解析並使用依賴。 如果請求的依賴之前沒有被註冊,那麼GetRequiredService將會拋出異常。換句話說, 這種情況下,GetService只會返回null。
當你在構造函數內部解析服務時,它們會隨着服務的釋放而釋放。因此,你不必關心構造函數內部已解析服務的釋放問題(就像構造函數注入和屬性注入)。
實踐指南
- 儘可能不要使用服務定位模式(除非服務類型在開發時就已經知道)。因爲它讓依賴不明確。這意味着在創建服務實例期間不可能容易地看出依賴關係。這對單元測試來說尤爲重要,因爲你可能想要模擬一些依賴。
- 如果可能,在服務構造函數中解析依賴。在服務方法中解析會使你的程序更加難懂、更加容易出錯。我將在下一個章節討論問題和解決方案。
服務生命週期
下面是服務在ASP.NET Core依賴注入中的生命週期:
- Transient 類型的服務在每次注入或請求的時候被創建。
- Scoped 類型的服務按照作用域被創建。在Web程序中,每個Web請求都會創建新的隔離的服務作用域。這意味着Scoped類型的服務通常會根據Web請求創建。
- Singleton 類型的服務由DI容器創建。這通常意味着它們根據應用程序僅僅被創建一次,然後用於應用程序的整個生命週期。
DI容器會持續跟蹤所有已經被解析的服務。當服務的生命週期終止時,它們會被釋放並銷燬:
- 如果服務還有依賴,它們同樣會被自動釋放並銷燬。
- 如果服務實現了 IDisposable 接口,Dispose 方法會在服務釋放時自動被調用。
實踐指南:
- 儘可能地將你的服務註冊爲 Transient 類型。因爲設計Transient服務是簡單的。你通常不用關心多線程問題和內存泄漏問題,並且你知道這類服務只有很短的生存期。
- 謹慎使用 Scoped 類型服務生命週期,因爲如果你創建了子服務作用域或者由非Web程序使用這些服務,那麼它會變得詭異複雜。
- 謹慎使用Singleton 類型的生命週期,因爲你需要處理多線程問題和潛在的內存泄漏問題。
- 不要在Singleton服務上依賴 Transient類型或者 Scoped類型的服務。因爲當單例服務注入的時候,Transient服務也會變成單例實例。並且如果Transient服務不是設計用於支持這樣的場景的話則可能會導致一些問題。ASP.NET Core的默認DI容器在這種情況下會拋出異常。
在方法體中解析服務
在某些情況下,你可能需要在你的服務的某個方法中解析另一個服務。 這種情況下,請確保在使用後釋放該服務。保障這個的最好方法是創建一個服務作用域。例如:
1 public class PriceCalculator 2 { 3 private readonly IServiceProvider _serviceProvider;
4 public PriceCalculator(IServiceProvider serviceProvider) 5 { 6 _serviceProvider = serviceProvider; 7 }
8 public float Calculate(Product product, int count, 9 Type taxStrategyServiceType) 10 { 11 using (var scope = _serviceProvider.CreateScope()) 12 { 13 var taxStrategy = (ITaxStrategy)scope.ServiceProvider 14 .GetRequiredService(taxStrategyServiceType);
15 var price = product.Price * count;
16 return price + taxStrategy.CalculateTax(price); 17 } 18 } 19 }
PriceCalculator 在構造函數中注入了 IServiceProvider,並賦值給了一個字段。然後,PriceCalculator使用它在 Calculate方法內部創建了一個子服務作用域。該作用域使用 scope.ServiceProvider來解析服務,替代了注入的 _serviceProvider 實例。因此,在using語句結束後,所有從該作用域解析的服務都會自動釋放並銷燬。
實踐指南:
- 如果你在某個方法體內解析服務,始終創建一個子服務作用域來確保解析出的服務被正確地釋放。
- 如果某個方法使用 IServiceProvider作爲參數,你可以直接從它解析服務,並且不必關心服務的釋放和銷燬。創建和管理服務作用域是調用你方法的代碼的職責。遵循這個原則可以使你的代碼更加整潔。
- 不要讓解析到的服務持有引用!否則,它可能導致內存泄漏。並且當你後面在使用對象引用時,你可能訪問到一個已經銷燬的服務。(除非解析到的服務是單例)
Singleton服務
單例服務通常設計用於保持應用程序狀態。緩存是一個應用程序狀態的好例子。例如:
1 public class FileService
2 {
3 private readonly ConcurrentDictionary<string, byte[]> _cache;
4 public FileService()
5 {
6 _cache = new ConcurrentDictionary<string, byte[]>();
7 }
8 public byte[] GetFileContent(string filePath)
9 {
10 return _cache.GetOrAdd(filePath, _ =>
11 {
12 return File.ReadAllBytes(filePath);
13 });
14 }
15 }
FileService簡單地緩存了文件內容以減少磁盤讀取。這個服務應該被註冊爲一個單例,否則,緩存將無法按照預期工作。
實踐指南:
- 如果服務持有狀態,那它應該以線程安全的方式來訪問這個狀態。因爲所有請求會併發地使用該服務的同一個實例。我使用 ConcurrentDictionary 替代 Dictionary 來確保線程安全。
- 不要在單例服務中使用Transient或Scoped服務。因爲Transient服務可能不是設計爲線程安全的。如果你使用了它們,在使用這些服務期間需要處理多線程問題(對實例使用lock語句)
- 內存泄漏通常由單例服務導致。在應用程序結束前單例服務不會被釋放/銷燬。因此,如果這些單例服務實例化了類(或注入)但是沒有釋放/銷燬,這些類會一直保留在內存中,直到應用程序結束。確保適時地釋放/銷燬這些類。見上面“在方法體中解析服務”的章節。
- 如果你緩存數據(本例中的文件內容),當原始數據源發生變化時,你應該創建一個機制來更新/失效緩存的數據。
Scoped 服務
Scoped 生命週期的服務看起來是一個不錯的存儲每個Web請求數據的好方法。因爲ASP.NET Core爲每個Web請求創建一個服務作用域。因此,如果你把一個服務註冊爲Scoped,那麼它可以在一個Web請求期間被共享。例如:
1 public class RequestItemsService 2 { 3 private readonly Dictionary<string, object> _items; 4 5 public RequestItemsService() 6 { 7 _items = new Dictionary<string, object>(); 8 } 9 10 public void Set(string name, object value) 11 { 12 _items[name] = value; 13 } 14 15 public object Get(string name) 16 { 17 return _items[name]; 18 } 19 }
如果你將RequestItemsService註冊爲Scoped,並注入到兩個不同的服務,然後你可以得到一個從另外一個服務添加的項。因爲它們會共享同一個RequestItemsService的實例。這就是我們對 Scoped服務的預期。
但是!!!事實並不總是如此。 如果你創建了一個子服務作用域並從子作用域解析RequestItemsService,然後你會得到一個RequestItemsService的新實例,並且不會按照你的預期工作。因此,Scoped服務並不總是意味着每個Web請求一個實例。
你可能認爲你不會犯如此明顯的錯誤(在子作用域內部解析另一個作用域)。但是,這並不是一個錯誤(一個很常規的用法)並且情況可能不會如此簡單。如果你的服務之間有一個大的依賴關係,你不知道是否有人創建了子作用域並在其他注入的服務中解析了服務……最終注入了一個Scoped服務。
實踐指南:
- Scoped服務可以認爲是在Web請求中注入太多服務的一種優化。因此,在相同的Web請求期間,所有這些服務都將使用該服務的單個實例。
- Scoped服務無需設計爲線程安全的。因爲,它們應該正常地被單個Web請求或線程使用。但是,這這種情況下,你不應該在不同的線程之間共享服務作用域。
- 在Web請求中,如果你設計一個Scoped服務在其他服務之間共享數據,請小心(上面解釋過)。你可以在HttpContext中存儲每個Web請求的數據(注入IHttpContextAccessor 來訪問它),這是共享數據的更安全的方式。 HttpContext的生命週期不是Scoped類型的,事實上,它根本不會被註冊到DI(這也是爲什麼不注入它,而是注入 IHttpContextAccessor來代替)。HttpContextAccessor 的實現採用 AsyncLocal 在Web請求期間共享同一個 HttpContext.
結論:
依賴注入剛開始看起來很容易使用,但是如果你不遵循一些嚴格的原則,則會有潛在的多線程問題和內存泄漏問題。我分享的這些實踐指南基於我在開發ABP框架期間的個人經驗。