DependencyInjection.StaticAccessor
https://github.com/inversionhourglass/DependencyInjection.StaticAccessor
前言
如何在靜態方法中訪問DI容器長期以來一直都是一個令人苦惱的問題,特別是對於熱愛編寫擴充套件方法的朋友。之所以會為這個問題苦惱,是因為一個特殊的服務生存期——範圍內(Scoped),所謂的Scoped就是範圍內單例,最常見的WebAPI/MVC中一個請求對應一個範圍,所有註冊為Scoped的物件在同一個請求中是單例的。如果僅僅用一個靜態欄位儲存應用啟動時建立出的IServiceProvider
物件,那麼在一個請求中透過該欄位是無法正確獲取當前請求中建立的Scoped物件的。
在早些時候有針對肉夾饃(Rougamo)訪問DI容器釋出了一些列NuGet,由於肉夾饃不僅能應用到例項方法上還能夠應用到靜態方法上,所以肉夾饃訪問DI容器的根本問題就是如何在靜態方法中訪問DI容器。考慮到靜態方法訪問DI容器是一個常見的公共問題,所以現在將核心邏輯抽離成一系列單獨的NuGet包,方便不使用肉夾饃的朋友使用。
快速開始
啟動專案引用DependencyInjection.StaticAccessor.Hosting
dotnet add package DependencyInjection.StaticAccessor.Hosting
非啟動專案引用DependencyInjection.StaticAccessor
dotnet add package DependencyInjection.StaticAccessor
// 1. 初始化。這裡用通用主機進行演示,其他型別專案後面將分別舉例
var builder = Host.CreateDefaultBuilder();
builder.UsePinnedScopeServiceProvider(); // 僅此一步完成初始化
var host = builder.Build();
host.Run();
// 2. 在任何地方獲取
class Test
{
public static void M()
{
var yourService = PinnedScope.ScopedServices.GetService<IYourService>();
}
}
如上示例,透過靜態屬性PinnedScope.ScopedServices
即可獲取當前Scope的IServiceProvider
物件,如果當前不在任何一個Scope中時,該屬性返回根IServiceProvider
。
版本說明
由於DependencyInjection.StaticAccessor
的實現包含了透過反射訪問微軟官方包非public成員,官方的內部實現隨著版本的迭代也在不斷地變化,所以針對官方包不同版本釋出了對應的版本。DependencyInjection.StaticAccessor
的所有NuGet包都採用語義版本號格式(SemVer),其中主版本號與Microsoft.Extensions.*
相同,次版本號為功能釋出版本號,修訂號為BUG修復及微小改動版本號。請各位在安裝NuGet包時選擇與自己引用的Microsoft.Extensions.*
主版本號相同的最新版本。
另外需要說明的是,由於我本地建立blazor專案時只能選擇.NET8.0,所以blazor相關包僅提供了8.0版本,如果確實有低版本的需求,可以到github中提交issue。
WebAPI/MVC初始化示例
啟動專案引用DependencyInjection.StaticAccessor.Hosting
dotnet add package DependencyInjection.StaticAccessor.Hosting
非啟動專案引用DependencyInjection.StaticAccessor
dotnet add package DependencyInjection.StaticAccessor
var builder = WebApplication.CreateBuilder();
builder.Host.UsePinnedScopeServiceProvider(); // 唯一初始化步驟
var app = builder.Build();
app.Run();
Blazor使用示例
Blazor的DI Scope是一個特殊的存在,在WebAssembly模式下Scoped等同於單例;而在Server模式下,Scoped對應一個SignalR連線。針對Blazor的這種特殊的Scope場景,除了初始化操作,還需要一些額外操作。
我們知道,Blazor專案在建立時可以選擇互動渲染模式,除了Server模式外,其他的模式都會建立兩個專案,多出來的這個專案的名稱以.Client
結尾。這裡我稱.Client
專案為Client端專案,另一個專案為Server端專案(Server模式下唯一的那個專案也稱為Server端專案)。
Server端專案
-
安裝NuGet
啟動專案引用
DependencyInjection.StaticAccessor.Blazor
dotnet add package DependencyInjection.StaticAccessor.Blazor
非啟動專案引用
DependencyInjection.StaticAccessor
dotnet add package DependencyInjection.StaticAccessor
-
初始化
var builder = WebApplication.CreateBuilder(); builder.Host.UsePinnedScopeServiceProvider(); // 唯一初始化步驟 var app = builder.Build(); app.Run();
-
頁面繼承
PinnedScopeComponentBase
推薦直接在
_Imports.razor
中宣告。// _Imports.razor @inherits DependencyInjection.StaticAccessor.Blazor.PinnedScopeComponentBase
Client端專案
與Server端步驟基本一致,只是引用的NuGet有所區別:
-
安裝NuGet
啟動專案引用
DependencyInjection.StaticAccessor.Blazor.WebAssembly
dotnet add package DependencyInjection.StaticAccessor.Blazor.WebAssembly
非啟動專案引用
DependencyInjection.StaticAccessor
dotnet add package DependencyInjection.StaticAccessor
-
初始化
var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.UsePinnedScopeServiceProvider(); await builder.Build().RunAsync();
-
頁面繼承
PinnedScopeComponentBase
推薦直接在
_Imports.razor
中宣告。// _Imports.razor @inherits DependencyInjection.StaticAccessor.Blazor.PinnedScopeComponentBase
已有自定義ComponentBase基類的解決方案
你可能會使用其他包定義的ComponentBase
基類,由於C#不支援多繼承,所以這裡提供了不繼承PinnedScopeComponentBase
的解決方案。
// 假設你現在使用的ComponentBase基類是ThirdPartyComponentBase
// 定義新的基類繼承ThirdPartyComponentBase
public class YourComponentBase : ThirdPartyComponentBase, IHandleEvent, IServiceProviderHolder
{
private IServiceProvider _serviceProvider;
[Inject]
public IServiceProvider ServiceProvider
{
get => _serviceProvider;
set
{
PinnedScope.Scope = new FoolScope(value);
_serviceProvider = value;
}
}
Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
{
return this.PinnedScopeHandleEventAsync(callback, arg);
}
}
// _Imports.razor
@inherits YourComponentBase
其他ComponentBase基類
除了PinnedScopeComponentBase
,還提供了PinnedScopeOwningComponentBase
和PinnedScopeLayoutComponentBase
,後續會根據需要可能會加入更多型別。如有需求,也歡迎反饋和提交PR.
注意事項
避免透過PinnedScope直接操作IServiceScope
雖然你可以透過PinnedScope.Scope
獲取當前的DI Scope,但最好不要透過該屬性直接操作IServiceScope
物件,比如呼叫Dispose方法,你應該透過你建立Scope時建立的變數進行操作。
不支援非通常Scope
一般日常開發時不需要關注這個問題的,通常的AspNetCore專案也不會出現這樣的場景,而Blazor就是官方專案型別中一個非通常DI Scope的案例。
在解釋什麼是非通常Scope前,我先聊聊通常的Scope模式。我們知道DI Scope是可以巢狀的,在通常情況下,巢狀的Scope呈現的是一種棧的結構,後建立的scope先釋放,井然有序。
using (var scope11 = serviceProvider.CreateScope()) // push scope11. [scope11]
{
using (var scope21 = scope11.ServiceProvider.CreateScope()) // push scope21. [scope11, scope21]
{
using (var scope31 = scope21.ServiceProvider.CreateScope()) // push scope31. [scope11, scope21, scope31]
{
} // pop scope31. [scope11, scope21]
using (var scope32 = scope21.ServiceProvider.CreateScope()) // push scope32. [scope11, scope21, scope32]
{
} // pop scope32. [scope11, scope21]
} // pop scope21. [scope11]
using (var scope22 = scope11.ServiceProvider.CreateScope()) // push scope22. [scope11, scope22]
{
} // pop scope22. [scope22]
} // pop scope11. []
瞭解了通常Scope,那麼就很好理解非通常Scope了,只要是不按照這種井然有序的棧結構的,那就是非通常Scope。比較常見的就是Blazor的這種情況:
我們知道,Blazor SSR透過SignalR實現SPA,一個SignalR連線對應一個DI Scope,介面上的各種事件(點選、獲取焦點等)透過SignalR通知服務端回撥事件函式,而這個回撥便是從外部橫插一腳與SignalR進行互動的,在不進行特殊處理的情況下,回撥事件所屬的Scope是當前回撥事件新建立的Scope,但我們在回撥事件中與之互動的Component
是SignalR所屬Scope建立的,這就出現了Scope交叉互動的情況。PinnedScopeComponentBase
所做的便是在執行回撥函式之前,將PinnedScope.Scope
重設回SignalR對應Scope。
肉夾饃相關應用
正如前面所說,DependencyInjection.StaticAccessor
的核心邏輯是從肉夾饃的DI擴充套件中抽離出來的,抽離後肉夾饃DI擴充套件將依賴於DependencyInjection.StaticAccessor
。現在你可以直接引用DependencyInjection.StaticAccessor
,然後直接透過PinnedScope.Scope
與DI進行互動,但還是推薦透過肉夾饃DI擴充套件進行互動,DI擴充套件提供了一些額外的功能,稍後將一一介紹。
DI擴充套件包變化
Autofac相關包未發生重大變化,後續介紹的擴充套件包都是官方DependencyInjection的相關擴充套件包
本次不僅僅是一個簡單的程式碼抽離,程式碼的核心實現上也有更新,更新後移出了擴充套件方法CreateResolvableScope
,直接支援官方的CreateScope
和CreateAsyncScope
方法。同時擴充套件包Rougamo.Extensions.DependencyInjection.AspNetCore
和Rougamo.Extensions.DependencyInjection.GenericHost
合併為Rougamo.Extensions.DependencyInjection.Microsoft
。
Rougamo.Extensions.DependencyInjection.Microsoft
僅定義切面型別的專案需要引用Rougamo.Extensions.DependencyInjection.Microsoft
,啟動專案根據專案型別引用DependencyInjection.StaticAccessor
相關包即可,初始化也是僅需要完成DependencyInjection.StaticAccessor
初始化即可。
更易用的擴充套件
Rougamo.Extensions.DependencyInjection.Microsoft
針對MethodContext
提供了豐富的DI擴充套件方法,簡化程式碼編寫。
public class TestAttribute : AsyncMoAttribute
{
public override ValueTask OnEntryAsync(MethodContext context)
{
context.GetService<ITestService>();
context.GetRequiredService(typeof(ITestService));
context.GetServices<ITestService>();
}
}
從當前宿主型別例項中獲取IServiceProvider
DependencyInjection.StaticAccessor
提供的是一種常用場景下獲取當前Scope的IServiceProvider
解決方案,但在千奇百怪的開發需求中,總會出現一些不尋常的DI Scope場景,比如前面介紹的非通常Scope,再比如Blazor。針對這種場景,肉夾饃DI擴充套件雖然不能幫你獲取到正確的IServiceProvider
物件,但如果你自己能夠提供獲取方式,肉夾饃DI擴充套件可以方便的整合該獲取方式。
下面以Blazor為例,雖然已經針對Blazor特殊的DI Scope提供了通用解決方案,但Blazor還存在著自己的特殊場景。我們知道Blazor SSR服務生存期是整個SignalR的生存期,這個生存期可能非常長,一個生存期期間可能會建立多個頁面(ComponentBase),這多個頁面也將共享註冊為Scoped的物件,這在某些場景下可能會存在問題(比如共享EF DBContext),所以微軟提供了OwningComponentBase
,它提供了更短的服務生存期,整合該類可以透過ScopedServices
屬性訪問IServiceProvider
物件。
// 1. 定義前鋒型別,針對OwningComponentBase返回ScopedServices屬性
public class OwningComponentScopeForward : SpecificPropertyFoolScopeProvider, IMethodBaseScopeForward
{
public override string PropertyName => "ScopedServices";
}
// 2. 初始化
var builder = WebApplication.CreateBuilder();
// 初始化DependencyInjection.StaticAccessor
builder.Host.UsePinnedScopeServiceProvider();
// 註冊前鋒型別
builder.Services.AddMethodBaseScopeForward<OwningComponentScopeForward>();
var app = builder.Build();
app.Run();
// 3. 使用
public class TestAttribute : AsyncMoAttribute
{
public override ValueTask OnEntryAsync(MethodContext context)
{
// 當TestAttribute應用到OwningComponentBase子類方法上時,ITestService將從OwningComponentBase.ScopedServices中獲取
context.GetService<ITestService>();
}
}
除了上面示例中提供的OwningComponentScopeForward
,還有根據欄位名稱獲取的SpecificFieldFoolScopeProvider
,根據宿主型別透過lambda表示式獲取的TypedFoolScopeProvider<>
,這裡就不一一舉例了,如果你的獲取邏輯更加複雜,可以直接實現先鋒型別介面IMethodBaseScopeForward
。
除了前鋒型別介面IMethodBaseScopeForward
,還提供了守門員型別介面IMethodBaseScopeGoalie
,在呼叫GetService
系列擴充套件方法時,內部實現按 [先鋒型別 -> PinnedScope.Scope.ServiceProvider -> 守門員型別 -> PinnedScope.RootServices] 的順序嘗試獲取IServiceProvider
物件。
完整示例
完整示例請訪問:https://github.com/inversionhourglass/Rougamo.DI/tree/master/samples