ASP.NET Core Dependency Injection: Best Practice, Mẹo và Thủ Thuật

ASP.NET Core Dependency Injection: Best Practice, Mẹo và Thủ Thuật

Trong bài viết này, tôi sẽ chia sẻ các best practice, mẹo và thủ thuật về việc sử dụng Dependency Injection (DI) trong ứng dụng ASP.NET Core. Mục địch đằng sau những nguyên tắc này là:

  • Thiết kế hiệu quả các dịch vụ và sự phụ thuộc của chúng.
  • Ngăn chặn các vấn đề đa luồng.
  • Ngăn chặn rò rỉ bộ nhớ.
  • Ngăn ngừa các lỗi tiềm ẩn.
  • Hỗ trợ viết unit test hiệu quả.

Bài viết này giả định rằng bạn đã quen thuộc với Dependency Injection và ASP.NET Core ở mức cơ bản. Nếu không, trước tiên bạn hãy đọc bài viết về ASP.NET Core Dependency Injection.

Dependency Injection trong ASP.NET Core | Comdy
Hướng dẫn này sẽ giúp bạn sử dụng Dependency Injection để giải quyết sự phụ thuộc trong ASP.NET Core.

Khái niệm cơ bản

Constructor Injection

Constructor injection được sử dụng để khai báo và lấy các phụ thuộc của một dịch vụ trên service construction. Ví dụ:

public class ProductService
{
    private readonly IProductRepository _productRepository;
    
    public ProductService(IProductRepository productRepository)
    {
        _productRepository = productRepository;
    }
    
    public void Delete(int id)
    {
        _productRepository.Delete(id);
    }
}

Trong ví dụ trên, ProductService inject IProductRepository trong phương thức khởi tạo của nó, sau đó sử dụng nó bên trong phương thức Delete.

Best practice:

  • Xác định rõ ràng các phụ thuộc bắt buộc trong phương thức khởi tạo dịch vụ. Do đó, dịch vụ không thể được khởi tạo mà không có các phụ thuộc của nó.
  • Gán phần phụ thuộc được inject vào một trường / thuộc tính read only (để tránh việc vô tình gán giá trị khác cho nó bên trong một phương thức).

Property Injection

Dependency Injection Container mặc định trong ASP.NET Core không hỗ trợ property injection. Nhưng bạn có thể sử dụng một Dependency Injection Container khác hỗ trợ property injection. Ví dụ:

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 đang khai báo thuộc tính Logger với bộ thiết lập công khai . Vùng chứa tiêm phụ thuộc có thể đặt Bộ ghi nhật ký nếu nó có sẵn (đã đăng ký với vùng chứa DI trước đó).

Best practice:

  • Chỉ sử dụng việc property injection cho các phụ thuộc tùy chọn. Điều đó có nghĩa là dịch vụ của bạn có thể hoạt động bình thường mà không cần cung cấp các phụ thuộc này.
  • Sử dụng Null Object Pattern (như trong ví dụ này) nếu có thể. Nếu không, hãy luôn kiểm tra null trước khi sử dụng phụ thuộc.

Service Locator

Mẫu định vị dịch vụ là một cách khác để lấy các phụ thuộc. Thí dụ:

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}");
    }
}

Trong ví dụ trên, ProductService inject IServiceProvider và giải quyết các phụ thuộc bằng cách sử dụng nó. Phương thức GetRequiredService sẽ ném ra một ngoại lệ nếu phụ thuộc được yêu cầu chưa được đăng ký trước đó. Còn phương thức GetService chỉ trả về null trong trường hợp đó.

Khi bạn khởi tạo các dịch vụ bên trong phương thức khởi tạo (constructor), chúng sẽ được giải phóng khi dịch vụ được giải phóng. Vì vậy, bạn không cần quan tâm đến việc giải phóng / hủy bỏ các dịch vụ được giải quyết bên trong phương thức khởi tạo (giống như constructor injection và property injection).

Best practice:

  • Không sử dụng mẫu service locator nếu có thể (nếu loại dịch vụ được biết trong thời gian phát triển). Bởi vì nó làm cho các phụ thuộc ngầm định. Điều đó có nghĩa là không thể dễ dàng nhìn thấy các phụ thuộc trong khi tạo một phiên bản của dịch vụ. Điều này đặc biệt quan trọng đối với unit test nơi bạn có thể muốn mock một số phụ thuộc của một dịch vụ.
  • Giải quyết các phụ thuộc trong phương thức khởi tạo của dịch vụ nếu có thể. Giải quyết các phụ thuộc trong các phương thức của dịch vụ làm cho ứng dụng của bạn phức tạp hơn và dễ xảy ra lỗi. Tôi sẽ trình bày các vấn đề & giải pháp trong các phần tiếp theo.

Vòng đời dịch vụ (Service Life Time)

Có ba loại vòng đời dịch vụ trong ASP.NET Core Dependency Injection:

  1. Transient: Các dịch vụ được tạo ra mỗi khi chúng được inject hoặc yêu cầu.
  2. Scoped: Các dịch vụ được tạo theo phạm vi. Trong một ứng dụng web, mọi yêu cầu web sẽ tạo ra một phạm vi dịch vụ riêng biệt mới. Điều đó có nghĩa là các dịch vụ có phạm vi thường được tạo theo yêu cầu web.
  3. Singleton: Các dịch vụ được tạo trên mỗi DI Container. Điều đó thường có nghĩa là chúng chỉ được tạo một lần cho mỗi ứng dụng và sau đó được sử dụng cho toàn bộ thời gian tồn tại của ứng dụng.

DI Container theo dõi tất cả các dịch vụ đã được khởi tạo. Dịch vụ sẽ được giải phóng và xử lý khi thời gian tồn tại của chúng kết thúc:

  • Nếu dịch vụ có các phụ thuộc, chúng cũng tự động được giải phóng và xử lý.
  • Nếu dịch vụ triển khai interface IDisposable, phương thức Dispose sẽ tự động được gọi khi dịch vụ giải phóng.

Best practice:

  • Đăng ký dịch vụ của bạn với loại transientbất cứ nơi nào có thể. Bởi vì nó đơn giản để thiết kế các dịch vụ nhất thời. Bạn thường không quan tâm đến đa luồngrò rỉ bộ nhớ và bạn biết rằng dịch vụ có tuổi thọ ngắn.
  • Sử dụng cẩn thận loại scoped vì có thể rất khó nếu bạn tạo phạm vi dịch vụ con hoặc sử dụng các dịch vụ này từ một ứng dụng không phải web.
  • Sử dụng cẩn thận loại singleton vì sau đó bạn cần phải xử lý các vấn đề về đa luồngrò rỉ bộ nhớ (memory leak).
  • Không phụ thuộc vào một dịch vụ transient hoặc scoped từ một dịch vụ singleton. Bởi vì dịch vụ tạm thời sẽ trở thành một thể hiện singleton khi một dịch vụ singleton inject nó và điều đó có thể gây ra sự cố nếu dịch vụ tạm thời không được thiết kế để hỗ trợ tình huống như vậy. DI Container mặc định của ASP.NET Core sẽ ném ra các ngoại lệ trong những trường hợp như vậy.

Inject dịch vụ trong phương thức

Trong một số trường hợp, bạn có thể cần inject một dịch vụ khác trong một phương thức của dịch vụ của bạn. Trong những trường hợp như vậy, hãy đảm bảo rằng bạn phải giải phóng dịch vụ sau khi sử dụng. Cách tốt nhất để đảm bảo điều đó là tạo phạm vi dịch vụ. Ví dụ:

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 inject IServiceProvider trong hàm khởi tạo của nó và gán nó cho một trường. Sau đó, PriceCalculator sử dụng nó bên trong phương thức Calculate để tạo phạm vi dịch vụ con. Nó sử dụng phương thức scope.ServiceProvider để inject dịch vụ, thay vì sử dụng trường _serviceProvider. Do đó, tất cả các dịch vụ được inject trong phạm vi được tự động giải phóng / xử lý vào cuối câu lệnh using.

Best practice:

  • Nếu bạn đang inject một dịch vụ trong phương thức, hãy luôn tạo một phạm vi dịch vụ con để đảm bảo rằng các dịch vụ đã phân giải được phát hành đúng cách.
  • Nếu một phương thức lấy IServiceProvider làm đối số, thì bạn có thể trực tiếp giải quyết các dịch vụ từ nó mà không cần quan tâm đến việc giải phóng / hủy bỏ. Tạo / quản lý phạm vi dịch vụ là trách nhiệm của mã gọi phương thức của bạn. Làm theo nguyên tắc này làm cho mã của bạn sạch hơn.
  • Không giữ một tham chiếu đến một dịch vụ đã giải quyết ! Nếu không, nó có thể gây rò rỉ bộ nhớ và bạn sẽ truy cập vào một dịch vụ đã được xử lý khi bạn sử dụng tham chiếu đối tượng sau này (trừ khi dịch vụ được giải quyết là singleton).

Dịch vụ Singleton

Các dịch vụ Singleton thường được thiết kế để giữ trạng thái ứng dụng. Bộ nhớ đệm là một ví dụ điển hình về trạng thái ứng dụng. Ví dụ:

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);
        });
    }
}

FileService chỉ cần lưu nội dung tệp vào bộ nhớ cache để giảm số lần đọc đĩa. Dịch vụ này nên được đăng ký dưới dạng Singleton. Nếu không, bộ nhớ đệm sẽ không hoạt động như mong đợi.

Best practice:

  • Nếu dịch vụ giữ một trạng thái, nó sẽ truy cập vào trạng thái đó theo cách an toàn luồng. Bởi vì tất cả các yêu cầu đồng thời sử dụng cùng một phiên bản của dịch vụ. Tôi đã sử dụng ConcurrentDictionary thay vì Dictionary để đảm bảo an toàn cho luồng.
  • Không sử dụng các dịch vụ scoped hoặc transient từ các dịch vụ singleton. Bởi vì, các dịch vụ tạm thời có thể không được thiết kế để an toàn luồng (thread safe). Nếu bạn phải sử dụng chúng, hãy quan tâm đến đa luồng trong khi sử dụng các dịch vụ này (ví dụ: sử dụng khóa).
  • Rò rỉ bộ nhớ thường do các dịch vụ singleton gây ra. Chúng không được giải phóng / xử lý cho đến khi kết thúc ứng dụng. Vì vậy, nếu chúng khởi tạo (hoặc inject) các lớp nhưng không giải phóng / loại bỏ chúng, chúng cũng sẽ ở trong bộ nhớ cho đến khi kết thúc ứng dụng. Đảm bảo rằng bạn giải phóng / xử lý chúng vào đúng thời điểm.
  • Nếu bạn lưu dữ liệu vào bộ nhớ cache (nội dung tệp trong ví dụ này), bạn nên tạo cơ chế để cập nhật / làm mất hiệu lực dữ liệu đã lưu trong bộ nhớ cache khi nguồn dữ liệu ban đầu thay đổi (khi tệp được lưu trong bộ nhớ cache thay đổi trên đĩa đối với ví dụ này).

Dịch vụ Scoped

Scoped có vẻlà một ứng cử viên phù hợp để lưu trữ dữ liệu theo yêu cầu web. Vì ASP.NET Core tạo phạm vi dịch vụ cho mỗi yêu cầu web. Vì vậy, nếu bạn đăng ký một dịch vụ theo scoped, nó có thể được chia sẻ trong một yêu cầu web. Ví dụ:

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];
    }
}

Nếu bạn đăng ký RequestItemsService dưới dạng scoped và đưa nó vào hai dịch vụ khác nhau, thì bạn có thể nhận được một mục được thêm vào từ một dịch vụ khác vì chúng sẽ chia sẻ cùng một thể hiện của RequestItemsService. Đó là những gì chúng tôi mong đợi từ các dịch vụ scoped.

Nhưng thực tế có thể không phải lúc nào cũng như vậy. Nếu bạn tạo một phạm vi dịch vụ con và inject RequestItemsService từ phạm vi con, thì bạn sẽ nhận được một phiên bản mới của RequestItemsService và nó sẽ không hoạt động như bạn mong đợi. Vì vậy, dịch vụ có phạm vi không phải lúc nào cũng có nghĩa là phiên bản cho mỗi yêu cầu web.

Bạn có thể nghĩ rằng bạn không phạm phải sai lầm rõ ràng như vậy (giải quyết một phạm vi trong phạm vi con). Tuy nhiên, đây không phải là một sai lầm (được sử dụng rất thường xuyên) và trường hợp có thể không đơn giản như vậy. Nếu có một sự phụ thuộc lớn giữa các dịch vụ của bạn, bạn không thể biết liệu có ai đã tạo phạm vi dịch vụ con và inject một dịch vụ đưa vào dịch vụ khác… cuối cùng inject một dịch vụ scoped.

Best practice:

  • Một dịch vụ scoped có thể được coi là một sự tối ưu hóa khi nó được inject bởi quá nhiều dịch vụ trong một yêu cầu web. Do đó, tất cả các dịch vụ này sẽ sử dụng một phiên bản duy nhất của dịch vụ trong cùng một yêu cầu web.
  • Các dịch vụ scoped không cần phải được thiết kế dạng an toàn luồng. Bởi vì chúng thường được sử dụng bởi một web-request / thread. Nhưng trong trường hợp đó, bạn không nên chia sẻ phạm vi dịch vụ giữa các luồng khác nhau!
  • Hãy cẩn thận nếu bạn thiết kế một dịch vụ scoped để chia sẻ dữ liệu giữa các dịch vụ khác trong một yêu cầu web (đã giải thích ở trên). Bạn có thể lưu trữ dữ liệu theo yêu cầu web bên trong HttpContext (đưa IHttpContextAccessor vào để truy cập), đây là cách an toàn hơn để làm điều đó. Thời gian tồn tại của HttpContext không có phạm vi. Trên thực tế, nó hoàn toàn không được đăng ký DI (đó là lý do tại sao bạn không inject nó mà thay vào đó là inject IHttpContextAccessor). Việc triển khai HttpContextAccessor sử dụng AsyncLocal để chia sẻ cùng một HttpContext trong một yêu cầu web.

Kết luận

Ban đầu, Dependency Injection có vẻ đơn giản để sử dụng, nhưng tiềm ẩn nhiều vấn đề về đa luồng và rò rỉ bộ nhớ nếu bạn không tuân theo một số nguyên tắc nghiêm ngặt. Tôi đã chia sẻ một số nguyên tắc tốt dựa trên kinh nghiệm của bản thân trong quá trình phát triển các ứng dụng doanh nghiệp dựa trên ASP.NET Core.

Trả lời

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *