從EFCore上下文的使用到深刻剖析DI的生命週期最後實現自動屬性注入

故事背景

最近在把本身的一個老項目從Framework遷移到.Net Core 3.0,數據訪問這塊選擇的是EFCore+Mysql。使用EF的話不可避免要和DbContext打交道,在Core中的常規用法通常是:建立一個XXXContext類繼承自DbContext,實現一個擁有DbContextOptions參數的構造器,在啓動類StartUp中的ConfigureServices方法裏調用IServiceCollection的擴展方法AddDbContext,把上下文注入到DI容器中,而後在使用的地方經過構造函數的參數獲取實例。OK,沒任何毛病,官方示例也都是這麼來用的。可是,經過構造函數這種方式來獲取上下文實例其實很不方便,好比在Attribute或者靜態類中,又或者是系統啓動時初始化一些數據,更多的是以下一種場景:html

    public class BaseController : Controller
    {
        public BloggingContext _dbContext;
        public BaseController(BloggingContext dbContext)
        {
            _dbContext = dbContext;
        }

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

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

從上面的代碼能夠看到,任何要繼承BaseController的類都要寫一個「多餘」的構造函數,若是參數再多幾個,這將是沒法忍受的(就算只有一個參數我也忍受不了)。那麼怎樣才能更優雅的獲取數據庫上下文實例呢,我想到如下幾種辦法。git


DbContext從哪來

一、  直接開new github

迴歸原始,既然要建立實例,沒有比直接new一個更好的辦法了,在Framework中沒有DI的時候也差很少都這麼幹。但在EFCore中不一樣的是,DbContext再也不提供無參構造函數,取而代之的是必須傳入一個DbContextOptions類型的參數,這個參數一般是作一些上下文選項配置例如使用什麼類型數據庫鏈接字符串是多少。sql

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

默認狀況下,咱們已經在StartUp中註冊上下文的時候作了配置,DI容器會自動幫咱們把options傳進來。若是要手動new一個上下文,那豈不是每次都要本身傳?不行,這太痛苦了。那有沒有辦法不傳這個參數?確定也是有的。咱們能夠去掉有參構造函數,而後重寫DbContext中的OnConfiguring方法,在這個方法中作數據庫配置: 數據庫

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

即便是這樣,依然有不夠優雅的地方,那就是鏈接字符串被硬編碼在代碼中,不能作到從配置文件讀取。反正我忍受不了,只能再尋找其餘方案。mvc

二、  從DI容器手動獲取app

既然前面已經在啓動類中註冊了上下文,那麼從DI容器中獲取實例確定是沒問題的。因而我寫了這樣一句測試代碼用來驗證猜測:框架

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

不過很遺憾拋出了異常:ide

報錯信息說的很明確,不能從root provider中獲取這個服務。我從G站下載了DI框架的源碼(地址是https://github.com/aspnet/Extensions/tree/master/src/DependencyInjection),拿報錯信息進行反向追溯,發現異常來自於CallSiteValidator類的ValidateResolution方法:函數

        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()));
            }
        }
View Code

繼續往上,看到了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);
        }
View Code

能夠看到,_callback在爲空的狀況下是不會作驗證的,因而猜測有參數能對它進行配置。把追溯對象換成_callback繼續往上翻,在DI框架的核心類ServiceProvider中找到以下方法:

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

說明個人猜測沒錯,驗證是受ValidateScopes控制的。這樣來看,把ValidateScopes設置成False就能夠解決了,這也是網上廣泛的解決方案:

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

但這樣作是極其危險的。

爲何危險?到底什麼是root provider?那就要從原生DI的生命週期提及。咱們知道,DI容器被封裝成一個IServiceProvider對象,服務都是從這裏來獲取。不過這並非一個單一對象,它是具備層級結構的,最頂層的即前面提到的root provider,能夠理解爲僅屬於系統層面的DI控制中心。在Asp.Net Core中,內置的DI有3種服務模式,分別是SingletonTransientScoped,Singleton服務實例是保存在root provider中的,因此它才能作到全局單例。相對應的Scoped,是保存在某一個provider中的,它能保證在這個provider中是單例的,而Transient服務則是隨時須要隨時建立,用完就丟棄。由此可知,除非是在root provider中獲取一個單例服務,不然必需要指定一個服務範圍(Scope),這個驗證是經過ServiceProviderOptionsValidateScopes來控制的。默認狀況下,Asp.Net Core框架在建立HostBuilder的時候會斷定當前是否開發環境,在開發環境下會開啓這個驗證:

因此前面那種關閉驗證的方式是錯誤的。這是由於,root provider只有一個,若是剛好有某個singleton服務引用了一個scope服務,這會致使這個scope服務也變成singleton,仔細看一下注冊DbContext的擴展方法,它實際上提供的是scope服務:

若是發生這種狀況,數據庫鏈接會一直得不到釋放,至於有什麼後果你們應該都明白。

因此前面的測試代碼應該這樣寫:

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

與之相關的還有一個ValidateOnBuild屬性,也就是說在構建IServiceProvider的時候就會作驗證,從源碼中也能體現出來:

            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("Some services are not able to be constructed", exceptions.ToArray());
                }
            }
View Code

正由於如此,Asp.Net Core在設計的時候爲每一個請求建立獨立的Scope,這個Scope的provider被封裝在HttpContext.RequestServices中。

 [小插曲]

經過代碼提示能夠看到,IServiceProvider提供了2種獲取service的方式:

這2個有什麼區別呢?分別查看各自的方法摘要能夠看到,經過GetService獲取一個沒有註冊的服務時會返回null,而GetRequiredService會拋出一個InvalidOperationException,僅此而已。

        // 返回結果:
        //     A service object of type T or null if there is no such service.
        public static T GetService<T>(this IServiceProvider provider);

        // 返回結果:
        //     A service object of type T.
        //
        // 異常:
        //   T:System.InvalidOperationException:
        //     There is no service of type T.
        public static T GetRequiredService<T>(this IServiceProvider provider);

 

終極大招

到如今爲止,儘管找到了一種看起來合理的方案,但仍是不夠優雅,使用過其餘第三方DI框架的朋友應該知道,屬性注入的快感無可比擬。那原生DI有沒有實現這個功能呢,我滿心歡喜上G站搜Issue,看到這樣一個回覆(https://github.com/aspnet/Extensions/issues/2406):

官方明確表示沒有開發屬性注入的計劃,沒辦法,只能靠本身了。

個人思路大概是:建立一個自定義標籤(Attribute),用來給須要注入的屬性打標籤,而後寫一個服務激活類,用來解析給定實例須要注入的屬性並賦值,在某個類型被建立實例的時候也就是構造函數中調用這個激活方法實現屬性注入。這裏有個核心點要注意的是,從DI容器獲取實例的時候必定要保證是和當前請求是同一個Scope,也就是說,必需要從當前的HttpContext中拿到這個IServiceProvider

先建立一個自定義標籤:

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

    }

解析屬性的方法:

        public void PropertyActivate(object service, IServiceProvider provider)
        {
            var serviceType = service.GetType();
            var properties = serviceType.GetProperties().AsEnumerable().Where(x => x.Name.StartsWith("_"));
            foreach (PropertyInfo property in properties)
            {
                var autowiredAttr = property.GetCustomAttribute<AutowiredAttribute>();
                if (autowiredAttr != null)
                {
                    //從DI容器獲取實例
                    var innerService = provider.GetService(property.PropertyType);
                    if (innerService != null)
                    {
                        //遞歸解決服務嵌套問題
                        PropertyActivate(innerService, provider);
                        //屬性賦值
                        property.SetValue(service, innerService);
                    }
                }
            }
        }

而後在控制器中激活屬性:

        [Autowired]
        public IAccountService _accountService { get; set; }

        public LoginController(IHttpContextAccessor httpContextAccessor)
        {
            var pro = new AutowiredServiceProvider();
            pro.PropertyActivate(this, httpContextAccessor.HttpContext.RequestServices);
        }

這樣子下來,雖然功能實現了,可是裏面存着幾個問題。第一個是因爲控制器的構造函數中不能直接使用ControllerBaseHttpContext屬性,因此必需要經過注入IHttpContextAccessor對象來獲取,貌似問題又回到原點。第二個是每一個構造函數中都要寫這麼一堆代碼,不能忍。因而想有沒有辦法在控制器被激活的時候作一些操做?沒考慮引入AOP框架,感受爲了這一個功能引入AOP有點重。通過網上搜索,發現Asp.Net Core框架激活控制器是經過IControllerActivator接口實現的,它的默認實現是DefaultControllerActivatorhttps://github.com/aspnet/AspNetCore/blob/master/src/Mvc/Mvc.Core/src/Controllers/DefaultControllerActivator.cs):

       /// <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());
        }
View Code

這樣一來,我本身實現一個Controller激活器不就能夠接管控制器激活了,因而有以下這個類:

    public class HosControllerActivator : IControllerActivator
    {
        public object Create(ControllerContext actionContext)
        {
            var controllerType = actionContext.ActionDescriptor.ControllerTypeInfo.AsType();
            var instance = actionContext.HttpContext.RequestServices.GetRequiredService(controllerType);
            PropertyActivate(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 PropertyActivate(object service, IServiceProvider provider)
        {
            var serviceType = service.GetType();
            var properties = serviceType.GetProperties().AsEnumerable().Where(x => x.Name.StartsWith("_"));
            foreach (PropertyInfo property in properties)
            {
                var autowiredAttr = property.GetCustomAttribute<AutowiredAttribute>();
                if (autowiredAttr != null)
                {
                    //從DI容器獲取實例
                    var innerService = provider.GetService(property.PropertyType);
                    if (innerService != null)
                    {
                        //遞歸解決服務嵌套問題
                        PropertyActivate(innerService, provider);
                        //屬性賦值
                        property.SetValue(service, innerService);
                    }
                }
            }
        }
    }
View Code

須要注意的是,DefaultControllerActivator中的控制器實例是從TypeActivatorCache獲取的,而本身的激活器是從DI獲取的,因此必須額外把系統全部控制器註冊到DI中,封裝成以下的擴展方法:

        /// <summary>
        /// 自定義控制器激活,並手動註冊全部控制器
        /// </summary>
        /// <param name="services"></param>
        /// <param name="obj"></param>
        public static void AddHosControllers(this IServiceCollection services, object obj)
        {
            services.Replace(ServiceDescriptor.Transient<IControllerActivator, HosControllerActivator>());
            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);
            });
        }
View Code

ConfigureServices中調用:

services.AddHosControllers(this);

到此,大功告成!能夠愉快的繼續CRUD了。

 

結尾

市面上好用的DI框架一堆一堆的,集成到Core裏面也很簡單,爲啥還要這麼折騰?沒辦法,這不就是造輪子的樂趣嘛。上面這些東西從頭至尾也折騰了很多時間,屬性注入那裏也還有優化的空間,歡迎探討。

推薦閱讀:

 https://www.cnblogs.com/artech/p/inside-asp-net-core-03-05.html

 https://www.cnblogs.com/tdfblog/p/controller-activation-and-dependency-injection-in-asp-net-core-mvc.html

相關文章
相關標籤/搜索