Hiểu Về Sử Dụng DbContext Trong EFCore Đến Triển Khai Tự Động Tiêm Thuộc Tính Với DI

Bối Cảnh

Trong quá trình di chuyển dự án từ Framework sang .NET Core 3.0, tôi đã chọn EFCore kết hợp với MySQL cho tầng truy cập dữ liệu. Khi làm việc với EF, việc tương tác với DbContext là không thể tránh khỏi. Trong Core, cách sử dụng thông thường là: tạo một lớp XXXContext kế thừa từ DbContext, triển khai một hàm tạo có tham số DbContextOptions, và trong lớp Startup, gọi phương thức mở rộng AddDbContext của IServiceCollection để tiêm DbContext vào container DI. Sau đó, tại nơi sử dụng, nhận instance thông qua tham số của hàm tạo. Tuy nhiên, cách này thực sự không thuận tiện trong nhiều trường hợp như khi sử dụng Attribute, lớp tĩnh, hoặc khởi tạo dữ liệu khi hệ thống khởi động.

    public class BaseController : Controller
    {
        protected BlogDbContext _databaseContext;
        
        public BaseController(BlogDbContext dbContext)
        {
            _databaseContext = dbContext;
        }

        public bool BlogExists(int id)
        {
            return _databaseContext.Blogs.Any(x => x.BlogId == id);
        }
    }

    public class BlogsController : BaseController
    {
        public BlogsController(BlogDbContext dbContext) : base(dbContext) { }
    }

Mã trên cho thấy bất kỳ lớp nào kế thừa BaseController đều phải viết một hàm tạo "dư thừa". Điều này trở nên không thể chấp nhận khi có nhiều tham số hơn. Vậy làm thế nào để lấy instance DbContext một cách thanh lịch hơn?

Nguồn Gốc DbContext

  1. Tạo trực tiếp bằng từ khóa new

Quay về cách làm nguyên thủy, tạo instance bằng từ khóa new. Trong Framework khi chưa có DI, chúng ta thường làm như vậy. Tuy nhiên, trong EFCore, DbContext không còn cung cấp hàm tạo không tham số, thay vào đó là bắt buộc truyền tham số DbContextOptions, thường dùng để cấu tùy chọn cho context như loại database, chuỗi kết nối, v.v.

        public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options)
        {
        }

Mặc định, chúng ta đã cấu hình việc đăng ký context trong Startup. Nếu muốn tạo thủ công, chúng ta sẽ phải tự truyền tham số này mỗi lần. Điều này quá phức tạp. Có cách nào không cần truyền tham số? Chắc chắn là có. Chúng ta có thể bỏ hàm tạo có tham số, và ghi đè phương thức OnConfiguring trong DbContext để cấu hình:

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlite("Filename=./efcore-demo.db");
        }

Tuy nhiên, cách này vẫn chưa đủ thanh lịch vì chuỗi kết nối được hardcode trong code, không thể đọc từ file cấu hình.

  1. Lấy thủ công từ container DI

Vì chúng ta đã đăng ký context trong Startup, việc lấy instance từ container DI là khả thi. Tôi đã viết đoạn mã kiểm tra:

        var context = app.ApplicationServices.GetService<BlogDbContext>();

Tuy nhiên, đã có ngoại lệ:

Thông báo lỗi rất rõ ràng: không thể lấy dịch vụ từ root provider. Sau khi xem xét mã nguồn của framework DI, tôi phát hiện ra ngoại lệ đến từ phương thức ValidateResolution của lớp CallSiteValidator:

        public void ValidateResolution(Type serviceType, IServiceScope scope, IServiceScope rootScope)
        {
            if (ReferenceEquals(scope, rootScope)
                && _scopedServices.TryGetValue(serviceType, out var scopedService))
            {
                if (serviceType == scopedService)
                {
                    throw new InvalidOperationException(
                        Resources.FormatDirectScopedResolvedFromRootException(serviceType,
                            nameof(ServiceLifetime.Scoped).ToLowerInvariant()));
                }

                throw new InvalidOperationException(
                    Resources.FormatScopedResolvedFromRootException(
                        serviceType,
                        scopedService,
                        nameof(ServiceLifetime.Scoped).ToLowerInvariant()));
            }
        }

Tiếp tục tìm hiểu, tôi thấy phương thức GetService:

        internal object GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
        {
            if (_disposed)
            {
                ThrowHelper.ThrowObjectDisposedException();
            }

            var realizedService = RealizedServices.GetOrAdd(serviceType, _createServiceAccessor);
            _callback?.OnResolve(serviceType, serviceProviderEngineScope);
            DependencyInjectionEventSource.Log.ServiceResolved(serviceType);
            return realizedService.Invoke(serviceProviderEngineScope);
        }

Việc chỉ diễn ra khi _callback khác null. Trong lớp ServiceProvider, tôi thấy:

        internal ServiceProvider(IEnumerable<ServiceDescriptor> serviceDescriptors, ServiceProviderOptions options)
        {
            IServiceProviderEngineCallback callback = null;
            if (options.ValidateScopes)
            {
                callback = this;
                _callSiteValidator = new CallSiteValidator();
            }
            //...
        }    

Điều này cho thấy việc kiểm soát bởi ValidateScopes. Giải pháp phổ biến trên mạng là đặt ValidateScopes thành False:

      .UseDefaultServiceProvider(options =>
       {
              options.ValidateScopes = false;
       })

Tuy nhiên, cách này cực kỳ nguy hiểm.

Tại sao nguy hiểm? Root provider là gì? Điều này liên quan đến vòng đời của DI gốc. Chúng ta biết container DI được đóng gói thành đối tượng IServiceProvider, dịch vụ được lấy từ đây. Nhưng đây không phải là đối tượng đơn lẻ, nó có cấu trúc phân cấp, với root provider ở tầng cao nhất. Trong ASP.NET Core, DI có 3 chế độ dịch vụ: Singleton, Transient, Scoped. Singleton được lưu trong root provider, do đó nó có thể đảm bảo tính toàn cục singleton. Ngược lại, Scoped được lưu trong một provider cụ thể, đảm bảo singleton trong phạm vi provider đó. Transient thì được tạo mỗi khi cần và loại bỏ sau khi sử dụng.

Việc kiểm tra này được điều khiển bởi ValidateScopes trong ServiceProviderOptions. Mặc định, ASP.NET Core khi tạo HostBuilder sẽ xác định môi trường hiện tại, trong môi trường phát triển sẽ bật tính năng này:

Do đó, việc tắt xác nhận ở trên là sai. Root provider chỉ có một, nếu có singleton service tham chiếu đến một scoped service, điều này sẽ khiến scoped service đó cũng trở thành singleton. Nhìn vào phương thức mở rộng đăng ký DbContext, nó thực tế cung cấp dịch vụ scoped:

Nếu xảy ra tình huống này, kết nối database sẽ không được giải phóng, hậu quả thì ai cũng biết.

Vì vậy, đoạn mã kiểm tra nên được viết như sau:

     using (var serviceScope = app.ApplicationServices.CreateScope())
     {
         var context = serviceScope.ServiceProvider.GetService<BlogDbContext>();
     }

Liên quan đến đó còn có thuộc tính ValidateOnBuild, có nghĩa là việc kiểm tra sẽ diễn ra khi xây dựng IServiceProvider, điều này cũng được thể hiện trong mã nguồn:

            if (options.ValidateOnBuild)
            {
                List<Exception> exceptions = null;
                foreach (var serviceDescriptor in serviceDescriptors)
                {
                    try
                    {
                        _engine.ValidateService(serviceDescriptor);
                    }
                    catch (Exception e)
                    {
                        exceptions = exceptions ?? new List<Exception>();
                        exceptions.Add(e);
                    }
                }

                if (exceptions != null)
                {
                    throw new AggregateException("Một số dịch vụ không thể được khởi tạo", exceptions.ToArray());
                }
            }

Chính vì vậy, ASP.NET Core khi thiết kế đã tạo ra Scope độc lập cho mỗi yêu cầu, Scope provider này được đóng gói trong HttpContext.RequestServices.

[Chuyện nhỏ]

Qua gợi ý code, chúng ta thấy IServiceProvider cung cấp 2 cách lấy service:

Sự khác biệt giữa chúng là gì? Xem xét tóm tắt từng phương thức, chúng ta thấy rằng GetService trả về null khi không tìm thấy service đã đăng ký, trong khi GetRequiredService sẽ ném ra ngoại lệ InvalidOperationException.

        // Kết quả trả về:
        //     Một đối tượng dịch vụ kiểu T hoặc null nếu không có dịch vụ như vậy.
        public static T GetService<T>(this IServiceProvider provider);

        // Kết quả trả về:
        //     Một đối tượng dịch vụ kiểu T.
        //
        // Ngoại lệ:
        //   T:System.InvalidOperationException:
        //     Không có dịch vụ kiểu T.
        public static T GetRequiredService<T>(this IServiceProvider provider);

Giải pháp cuối cùng

Đến giờ này, dù đã tìm thấy giải pháp hợp lý, nhưng vẫn chưa đủ thanh lịch. Bạn nào đã dùng các framework DI khác thì biết cảm giác của property injection không gì sánh bằng. Vậy liệu DI gốc có hỗ trợ tính năng này không? Tôi vui vẻ lên GitHub tìm Issue và thấy câu trả lời này:

Chính thức tuyên bố không có kế hoạch phát triển property injection. Không còn cách nào khác, đành tự mình làm.

Ý tưởng của tôi là: tạo một custom attribute để đánh dấu các thuộc tính cần tiêm, viết một service activator để phân tích và gán giá trị cho các thuộc tính đã đánh dấu, và gọi activator này khi instance được tạo ra. Điểm quan trọng là lấy instance từ DI container phải đảm bảo cùng Scope với request hiện tại, tức là phải lấy IServiceProvider từ HttpContext hiện tại.

Đầu tiên tạo custom attribute:

    [AttributeUsage(AttributeTargets.Property)]
    public class InjectAttribute : Attribute
    {

    }

Phương thức phân tích thuộc tính:

        public void InjectProperties(object target, IServiceProvider provider)
        {
            var targetType = target.GetType();
            var properties = targetType.GetProperties().Where(p => p.Name.StartsWith("_"));
            foreach (var property in properties)
            {
                var injectAttr = property.GetCustomAttribute<InjectAttribute>();
                if (injectAttr != null)
                {
                    var serviceInstance = provider.GetService(property.PropertyType);
                    if (serviceInstance != null)
                    {
                        // Giải quyết vấn đề lồng ghép dịch vụ
                        InjectProperties(serviceInstance, provider);
                        // Gán giá trị thuộc tính
                        property.SetValue(target, serviceInstance);
                    }
                }
            }
        }

Sau đó kích hoạt thuộc tính trong controller:

        [Inject]
        public IUserService _userService { get; set; }

        public LoginController(IHttpContextAccessor httpContextAccessor)
        {
            var injector = new PropertyInjector();
            injector.InjectProperties(this, httpContextAccessor.HttpContext.RequestServices);
        }

Mặc dù đã thực hiện được chức năng, nhưng vẫn còn một vài vấn đề. Thứ nhất là do trong hàm tạo controller không thể trực tiếp sử dụng thuộc tính HttpContext của ControllerBase, nên phải tiêm IHttpContextAccessor để lấy, vấn đề lại quay về điểm xuất phát. Thứ hai là mỗi hàm tạo đều phải viết một đoạn code như vậy, không thể chấp nhận được. Vậy có cách nào thực hiện thao tác khi controller được kích hoạt không? Tôi không cân nhắc引入 AOP framework, cảm thấy quá nặng cho một chức năng này. Sau khi tìm kiếm, tôi phát hiện ASP.NET Core kích hoạt controller thông qua giao diện IControllerActivator, với triển khai mặc định là DefaultControllerActivator:

       /// <inheritdoc />
        public object Create(ControllerContext controllerContext)
        {
            if (controllerContext == null)
            {
                throw new ArgumentNullException(nameof(controllerContext));
            }

            if (controllerContext.ActionDescriptor == null)
            {
                throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull(
                    nameof(ControllerContext.ActionDescriptor),
                    nameof(ControllerContext)));
            }

            var controllerTypeInfo = controllerContext.ActionDescriptor.ControllerTypeInfo;

            if (controllerTypeInfo == null)
            {
                throw new ArgumentException(Resources.FormatPropertyOfTypeCannotBeNull(
                    nameof(controllerContext.ActionDescriptor.ControllerTypeInfo),
                    nameof(ControllerContext.ActionDescriptor)));
            }

            var serviceProvider = controllerContext.HttpContext.RequestServices;
            return _typeActivatorCache.CreateInstance<object>(serviceProvider, controllerTypeInfo.AsType());
        }

Thế là tôi tự triển khai một Controller activator:

    public class CustomControllerActivator : IControllerActivator
    {
        public object Create(ControllerContext actionContext)
        {
            var controllerType = actionContext.ActionDescriptor.ControllerTypeInfo.AsType();
            var instance = actionContext.HttpContext.RequestServices.GetRequiredService(controllerType);
            InjectProperties(instance, actionContext.HttpContext.RequestServices);
            return instance;
        }

        public virtual void Release(ControllerContext context, object controller)
        {
            if (context == null)
            {
                throw new ArgumentNullException(nameof(context));
            }
            if (controller == null)
            {
                throw new ArgumentNullException(nameof(controller));
            }
            if (controller is IDisposable disposable)
            {
                disposable.Dispose();
            }
        }

        private void InjectProperties(object target, IServiceProvider provider)
        {
            var targetType = target.GetType();
            var properties = targetType.GetProperties().Where(p => p.Name.StartsWith("_"));
            foreach (var property in properties)
            {
                var injectAttr = property.GetCustomAttribute<InjectAttribute>();
                if (injectAttr != null)
                {
                    var serviceInstance = provider.GetService(property.PropertyType);
                    if (serviceInstance != null)
                    {
                        // Giải quyết vấn đề lồng ghép dịch vụ
                        InjectProperties(serviceInstance, provider);
                        // Gán giá trị thuộc tính
                        property.SetValue(target, serviceInstance);
                    }
                }
            }
        }
    }

Lưu ý rằng, DefaultControllerActivator lấy instance controller từ TypeActivatorCache, trong khi activator tùy chỉnh của chúng ta lấy từ DI, do đó phải đăng ký tất cả controller vào DI, đóng gói thành phương thức mở rộng:

        /// <summary>
        /// Tích hợp activator tùy chỉnh và đăng ký thủ công tất cả controller
        /// </summary>
        /// <param name="services"></param>
        /// <param name="obj"></param>
        public static void AddCustomControllers(this IServiceCollection services, object obj)
        {
            services.Replace(ServiceDescriptor.Transient<IControllerActivator, CustomControllerActivator>());
            var assembly = obj.GetType().GetTypeInfo().Assembly;
            var manager = new ApplicationPartManager();
            manager.ApplicationParts.Add(new AssemblyPart(assembly));
            manager.FeatureProviders.Add(new ControllerFeatureProvider());
            var feature = new ControllerFeature();
            manager.PopulateFeature(feature);
            feature.Controllers.Select(ti => ti.AsType()).ToList().ForEach(t =>
            {
                services.AddTransient(t);
            });
        }

Gọi trong ConfigureServices:

services.AddCustomControllers(this);

Đến đây, công việc hoàn thành! Có thể tiếp tục CRUD một cách thoải mái.

Kết luận

Có rất nhiều framework DI hữu dụng trên thị trường, tích hợp vào Core cũng rất đơn giản, tại sao còn phải vất vả như vậy? Không có cách nào khác, đây không phải là niềm vui khi tự làm bánh xe sao? Những thứ trên đã tốn không ít thời gian, phần property injection vẫn còn không gian để tối ưu, mọi người cùng thảo luận nhé.

Thẻ: Entity-Framework-Core dependency-injection ASP.NET-Core DbContext Property-Injection

Đăng vào ngày 23 tháng 6 lúc 02:14