file

.NET Core 內建的依賴注入系統中有三個不同的層次:

  • Singleton:整個 process 裡面只會存在一個實體。
  • Scoped:每個 scope 當中只會有一個實體;預設情況下每次使用者 request 都會建立新的 scope,而 scope 也可以手動產生新的。
  • Transient:每一次需要注入的時候都會重新產生服務實體。

這三個層次原本在 MVC 的架構裡面是很夠用的,可是進入到 Blazor 框架之後我們很快就會發現這有點不太夠。在 Blazor 中,預設的情況下在使用者造訪網站的期間通通都是同一個 scope,這樣的 scope 很適合例如登入服務這一類的東西(因為在使用者離開網站之前,登入狀態都是一樣的),可是卻不適合像 DBContext 這樣的東西;對於資料庫 context 來說,一直活到使用者離開網站這樣肯定是太久了,絕對會發生同時存取的問題。可是資料庫 context 卻也不適合開成 transient,因為我們很可能會有許多商業邏輯的服務想要使用同樣的資料庫 context。所以,某種意義上,我們希望資料庫 context 的生命週期是某種介於網站 scope 和 transient 之間的東西。

這本身其實並不困難,我們只要針對我們的 unit of work(UOW)另外再手動開一個新的 scope 就可以做到這一點了。也就是說,我們一樣把我們的 DBContext 註冊為 scoped,但是當我們要發起 UoW 的時候,我們需要再額外做類似這樣的事情:

// 其中 provider 是 IServiceProvider,
// 而 MyService 是執行某個 UOW 動作的入口。
var service = provider.CreateScope().ServiceProvider.GetService<MyService>();
// 然後再去呼叫 service 的方法……

此時,不管在 MyService 裡面需要注入什麼 scoped 的服務(尤其是 DBContext),因為它自己是處於一個新創造出來的 scope,依賴注入底層都會產生一個新的給它用。到此為止還沒什麼問題……

直到 MyService 裡面需要引用到某個存活於網站 scope 之下的服務。

舉例來說,假設我負責處理登入的服務叫作 MyAuth,我希望當 MyService 需要用到 MyAuth 的時候,它會拿到的是存活在整個 Blazor 網站生命週期下的那一個,而不是 MyService 所屬的 scope 中新產生的一個。也就是說,我等於是真的需要把原本 .NET Core 所謂的「scoped」再細分成兩個層次,一個是網站層次、另一個是 UOW 層次,其中 UOW 層次的可以呼叫到網站層次的服務。

據我所知 .NET Core 內建的依賴注入機制本身是沒有提供這樣的一種概念的,但是我們是有辦法自己把這樣的東西實質上刻出來的。

其辦法大致是這樣的。首先我定義這樣的一個類別:

public class SiteScopeFactory {
    public SiteScopeFactory(IServiceProvider provider) {
        Provider = provider;
    }

    private IServiceProvider Provider;

    public Dictionary<Type, object> Instances { get; set; }

    public T Get<T>() where T : class {
        if(Instances != null && Instances.TryGetValue(typeof(T), out var obj)) {
            return obj as T;
        } else {
            return ActivatorUtilities.CreateInstance<T>(Provider);
        }
    }
}

再來我定義擴充方法:

public static class DIExtension {

    private static ICollection<Type> SiteTypes = new HashSet<Type>();

    public static IServiceCollection AddSiteScoped<T>(this IServiceCollection services)
        where T : class {
        return services.AddSiteScoped<T, T>();
    }

    public static IServiceCollection AddSiteScoped<I, T>(this IServiceCollection services)
        where T : class, I
        where I : class {
        SiteTypes.Add(typeof(T));
        services.TryAddScoped<SiteScopeFactory>();
        services.AddScoped<I>(x => x.GetService<SiteScopeFactory>().Get<T>());
        return services;
    }

    public static IServiceProvider CreateUOW(this IServiceProvider provider) {
        var dict = new Dictionary<Type, object>();
        foreach(var type in SiteTypes) {
            dict.Add(type, provider.GetService(type));
        }

        var result = provider.CreateScope().ServiceProvider;
        var factory = result.GetService<SiteScopeFactory>();
        if(factory != null) factory.Instances = dict;

        return result;
    }

}

有了這些東西之後,我只要在註冊 MyAuth 服務的時候改用 services.AddSiteScoped<MyAuth>()、然後在使用 MyService 的時候改用:

var service = provider.CreateUOW().GetService<MyService>();

這樣一來,MyService 一樣會活在自己的 scope 之中,其它被它引用到的 scoped 服務也都是,除了當它需要用到 MyAuth 或是其它透過 AddSiteScoped 註冊的服務的時候,SiteScopeFactory 會幫我們傳回原本存在於網站 scope 中的那一個,而不會再建構一個新的。

最後我想特別說明的是,各位絕對可以想出很多別的可能的作法可以解決我最一開始的需求(例如先生成一個 DBContext 實體然後明確地在服務之間傳遞它,或是明確地把 MyAuth 服務實體本身、或是其中會被用到的資料傳遞給 MyService……),但是這些作法基本上都等於是捨棄了依賴注入、且會使得 MyService 的內部程式碼耦合於這些解法;我這邊希望做到的是在「保留依賴注入的精神、並且完全不動到 MyService 的內部程式碼」的情況下解決同樣的需求。


分享此頁至:
最後修改日期: 2021/07/02

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。