Tối ưu hóa Cấu hình Dependency Injection trong .NET với Scrutor: Từ Quét Assembly đến Áp dụng Decorator

Giới thiệu

Trong hành trình trở thành một lập trình viên .NET chuyên nghiệp, việc nắm vững Dependency Injection (DI) là cực kỳ quan trọng. Chúng ta đã cùng nhau tìm hiểu về vòng đời dịch vụ trong DI (Scoped, Transient, Singleton) và tầm quan trọng của nó trong việc xây dựng các ứng dụng có cấu trúc chặt chẽ, dễ kiểm thử và bảo trì.

Tuy nhiên, khi ứng dụng của bạn phát triển lớn hơn, số lượng dịch vụ (services) và các phụ thuộc (dependencies) cũng tăng theo cấp số nhân. Việc đăng ký thủ công từng dịch vụ trong phương thức ConfigureServices (hoặc tương đương) của lớp Startup hoặc Program.cs có thể trở nên cồng kềnh, lặp đi lặp lại và dễ xảy ra lỗi. Imagine bạn có hàng chục, thậm chí hàng trăm cặp Interface-Implementation cần đăng ký!

Đây là lúc các công cụ hỗ trợ phát huy tác dụng. Trong bài viết này, thuộc chuỗi bài về Lộ trình ASP.NET CoreHệ sinh thái .NET, chúng ta sẽ khám phá Scrutor – một thư viện mã nguồn mở mạnh mẽ giúp tự động hóa quá trình đăng ký dịch vụ thông qua quét assembly (assembly scanning) và hỗ trợ áp dụng Decorator pattern một cách dễ dàng vào hệ thống DI của .NET Core/ASP.NET Core.

Thách thức khi cấu hình DI thủ công

Hãy xem xét đoạn mã cấu hình DI quen thuộc:


public void ConfigureServices(IServiceCollection services)
{
    // Đăng ký các dịch vụ thủ công
    services.AddScoped<IUserService, UserService>();
    services.AddTransient<IEmailService, EmailService>();
    services.AddSingleton<ICacheService, MemoryCacheService>();
    services.AddScoped<IProductRepository, ProductRepository>();
    services.AddScoped<IOrderRepository, OrderRepository>();
    // ... và rất nhiều dòng tương tự khác
}

Mặc dù cách tiếp cận này hoạt động tốt cho các ứng dụng nhỏ, nhưng trong một dự án lớn:

  • Lặp lại (Repetitive): Phải gõ hoặc sao chép/dán rất nhiều dòng code tương tự.
  • Khó bảo trì: Mỗi khi thêm một dịch vụ mới, bạn phải nhớ quay lại file cấu hình DI và thêm dòng đăng ký tương ứng. Nếu quên, ứng dụng sẽ báo lỗi lúc runtime.
  • Không linh hoạt: Việc áp dụng các quy tắc đăng ký (ví dụ: tất cả các repository đều là Scoped) yêu cầu kiểm tra thủ công từng dòng.

Scrutor ra đời để giải quyết những vấn đề này.

Giới thiệu về Scrutor

Scrutor là một thư viện mở rộng cho Microsoft.Extensions.DependencyInjection, cung cấp các phương thức tiện lợi để:

  1. Quét (Scan) các assembly (các file .dll) để tìm kiếm các lớp (classes) hoặc interfaces.
  2. Tự động đăng ký các lớp/interfaces được tìm thấy vào service container dựa trên các quy tắc được định nghĩa.
  3. Áp dụng Decorator pattern cho các dịch vụ đã đăng ký.

Sử dụng Scrutor giúp giảm đáng kể lượng mã boilerplate (mã lặp đi lặp lại) trong phần cấu hình DI, làm cho code sạch sẽ hơn và dễ bảo trì hơn nhiều.

Cài đặt Scrutor

Sử dụng .NET CLI hoặc NuGet Package Manager để thêm thư viện:


dotnet add package Scrutor

Thư viện này sẽ thêm các phương thức mở rộng cho IServiceCollection.

Quét Assembly (Assembly Scanning) với Scrutor

Tính năng cốt lõi đầu tiên của Scrutor là khả năng quét các assembly. Thay vì chỉ định từng cặp Interface-Implementation, bạn có thể yêu cầu Scrutor tìm tất cả các lớp theo một quy tắc nào đó và đăng ký chúng. Điều này đặc biệt hữu ích khi bạn có cấu trúc dự án tuân theo các quy ước đặt tên (naming conventions).

Quét và đăng ký theo Interface

Quy tắc phổ biến nhất là đăng ký một lớp với tất cả các interface mà nó implement (thực thi).


// Ví dụ: Giả sử bạn có các lớp UserService, ProductService
// và các interface IUserService, IProductService trong cùng một assembly.

public void ConfigureServices(IServiceCollection services)
{
    services.Scan(scan => scan
        .FromAssembliesOf<Startup>() // Hoặc FromEntryAssembly(), FromApplicationDependencies()
        .AddClasses() // Tìm tất cả các lớp trong assembly được chọn
        .AsImplementedInterfaces() // Đăng ký mỗi lớp với TẤT CẢ các interface mà nó implement
        .WithScopedLifetime()); // Đăng ký với vòng đời Scoped
}

Trong ví dụ trên:

  • services.Scan(...): Bắt đầu quá trình quét.
  • FromAssembliesOf<Startup>(): Chỉ định assembly cần quét. Ở đây là assembly chứa lớp Startup (thường là assembly chính của ứng dụng web). Bạn cũng có thể dùng FromEntryAssembly() (assembly thực thi) hoặc FromApplicationDependencies() (tất cả các assembly phụ thuộc của ứng dụng trừ các assembly hệ thống).
  • AddClasses(): Chọn tất cả các lớp trong assembly.
  • AsImplementedInterfaces(): Đối với mỗi lớp được chọn, Scrutor sẽ tìm tất cả các interface mà lớp đó implement và đăng ký lớp đó là implementation cho TẤT CẢ các interface đó. Cẩn thận khi sử dụng cái này nếu một lớp implement nhiều interface và bạn chỉ muốn đăng ký nó cho một interface cụ thể.
  • WithScopedLifetime(): Thiết lập vòng đời (lifetime) cho các dịch vụ được đăng ký (có thể là WithTransientLifetime() hoặc WithSingletonLifetime()).

Lưu ý: Nếu một lớp implement nhiều interface, AsImplementedInterfaces() sẽ đăng ký lớp đó cho *tất cả* các interface đó. Điều này có thể không phải lúc nào cũng mong muốn. Ví dụ, nếu MyService implement IServiceAIServiceB, nó sẽ được đăng ký cho cả hai. Khi bạn resolve IServiceA, bạn nhận được MyService. Khi bạn resolve IServiceB, bạn cũng nhận được MyService.

Quét và đăng ký theo quy ước đặt tên (Naming Convention)

Một cách tiếp cận phổ biến khác là quét dựa trên quy ước đặt tên. Ví dụ: tất cả các lớp kết thúc bằng “Repository” đều là implementation của interface tương ứng (ví dụ: ProductRepository implement IProductRepository).


public void ConfigureServices(IServiceCollection services)
{
    services.Scan(scan => scan
        .FromAssembliesOf<Startup>()
        .AddClasses(classes => classes.Where(type => type.Name.EndsWith("Repository"))) // Chỉ chọn các lớp kết thúc bằng "Repository"
        .AsMatchingInterface() // Đăng ký lớp với interface có tên tương ứng (ví dụ: ProductRepository -> IProductRepository)
        .WithScopedLifetime());
}

Ở đây:

  • AddClasses(classes => classes.Where(type => type.Name.EndsWith("Repository"))): Sử dụng bộ lọc để chỉ chọn các lớp có tên kết thúc bằng “Repository”. Bạn có thể sử dụng bất kỳ điều kiện lọc nào dựa trên Type (namespace, thuộc tính, v.v.).
  • AsMatchingInterface(): Scrutor sẽ tìm kiếm interface có cùng tên với lớp, nhưng bắt đầu bằng ‘I’. Ví dụ: tìm IProductRepository cho ProductRepository, IUserRepository cho UserRepository. Đây là một quy ước rất phổ biến trong .NET.

Các tùy chọn quét nâng cao

Scrutor cung cấp nhiều tùy chọn quét khác:

  • FromApplicationDependencies(): Quét tất cả các assembly mà ứng dụng phụ thuộc, loại trừ các assembly hệ thống.
  • FromCallingAssembly(): Quét assembly đang gọi phương thức Scan.
  • AddClasses(classes => classes.AssignableTo<ISomeMarkerInterface>()): Chỉ chọn các lớp implement một interface cụ thể (marker interface).
  • AddClasses(classes => classes.WithAttribute<SomeAttribute>()): Chỉ chọn các lớp được đánh dấu bằng một attribute cụ thể.
  • AsSelf(): Đăng ký lớp với chính nó (ví dụ: services.AddScoped<MyService, MyService>()).
  • AsMatchingInterface(matching => matching.FromScrutor()): Sử dụng quy tắc matching interface mặc định của Scrutor (thêm ‘I’ vào trước tên lớp).
  • AsMatchingInterface(matching => matching.From(type => type.GetInterfaces().FirstOrDefault(i => i.Name == "My" + type.Name))): Tùy chỉnh quy tắc matching interface của riêng bạn.

Bằng cách kết hợp các phương thức AddClasses, As*, và With*Lifetime, bạn có thể tạo ra các quy tắc đăng ký rất linh hoạt và mạnh mẽ, tự động hóa hầu hết quá trình cấu hình DI.

Áp dụng Decorator Pattern trong DI với Scrutor

Tính năng mạnh mẽ thứ hai của Scrutor là khả năng áp dụng Decorator pattern một cách dễ dàng. Decorator pattern là một mẫu thiết kế cấu trúc cho phép bạn thêm các hành vi mới vào một đối tượng hiện có bằng cách đặt đối tượng đó bên trong một đối tượng bao bọc (wrapper) mà không làm thay đổi cấu trúc của nó.

Trong bối cảnh DI, Decorator rất hữu ích để thêm các chức năng cross-cutting (xuyên cắt) như logging, caching, validation, xử lý lỗi, retry logic, transaction management, v.v., mà không làm “ô nhiễm” code gốc của dịch vụ chính.

Ví dụ, bạn có một dịch vụ IUserService:


public interface IUserService
{
    Task<User> GetUserByIdAsync(int userId);
    Task CreateUserAsync(User user);
}

public class UserService : IUserService
{
    public async Task<User> GetUserByIdAsync(int userId)
    {
        // Logic truy vấn database...
        return new User { Id = userId, Name = "Test User" };
    }

    public async Task CreateUserAsync(User user)
    {
        // Logic lưu vào database...
        await Task.CompletedTask;
    }
}

Bây giờ bạn muốn log lại mọi lần gọi đến các phương thức của IUserService. Thay vì thêm code logging vào trực tiếp trong UserService, bạn tạo một Decorator:


public class LoggingUserServiceDecorator : IUserService
{
    private readonly IUserService _decorated;
    private readonly ILogger<LoggingUserServiceDecorator> _logger;

    public LoggingUserServiceDecorator(IUserService decorated, ILogger<LoggingUserServiceDecorator> logger)
    {
        _decorated = decorated; // Đây là instance của dịch vụ gốc (UserService) hoặc decorator trước đó
        _logger = logger;
    }

    public async Task<User> GetUserByIdAsync(int userId)
    {
        _logger.LogInformation($"Calling GetUserByIdAsync for user {userId}");
        try
        {
            var user = await _decorated.GetUserByIdAsync(userId);
            _logger.LogInformation($"Finished GetUserByIdAsync for user {userId}");
            return user;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, $"Error calling GetUserByIdAsync for user {userId}");
            throw;
        }
    }

    public async Task CreateUserAsync(User user)
    {
        _logger.LogInformation($"Calling CreateUserAsync for user {user.Id}");
        try
        {
            await _decorated.CreateUserAsync(user);
            _logger.LogInformation($"Finished CreateUserAsync for user {user.Id}");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, $"Error calling CreateUserAsync for user {user.Id}");
            throw;
        }
    }
}

Để sử dụng Decorator này với hệ thống DI mặc định, bạn sẽ cần đăng ký như sau:


// Đăng ký dịch vụ gốc với tên khác
services.AddScoped<UserService>();
// Đăng ký decorator, truyền dịch vụ gốc vào
services.AddScoped<IUserService, LoggingUserServiceDecorator>(provider =>
    new LoggingUserServiceDecorator(
        provider.GetRequiredService<UserService>(),
        provider.GetRequiredService<ILogger<LoggingUserServiceDecorator>>()
    )
);

Cách này hoạt động, nhưng vẫn khá thủ công và dễ gây nhầm lẫn nếu bạn có nhiều dịch vụ cần decorate hoặc áp dụng nhiều decorator chồng lên nhau.

Áp dụng Decorator với Scrutor

Scrutor làm cho việc này trở nên đơn giản hơn nhiều với phương thức mở rộng Decorate:


public void ConfigureServices(IServiceCollection services)
{
    // 1. Đăng ký dịch vụ gốc như bình thường
    services.AddScoped<IUserService, UserService>();

    // 2. Áp dụng Decorator bằng Scrutor
    services.Decorate<IUserService, LoggingUserServiceDecorator>();
}

Khi bạn yêu cầu IUserService từ DI container, Scrutor sẽ đảm bảo rằng bạn nhận được một instance của LoggingUserServiceDecorator, và instance này đã được khởi tạo với instance của UserService (hoặc bất kỳ dịch vụ nào được đăng ký trước đó cho IUserService) làm tham số đầu tiên (được gọi là dịch vụ “được decorate”).

Nếu bạn có nhiều decorator, Scrutor sẽ áp dụng chúng theo thứ tự bạn gọi Decorate:


// Giả sử bạn có thêm CachingUserServiceDecorator
services.AddScoped<IUserService, UserService>();
services.Decorate<IUserService, LoggingUserServiceDecorator>();
services.Decorate<IUserService, CachingUserServiceDecorator>();

Trong trường hợp này, khi bạn resolve IUserService:

  1. DI container thấy đăng ký cuối cùng là Decorate<IUserService, CachingUserServiceDecorator>().
  2. Nó tạo một instance của CachingUserServiceDecorator. Tham số constructor của CachingUserServiceDecorator (thường là IUserService decorated) sẽ được cung cấp bởi đăng ký trước đó.
  3. Đăng ký trước đó là Decorate<IUserService, LoggingUserServiceDecorator>(). Container tạo một instance của LoggingUserServiceDecorator. Tham số constructor của nó sẽ được cung cấp bởi đăng ký trước đó.
  4. Đăng ký trước đó là AddScoped<IUserService, UserService>(). Container tạo một instance của UserService và truyền nó vào constructor của LoggingUserServiceDecorator.
  5. Instance của LoggingUserServiceDecorator được truyền vào constructor của CachingUserServiceDecorator.
  6. Cuối cùng, bạn nhận được instance của CachingUserServiceDecorator. Khi bạn gọi một phương thức trên nó, nó sẽ gọi phương thức tương ứng trên LoggingUserServiceDecorator, và LoggingUserServiceDecorator sẽ gọi trên UserService.

Chuỗi gọi sẽ là: Request -> CachingUserServiceDecorator -> LoggingUserServiceDecorator -> UserService -> Response.

Kết hợp Quét Assembly và Decorator

Sức mạnh thực sự của Scrutor đến từ việc kết hợp hai tính năng này. Bạn có thể quét một tập hợp các dịch vụ theo quy tắc, và sau đó áp dụng một hoặc nhiều decorator cho TẤT CẢ các dịch vụ đó chỉ bằng vài dòng code.


public void ConfigureServices(IServiceCollection services)
{
    services.Scan(scan => scan
        .FromAssembliesOf<Startup>()
        .AddClasses(classes => classes.Where(type => type.Name.EndsWith("Service"))) // Ví dụ: Quét tất cả các service
        .AsImplementedInterfaces()
        .WithScopedLifetime()
    ) // Kết thúc quá trình quét

    // Bắt đầu quá trình trang trí (decorate) cho các dịch vụ đã đăng ký từ quét
    .Decorate(typeof(IUserService), typeof(LoggingUserServiceDecorator)) // Chỉ decorate IUserService
    .DecorateAllWith(typeof(ValidationServiceDecorator<>)) // Decorate TẤT CẢ các dịch vụ được quét bằng một Generic Decorator
    .Decorate(typeof(IProductService), typeof(CachingProductServiceDecorator)); // Chỉ decorate IProductService
}

Trong ví dụ này:

  • Đầu tiên, chúng ta quét tất cả các lớp có tên kết thúc bằng “Service” và đăng ký chúng với các interface mà chúng implement, với vòng đời Scoped.
  • Sau đó, chúng ta gọi DecorateDecorateAllWith trên kết quả của quá trình quét.
  • Decorate(typeof(IUserService), typeof(LoggingUserServiceDecorator)): Chỉ áp dụng LoggingUserServiceDecorator cho những dịch vụ nào được đăng ký cho interface IUserService.
  • DecorateAllWith(typeof(ValidationServiceDecorator<>)): Áp dụng ValidationServiceDecorator<> (một generic decorator) cho TẤT CẢ các dịch vụ đã được đăng ký trong quá trình quét trước đó. Đây là cách mạnh mẽ để thêm validation (hoặc logging, caching…) cho một loạt các dịch vụ mà không cần chỉ định từng dịch vụ một.
  • Decorate(typeof(IProductService), typeof(CachingProductServiceDecorator)): Chỉ áp dụng CachingProductServiceDecorator cho những dịch vụ nào được đăng ký cho interface IProductService.

Thứ tự gọi các phương thức DecorateDecorateAllWith là quan trọng, vì nó xác định thứ tự các decorator được áp dụng.

Các trường hợp sử dụng thực tế

Scrutor rất hữu ích trong nhiều tình huống:

  • Đăng ký Repository/Service layers: Tự động tìm và đăng ký tất cả các lớp repository hoặc service theo quy ước đặt tên (ví dụ: *Repository implement I*Repository) chỉ với vài dòng code. Điều này đặc biệt hữu ích khi bạn làm việc với Entity Framework Core hoặc các ORM khác.
  • Áp dụng Logging/Monitoring: Sử dụng Decorator để tự động log thời gian thực thi hoặc các tham số đầu vào/kết quả trả về cho một nhóm các dịch vụ quan trọng.
  • Caching: Áp dụng Decorator caching cho các dịch vụ đọc dữ liệu thường xuyên (ví dụ: các dịch vụ truy vấn database), tích hợp với các giải pháp cache như Redis hoặc cache in-memory.
  • Validation: Áp dụng Decorator để thực hiện validation các tham số đầu vào của phương thức dịch vụ trước khi gọi đến logic nghiệp vụ chính.
  • Retry Logic: Áp dụng Decorator để tự động thử lại (retry) các lời gọi dịch vụ khi gặp lỗi tạm thời (ví dụ: timeout, lỗi kết nối database).

Lợi ích và cân nhắc khi sử dụng Scrutor

Lợi ích

  • Giảm Boilerplate Code: Loại bỏ hàng trăm dòng đăng ký DI thủ công.
  • Tăng Khả năng Bảo trì: Khi thêm hoặc xóa dịch vụ, bạn không cần cập nhật file cấu hình DI nếu chúng tuân thủ quy tắc quét.
  • Cải thiện Tính Mở rộng: Dễ dàng áp dụng quy tắc đăng ký hoặc decorator cho một nhóm lớn các dịch vụ.
  • Áp dụng Decorator Dễ dàng: Đơn giản hóa việc triển khai Decorator pattern cho các chức năng cross-cutting.
  • Đồng nhất Cấu hình: Đảm bảo các dịch vụ tuân thủ một quy tắc cấu hình DI nhất quán.

Cân nhắc

  • Khả năng Debugging: Việc debug quá trình đăng ký ban đầu có thể khó khăn hơn so với đăng ký thủ công, vì bạn đang làm việc với các quy tắc thay vì các dòng code cụ thể. Cần hiểu rõ cách Scrutor hoạt động và các quy tắc lọc của bạn.
  • Quét quá mức: Nếu không cẩn thận với các bộ lọc (AddClasses(classes => ...)) và nguồn assembly (From*Assemblies), bạn có thể vô tình đăng ký các lớp mà bạn không muốn trở thành dịch vụ, dẫn đến các hành vi không mong muốn hoặc lỗi.
  • Hiệu năng lúc khởi động: Việc quét assembly sử dụng reflection, có thể tốn một chút thời gian lúc khởi động ứng dụng. Tuy nhiên, đối với hầu hết các ứng dụng, đây không phải là vấn đề đáng kể.

Để tổng kết, hãy xem bảng so sánh:

Tính năng Cấu hình Thủ công Sử dụng Scrutor (Scan)
Khối lượng code cấu hình Lặp lại nhiều cho mỗi cặp Interface/Implementation Tối thiểu, cấu hình dựa trên quy tắc
Bảo trì khi thêm/xóa service Cần cập nhật thủ công file cấu hình DI Tự động phát hiện service mới nếu tuân thủ quy tắc
Khả năng mở rộng cho nhiều service Khó áp dụng cùng một cấu hình/decorator cho nhiều service Dễ dàng áp dụng quy tắc hoặc decorator cho một nhóm service
Nguy cơ quên đăng ký Cao, dễ quên thêm dòng đăng ký mới Thấp, nếu quy tắc quét được cấu hình đúng
Áp dụng Decorator Cần đăng ký thủ công cho từng cặp Service/Decorator Hỗ trợ cú pháp Fluent API đơn giản, dễ dàng áp dụng nhiều decorator
Debugging cấu hình đăng ký Dễ dàng xác định lỗi đăng ký cụ thể Có thể phức tạp hơn nếu quy tắc quét/lọc sai

Kết luận

Scrutor là một công cụ vô giá trong bộ công cụ của lập trình viên .NET hiện đại, đặc biệt khi làm việc với các ứng dụng quy mô vừa và lớn sử dụng Dependency Injection. Khả năng tự động quét assembly giúp giảm đáng kể lượng mã cấu hình DI thủ công, làm cho dự án gọn gàng và dễ bảo trì hơn.

Bên cạnh đó, tính năng hỗ trợ Decorator pattern của Scrutor mở ra cánh cửa cho việc triển khai các chức năng cross-cutting một cách sạch sẽ và hiệu quả, tách biệt logic nghiệp vụ chính khỏi các vấn đề kỹ thuật phụ trợ như logging, caching hay validation. Việc nắm vững cách sử dụng Scrutor không chỉ giúp bạn viết code tốt hơn mà còn thể hiện sự hiểu biết sâu sắc về kiến trúc ứng dụng hiện đại. Hãy thêm Scrutor vào “Lộ trình .NET” của bạn ngay hôm nay!

Chỉ mục