.NET全域性靜態可訪問IServiceProvider(支援Blazor)

nigture發表於2024-09-19

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端專案

  1. 安裝NuGet

    啟動專案引用DependencyInjection.StaticAccessor.Blazor

    dotnet add package DependencyInjection.StaticAccessor.Blazor

    非啟動專案引用DependencyInjection.StaticAccessor

    dotnet add package DependencyInjection.StaticAccessor

  2. 初始化

    var builder = WebApplication.CreateBuilder();
    
    builder.Host.UsePinnedScopeServiceProvider(); // 唯一初始化步驟
    
    var app = builder.Build();
    
    app.Run();
    
  3. 頁面繼承PinnedScopeComponentBase

    推薦直接在_Imports.razor中宣告。

    // _Imports.razor
    
    @inherits DependencyInjection.StaticAccessor.Blazor.PinnedScopeComponentBase
    

Client端專案

與Server端步驟基本一致,只是引用的NuGet有所區別:

  1. 安裝NuGet

    啟動專案引用DependencyInjection.StaticAccessor.Blazor.WebAssembly

    dotnet add package DependencyInjection.StaticAccessor.Blazor.WebAssembly

    非啟動專案引用DependencyInjection.StaticAccessor

    dotnet add package DependencyInjection.StaticAccessor

  2. 初始化

    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    
    builder.UsePinnedScopeServiceProvider();
    
    await builder.Build().RunAsync();
    
  3. 頁面繼承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,還提供了PinnedScopeOwningComponentBasePinnedScopeLayoutComponentBase,後續會根據需要可能會加入更多型別。如有需求,也歡迎反饋和提交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,直接支援官方的CreateScopeCreateAsyncScope方法。同時擴充套件包Rougamo.Extensions.DependencyInjection.AspNetCoreRougamo.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

相關文章