EFCore查缺补漏(一):依赖注入
前段時間,在群里潛水的時候,看見有個群友的報錯日志是這樣的:
An unhandled exception was thrown by the application. System.OutOfMemoryException: Exception of type 'System.OutOfMemoryException' was thrown.at System.Threading.Thread.StartInternal()at Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider..ctor(IOptionsMonitor`1 options)at …at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitCache(ServiceCallSite callSite, RuntimeResolverContext context, ServiceProviderEngineScope serviceProviderEngine, RuntimeResolverLock lockType)at …at Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetService[T](IServiceProvider provider)at Microsoft.Extensions.Logging.LoggerFactory.Create(Action`1 configure)at xxxxxxx. <>c__DisplayClass2_0.<AddXxxDbContext>b__0(DbContextOptionsBuilder builder)at Microsoft.Extensions.DependencyInjection.EntityFrameworkServiceCollectionExtensions.CreateDbContextOptions[TContext](IServiceProvider applicationServiceProvider, Action`2 optionsAction)at …嗯……內存滿了?是在構建 ConsoleLoggerProvider 的時候報的異常?是由依賴注入容器產生的?再上層是 AddXxxDbContext?
好吧,看來一定是位沒研究過 EFCore 源碼也沒看過與本文類似內容的仁兄……我甚至能反推出他寫的代碼:
public class Startup {public void ConfigureServices(IServiceCollection services){services.AddDbContext<MyDbContext>(options =>{// ...options.UseLoggerFactory(LoggerFactory.Create(b => b.AddConsole().AddDebug()));});// ...}// ... }C#
看,這個調用堆棧是不是對上味兒了。
接下來我將介紹這個bug產生的原因,并帶各位看官一窺 DbContext、DbContextOptions、EFCore內部類的大致生命周期。
本文所有知識均基于 EFCore 3.1 版本,EFCore 5.0 對這部分幾乎沒有改動。
另外,如果有興趣調試 EFCore 的源碼,可以 clone 下來某個 release 版本,然后保留 EFCore/Abstractions/Analyzers/Relational/SqlServer 這幾個項目,然后開一個自己的命令行或者單元測試項目,就可以盡情遨游 EFCore 的源碼了。
讀代碼前,請儲備一定量的英文知識和自信。很多代碼的意思都寫在變量名和函數名上了,大部分源代碼讀起來并不是什么很難的事情:)
誰實例化了 DbContext?
常見有兩種方式來構建 DbContext,一種是直接拿來 new 一個,構造函數傳入 DbContextOptions 或者什么都不傳入;一種是在 ASP.NET Core 中常用的?services.AddDbContext<...>(...),然后通過某個服務的構造函數或者?IServiceProvider?取得該 DbContext 實例。后者要求該 DbContext 只實現一個構造函數,該構造函數只接受一個參數?DbContextOptions<MyDbContext>。
關于后一種構造方式,我們將父依賴注入容器稱為 Application ServiceProvider。
首先需要明確的一點是,DbContext 的構造是由父依賴注入容器實現的。而構造函數要求檢測僅僅是 EFCore 那個拓展函數進行的檢查。
我們先來看各個?AddDbContext?的核心操作函數吧。
public static IServiceCollection AddDbContext<TContextService, TContextImplementation>([NotNull] this IServiceCollection serviceCollection,[CanBeNull] Action<IServiceProvider, DbContextOptionsBuilder> optionsAction,ServiceLifetime contextLifetime = ServiceLifetime.Scoped,ServiceLifetime optionsLifetime = ServiceLifetime.Scoped)where TContextImplementation : DbContext, TContextService {Check.NotNull(serviceCollection, nameof(serviceCollection));if (contextLifetime == ServiceLifetime.Singleton){optionsLifetime = ServiceLifetime.Singleton;}if (optionsAction != null){CheckContextConstructors<TContextImplementation>();}AddCoreServices<TContextImplementation>(serviceCollection, optionsAction, optionsLifetime);serviceCollection.TryAdd(new ServiceDescriptor(typeof(TContextService), typeof(TContextImplementation), contextLifetime));return serviceCollection; }C#
Copy
在這里可以看到:
我們可以修改?DbContextOptions?和?DbContext?的生命周期為 Singleton 或者 Transient,而不是默認的 Scoped
當檢測到對?DbContextOptionsBuilder?的調用時,會檢查構造函數是否符合要求
TContextImplementation?是被構造的 DbContext 實例類型,直接由該依賴注入容器構造
而?AddCoreServices?函數則是將?DbContextOptions?實例注入容器。
private static void AddCoreServices<TContextImplementation>(IServiceCollection serviceCollection,Action<IServiceProvider, DbContextOptionsBuilder> optionsAction,ServiceLifetime optionsLifetime)where TContextImplementation : DbContext {serviceCollection.TryAdd(new ServiceDescriptor(typeof(DbContextOptions<TContextImplementation>),p => CreateDbContextOptions<TContextImplementation>(p, optionsAction),optionsLifetime));serviceCollection.Add(new ServiceDescriptor(typeof(DbContextOptions),p => p.GetRequiredService<DbContextOptions<TContextImplementation>>(),optionsLifetime)); }C#
Copy
在這里可以看到:
容器中可能具有很多個?DbContextOptions?實例,可以通過?IEnumerable<DbContextOptions>?拿到全部;這一設計是由于一個依賴注入容器中可以加入多個?DbContext?類型
對于每一個特性類型的 DbContext (以下寫為 MyDbContext),都會有一個?DbContextOptions<MyDbContext>?與之對應
我們在構造函數處用到的?DbContextOptionsBuilder?和?Microsoft.Extensions.Options?其實沒什么關系,不能用?IOptions<TOptions>?拿到,只是恰巧都叫?XxxxxxOptions?而已
每次新構造 DbContextOptions 實例時,都會使用傳入的?Action<IServiceProvider, DbContextOptionsBuilder>?函數;此時第一個參數顯然是當前的依賴注入容器,例如發生 HTTP 請求時?HttpContext.RequestService?的容器 Scope;或者 DbContextOptions 單例注入時,?IHost.Services?這種容器根
實際構建結果是由?CreateDbContextOptions?函數創造的
那么再來看看?CreateDbContextOptions?的實現。
private static DbContextOptions<TContext> CreateDbContextOptions<TContext>([NotNull] IServiceProvider applicationServiceProvider,[CanBeNull] Action<IServiceProvider, DbContextOptionsBuilder> optionsAction)where TContext : DbContext {var builder = new DbContextOptionsBuilder<TContext>(new DbContextOptions<TContext>(new Dictionary<Type, IDbContextOptionsExtension>()));builder.UseApplicationServiceProvider(applicationServiceProvider);optionsAction?.Invoke(applicationServiceProvider, builder);return builder.Options; }C#
Copy
可以看到,DbContextOptionsBuilder.UseApplicationServiceProvider?實際上是被執行過的,并且恰好指向父依賴注入容器。
此時會發現,我們在單元測試時,不創建依賴注入容器而直接實例化 DbContext 的時候,是沒有這一步的。這就是為什么兩者有時表現不同,例如直接實例化 Builder 拿到 Options,并且沒有?UseLoggerFactory?和?UseApplicationServiceProvider?時,它不會有日志輸出。至于日志那部分是怎么構建的呢,暫且按下不表。
而我們會看到網上有些文章說,因為某某原因,選擇?services.AddEntityFrameworkSqlServer()?然后?options.UseInternalServiceProvider(..)?的,其實是將兩個依賴注入容器合二為一了。具體好壞,還是使用者自行定奪。
DbContext 實例化時做了些什么?
看到上面那個圖了嗎。我們會發現,原來 EFCore 的內部容器也是分 Singleton 和 Scoped 的。
先來看看 DbContext 的這樣一個 private 成員屬性 InternalServiceProvider。
private IServiceProvider InternalServiceProvider {get{CheckDisposed();if (_contextServices != null){return _contextServices.InternalServiceProvider;}if (_initializing){throw new InvalidOperationException(CoreStrings.RecursiveOnConfiguring);}try{_initializing = true;var optionsBuilder = new DbContextOptionsBuilder(_options);OnConfiguring(optionsBuilder);if (_options.IsFrozen&& !ReferenceEquals(_options, optionsBuilder.Options)){throw new InvalidOperationException(CoreStrings.PoolingOptionsModified);}var options = optionsBuilder.Options;_serviceScope = ServiceProviderCache.Instance.GetOrAdd(options, providerRequired: true).GetRequiredService<IServiceScopeFactory>().CreateScope();var scopedServiceProvider = _serviceScope.ServiceProvider;var contextServices = scopedServiceProvider.GetService<IDbContextServices>();contextServices.Initialize(scopedServiceProvider, options, this);_contextServices = contextServices;DbContextDependencies.InfrastructureLogger.ContextInitialized(this, options);}finally{_initializing = false;}return _contextServices.InternalServiceProvider;} }C#
Copy
可以觀察到如下事實:
除了外部的?DbContextOptions?實例,內部可能也會用?OnConfiguring?函數修改這個 Options,這樣保證了兩者的配置都會被應用;當使用?DbContextPool?時,內部函數是不能修改配置的
DbContext 的每個執行指令都是在內部容器的一個 Service Scope 中執行
每次創建 Service Scope 之后,會取出其中 Scoped 服務?IDbContextServices,并將這個 DbContext 實例和 DbContextOptions 保存進這個 Service Scope
內部容器的獲取是由?ServiceProviderCache.Instance.GetOrAdd(options, providerRequired: true)?操作的;此時拿到的一般都是內部容器的根容器
這個?ServiceProviderCache?的源碼處于?src\EFCore\Internal\ServiceProviderCache.cs。
在解析?GetOrAdd?函數之前,我們需要了解這樣一個結構:IDbContextOptionsExtension。這個結構具有幾個基本功能:
向依賴注入容器注冊依賴服務
驗證當前?IDbContextOptions?是否正確配置,是否具有沖突
告訴 EFCore 該拓展是否提供數據庫底層功能(即 Database Provider,例如提供 SQL Server 相關依賴、數據庫連接信息等)
提供調試信息、日志片段(就是初始化 DbContext 時出現的類似?initialized 'MyDbContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer' with options:...?的地方添加的)
實現函數?long GetServiceProviderHashCode(),當這個 EFCore 插件包括某些不太方便通過 Scoped 服務修改的 Singleton 信息時(例如 SensitiveDataLoggingEnabled),這里應該返回一個與這些配置有關的值,同時保證:對于相同的配置,返回相同的值;對于不同的配置,返回不同的值。
例如 DbContextOptionsBuilder 中很多函數都是修改?CoreOptionsExtension?完成的。
再看看 EFCore 的內部容器中有哪些類,其對應生命周期是什么樣的。此處建議參考?src/EFCore/Infrastructure/EntityFrameworkServicesBuilder.cs。這個代碼文件中規定了每個類的生命周期,以及是否可以注冊多個。
可以注意到,有這樣一些類有著對應的生命周期:
Singleton: - IDatabaseProvider - IDbSetFinder - IModelCustomizer - ILoggingOptions - IMemoryCacheScoped: - IInterceptors - ILoggerFactory - IModel - IDbContextServices - IChangeTrackerFactory - IDiagnosticsLogger<> - IQueryCompiler - IQueryContextFactory - IAsyncQueryProvider - ICurrentDbContext - IDbContextOptions接下來看拿到內部容器的邏輯。
public virtual IServiceProvider GetOrAdd([NotNull] IDbContextOptions options, bool providerRequired) {var coreOptionsExtension = options.FindExtension<CoreOptionsExtension>();var internalServiceProvider = coreOptionsExtension?.InternalServiceProvider;if (internalServiceProvider != null){ValidateOptions(options);var optionsInitializer = internalServiceProvider.GetService<ISingletonOptionsInitializer>();if (optionsInitializer == null){throw new InvalidOperationException(CoreStrings.NoEfServices);}if (providerRequired){optionsInitializer.EnsureInitialized(internalServiceProvider, options);}return internalServiceProvider;}if (coreOptionsExtension?.ServiceProviderCachingEnabled == false){return BuildServiceProvider().ServiceProvider;}var key = options.Extensions.OrderBy(e => e.GetType().Name).Aggregate(0L, (t, e) => (t * 397) ^ ((long)e.GetType().GetHashCode() * 397) ^ e.Info.GetServiceProviderHashCode());return _configurations.GetOrAdd(key, k => BuildServiceProvider()).ServiceProvider;(IServiceProvider ServiceProvider, IDictionary<string, string> DebugInfo) BuildServiceProvider(){... 此處省略} }C#
Copy
嗯,這個邏輯很好盤,而且 99.99% 的情況下大家都只使用了默認配置,即:通過?GetServiceProviderHashCode?函數來計算哈希值,然后從?ServiceProviderCache?內部的一個緩存表中取得之前創建的容器,或者構建一個新的容器。
我們可能會發現,第一次使用 DbContext 的時候,加載時間很長;經過兩三秒才能實例化完成;第二次使用的時候,基本上就是瞬間實例化成功了。但我們通過在上層依賴注入容器的?AddDbContext?處做手腳,或者通過重寫?OnConfiguring?函數,更改了?DbContextOptions?之后,或者實例化另一個不同類型的 DbContext,又會花很久時間才能實例化成功。應證了上面的說法。
如果每次構建 DbContext 實例時都創建一個全新的內部容器,這樣會有大量的性能浪費。
那么我們再來觀察一下?DbContextOptionsBuilder?有哪些方法。
- UseSqlServer / UseNpgSql / UseInMemoryDatabase - Use第三方插件1/2/3 - EnableDetailedErrors - UseInternalServiceProvider - EnableSensitiveDataLogging - EnableServiceProviderCaching - ConfigureWarnings - UseMemoryCache - ReplaceService --- 一條樸實無華的分割線 --- - UseModel - UseLoggerFactory - UseApplicationServiceProvider - UseQueryTrackingBehavior - AddInterceptorsCoreOptionsExtension?的?long GetServiceProviderHashCode()?會包括?IMemoryCache、SensitiveDataLoggingEnabled、DetailedErrorsEnabled、WarningsConfiguration、通過?ReplaceService?修改的那些服務。
可以注意到,其中有些控制的是 Singleton 服務或者決定了實例化的結果,例如?UseMemoryCache、UseSqlServer、ReplaceService,如果每次拿到的?DbContextOptions?實例中的?IMemoryCache?或者數據庫類型不一樣,那么此時肯定需要構建一個新的依賴注入容器。而有些東西控制的是 Scoped 服務,例如?UseLoggerFactory、UseModel、數據庫連接字符串,在一般場景下是不需要重新構建容器的。
也就是說,如果不動態改變分割線上方的那些狀態,并且你使用的第三方插件編寫很科學,是不會每次都構建新的內部容器的。
內部容器如何取得 ILoggerFactory?
內部的服務當然是從內部容器構建的了。
先以?ILoggerFactory?為例,看看為什么 EFCore 能拿到父容器的?ILoggerFactory。
回到上面?EntityFrameworkServicesBuilder,我們可以看到一行
TryAdd<ILoggerFactory>(p => ScopedLoggerFactory.Create(p, null));C#
Copy
轉到這個函數,我們可以看到
public static ScopedLoggerFactory Create([NotNull] IServiceProvider internalServiceProvider,[CanBeNull] IDbContextOptions contextOptions) {var coreOptions= (contextOptions ?? internalServiceProvider.GetService<IDbContextOptions>())?.FindExtension<CoreOptionsExtension>();if (coreOptions != null){if (coreOptions.LoggerFactory != null){return new ScopedLoggerFactory(coreOptions.LoggerFactory, dispose: false);}var applicationServiceProvider = coreOptions.ApplicationServiceProvider;if (applicationServiceProvider != null&& applicationServiceProvider != internalServiceProvider){var loggerFactory = applicationServiceProvider.GetService<ILoggerFactory>();if (loggerFactory != null){return new ScopedLoggerFactory(loggerFactory, dispose: false);}}}return new ScopedLoggerFactory(new LoggerFactory(), dispose: true); }C#
Copy
即:先看?CoreOptionsExtension?中是否有之前?optionsBuilder.UseLoggerFactory?指定的;如果沒有,再到?ApplicationServiceProvider?中找一個?ILoggerFactory;再如果真的沒有,就不用了。
回顧開頭的內存溢出問題:為什么呢?
DbContextOptions?未經修改的默認生命周期是 Scoped,也就是在父容器中每次實例化一個?DbContextOptions,就會調用一次?LoggerFactory.Create(b => b.AddConsole()),并且并沒有照顧到它的 Dispose。而?ConsoleLoggerProvider?每次會建立一個新的線程去輸出日志,沒有被回收,于是……內存就在一次又一次請求中消耗殆盡了。
再回過來想想,既然能調用到父容器的?ILoggerFactory,他又為什么會用?LoggerFactory.Create?呢?……一定是?Microsoft.EntityFrameworkCore?開頭的日志被父容器的設置禁用了,所以沒有輸出。
如何把玩其他內部服務?
觀察到?DbContext?實現了?IInfrastructure<IServiceProvider>?這一接口,這個接口要求保存一個?IServiceProvider?的實例,而其實現直接指向了?InternalServiceProvider?這一私有屬性。
那先談談這個?IInfrastructure<IServiceProvider>?接口的作用吧。這個接口同時在?DbSet<T>?和?DatabaseFacade?中也有實現。在?Microsoft.EntityFrameworkCore.Infrastructure.AccessorExtensions?中,我們有一個針對這個接口的拓展函數?TService GetService<TService>([NotNull] this IInfrastructure<IServiceProvider> accessor)。
也就是說,我們在引入?Microsoft.EntityFrameworkCore.Infrastructure?命名空間之后,可以通過?DbContext.GetService<T>()?來拿到一部分服務。
其進一步的查找邏輯為:先在 EFCore 內部直接使用的依賴注入容器(即?InternalServiceProvider)中查找,再去上一層依賴注入容器中查找。
這個函數在 EFCore 中用的很少,基本上只用于靜態函數,或者非靜態函數中傳入 DbContext 實例時需要拿到某個服務時才會用到。
例如,如果是在寫某個 EFCore 的拓展函數,傳入只有?DbSet<T>?的實例,但我們想拿到這個?DbContext,不用反射之類的奇怪功能,要如何拿到呢?通常可以用?dbSetInstance.GetService<ICurrentDbContext>().Context?拿到實例。
好了,容器都拿到了,該咋玩咋玩吧……
課后習題
已知數據庫模型是通過?IModelCustomizer?進行構建的,需要達到這樣的效果:
一個模塊化的應用
每個模塊可以向父容器注冊很多個功能類似于?Action<ModelBuilder>?的東西
希望在構建數據庫的?IModel?時,對著?ModelBuilder?執行這些操作
這樣可以不修改 DbContext 本身的代碼,而將所需的實體信息加載到 DbContext 的 Model 里。
參考答案:IDbModelSupplier設計?+?AddDbContext部分
總結
以上是生活随笔為你收集整理的EFCore查缺补漏(一):依赖注入的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 深度解读.NET 5授权中间件的执行策略
- 下一篇: 阅读源码的真正价值