前一篇已經簡單的描述了Dotnet Core的啟動順序,其中有一個重要的環節,就是使用Startup來設定依賴注入,以及設定中間件。所以這篇文章會先介紹Startup運作原理,接者介紹依賴注入相關的重要事項。下一篇再接者介紹中間件的原理與使用。

Startup結構與運作原理

有寫過Dotnet Core的人應該都知道Startup的重要,讓我們看一下所熟悉的Startup結構。

public class Startup
{
    public Startup(IWebHostEnvironment webEnv, IHostEnvironment env, IConfiguration config)
    {
        ...
    }
    public void ConfigureServices(IServiceCollection services)
    {
        ...    
    }
    public void Configure(IApplicationBuilder app, ...)
    {
        ...
    }
}

其中最關鍵的莫過於ConfigureServices方法與Configure方法,分別的作用是告訴Dotnet Core哪些東西要被注入,以及要使用那些中間件。但有沒有想過它是如何用一個類做到這件事的呢?這邊簡單拆解一下Startup的呼叫,關鍵的程式碼就是上一篇所預留的伏筆UseStartup,這方法的關鍵內容如下:

private void UseStartup(Type type, HostBuilderContext context, IServiceCollection services)
{
    ...
    // 產生Startup實例
    instance = ActivatorUtilities.CreateInstance(new HostServiceProvider(webHostBuilderContext), startupType);
    ...
    // 創建configureServicesBuilder
    var configureServicesBuilder = StartupLoader.FindConfigureServicesDelegate(startupType, context.HostingEnvironment.EnvironmentName);
    var configureServices = configureServicesBuilder.Build(instance);
    configureServices(services);
    ...
    // 創建configureBuilder
    configureBuilder = StartupLoader.FindConfigureDelegate(startupType, context.HostingEnvironment.EnvironmentName);
    ...
    configureBuilder.Build(instance)(app);
    ...
}

不知道看完上面的程式碼,是不是有感覺與Startup結構有關聯?以下列出關聯的點:

  1. Startup建構方法與產生Startup實例呼應
  2. ConfigureServices方法與創建configureServicesBuilder呼應
  3. Configure方法與創建configureBuilder呼應

了解以上三段程式碼就可以解開另外三個謎團。為何Startup只能傳入IWebHostEnvironment、IHostEnvironment、IConfiguration;以及為何ConfigureServices只能傳入IServiceCollection;還有,為何Configure後面可以傳入所有在IServiceCollection註冊過的服務。以下簡單說明原因並附上原始碼服用。

  • 為何Startup建構子只能傳入IWebHostEnvironment、IHostEnvironment、IConfiguration?
    • 關鍵就是使用ActivatorUtilities.CreateInstance去產生Startup實例,此方法會根據所傳入的 IServiceProvider去尋找裡面是否有可以實例化的類。至此,它使用了HostServiceProvider,在其中故意限制只能使用IWebHostEnvironment、IHostEnvironment、IConfiguration這三個引數。(參考源碼)
  • 為何ConfigureServices只能傳入IServiceCollection?
    • 這邊使用反射機制將Startup裡ConfigureServices方法取出來,並建構configureServicesBuilder。Build完會產生一個configureServices委託,最終將services也就是IServiceCollection傳入並執行,Starup中ConfigureService的方法。(參考源碼)
  • 為何Configure後面可以傳入所有在IServiceCollection註冊過的服務?
    • 思路與上面相同,只差在最後執行時所傳入是app也就是IApplicationBuilder,他會去整個serviceProvider去找有對應到的類別並加入傳入的參數中。如此就可以動態的做到想要傳什麼就傳什麼的超強功能。(參考源碼)

如何使用Dotnet Core的依賴注入

Starup好像說太多了,現在進入正題…。一般來說,依賴注入有三種方式,建構子注入屬性注入方法注入,然而Dotnet Core的注入方式相當簡單,不像Java Spring支援所有的注入方式,只提供建構子注入這個方式。這種簡化的模式帶來兩個好處:第一,容易理解,第二,易於測試。當然也有一個小缺點,若注入的數量太多,則寫起來會非常繁瑣;但認真說起來,當你發現一個類注入了超多東西,這其實是一個code smell,一個類不應該依賴那麼多東西才對。這表示已經違反了『單一職責原則』(SRP)。這倒是幫助我們反思我們在類上的設計出了問題。之前我在第一篇文章有說過,Dotnet Core已經將一些複雜的功能去蕪存菁了。
以下提供一段建構子注入方式的程式碼:

// 定義一個介面
public interface IMyDependency
{
    void WriteMessage(string message);
}
// 實作一下這個介面
public class MyDependency : IMyDependency
{
    public void WriteMessage(string message)
    {
        Console.WriteLine($"MyDependency.WriteMessage Message: {message}");
    }
}
// 在Startup中,註冊IMyDependency到IServiceCollection容器中(包含設定生命週期)
public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<IMyDependency, MyDependency>();
    services.AddRazorPages();
}
// 呼叫頁面時會解析服務並於建構子中注入
public class IndexModel : PageModel
{
    private readonly IMyDependency _myDependency;
    public IndexModel(IMyDependency myDependency)
    {
        _myDependency = myDependency;            
    }
    public void OnGet()
    {
        _myDependency.WriteMessage("IndexModel.OnGet");
    }
}

直接使用微軟提供的DI容器

其實微軟提供的依賴注入是可以獨立使用的,接下來帶大家看一下如何不使用Dotnet Core框架,來使用DI容器注入。因為這樣可以讓我們更了解依賴注入的過程,事實上許多呼叫細節已經被Dotnet Core隱藏了,藉由最基本的呼叫方式來認識是最好的。

var services = new ServiceCollection();
services.AddTransient<IMyDependency, MyDependency>();
ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
IServiceScope scope = provider.CreateScope();
MyDependency md = scope.ServiceProvider.GetRequiredService<IMyDependency>();

我們可以從以上的程式可以發現接下三個注入細節,我就分三個階段來談談這些注入細節;服務的註冊服務的解析,以及服務的生命週期。只要大致了解這三項,對依賴注入已經有不錯的進入了。給一張簡圖概覽一下。

服務的註冊

服務註冊關鍵的介面就是IServiceCollection,而他的實作就是上面的ServiceCollection。簡單的說它就是一個存放所有服務描述(ServiceDescriptor)的容器(IList),所以ServiceCollection本身就提供Add,TryAdd,RemoveAll,Replace等的基本方法。
所以關鍵就是ServiceDescriptor,它定義了服務的型別,實作的類,以及生命週期。由它的建構子重載可以發現有三種設定方式。

  1. 直接給一個類
  2. 給一個抽象介面和一個實作
  3. 給一個抽象介面(類)和一個委派方法

在實務上多採用擴充方法直接使用,就是在Add後面加上Life time來使用。例如:AddSingleton, AddScoped, AddTransient。就可以輕鬆將要註冊的東西,以ServiceDescriptor註冊到容器裡面。
以下給出三種註冊方式(使用泛型):

    services.AddSingleton<MyDependency>();
    services.AddSingleton<IMyDependency, MyDependency>();
    services.AddSingleton<IMyDependency>(sp => { return new MyDependency()});

這三種註冊方式就是對應上面的三種建構子重載。其中值得說明的是第三種方式,它是用委派的方式並使用靜態工廠去創建類,所以他是較晚註冊進去的,且可以保證執行緒安全。所以以上面這三種注入方式,推崇的順序為3 > 2 > 1。

服務的解析

有了ServiceCollection後,真正去解析服務的其實是IServiceProvider,這也是最難理解且最核心的地方。我們可以一一拆解,就著呼叫順序我們先來看看BuildServiceProvider做了什麼?

BuildServiceProvider 做了那些事?

事實上它是藉由IServiceCollection的擴充方法去創建的。關鍵程式碼如下:

# ServiceCollectionContainerBuilderExtensions.cs
public static ServiceProvider BuildServiceProvider(this IServiceCollection services, ServiceProviderOptions opt)
{
    ...
    engine = new DynamicServiceProviderEngine(services);
    ...
    return new ServiceProvider(services, engine, options);
}

我們看到的ServiceProvider只是一個包裝類,裡面重要的解析引擎就是上面看到的DynamicServiceProviderEngine(以下簡稱DSPE),而DSPE繼承CompiledServiceProviderEngine(以下簡稱CSPE),CSPE又繼承ServiceProviderEngine(以下簡稱SPE)抽象類;其目的就是要把關鍵的一步留給子類去實作。所以我們先聚焦在SPE就好,其他的部分我會貼上原始碼,大家有空可以自行閱讀。

講解SPE之前最好先去看一下它的建構子以及屬性。其中最需要理解的就是下SPE與ServiceProviderEngineScope(SPES)的關係,你可以理解成SPE是用來解析查找與創建服務的;而創建好的物件實際上是放在SPES裡面。而SPE自己本身也有一個放置物件的地方,就是Root屬性(SPES)。就是所謂的Root scope。SPE以利用CreateScope方法來創建新的SPES;也可以利用GetService來解析創建物件。以下分別來看這兩件事情。

CreateScope做了那些事?

public IServiceScope CreateScope()
{
    ...
    return new ServiceProviderEngineScope(this);
}

從上面的程式可以發現SPE會將自己的引用傳入,這樣就可以讓每一個SPES都可以知道SPE,並可以統一使用SPE來解析並創建服務。實際上他有點像一棵只有一階層的樹狀結構,並且每個節點都有root的引用。

GetService做了那些事?

internal object GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
{
    ...
    Func<ServiceProviderEngineScope, object> realizedService = RealizedServices.GetOrAdd(serviceType, _createServiceAccessor);
    ...
    return realizedService.Invoke(serviceProviderEngineScope);
}
private Func<ServiceProviderEngineScope, object> CreateServiceAccessor(Type serviceType)
{
    ServiceCallSite callSite = CallSiteFactory.GetCallSite(serviceType, new CallSiteChain());
    ...
    return RealizeService(callSite);
    ...
}

不要小看短短的兩行code其中的意涵可是很深啊,以下就用淺顯易懂的方式解釋就好。事實上,SPE會維護一個對照表,就是RealizedServices,這張表的key是類的型別,value是一個委派方法。這委派方法是用CreateServiceAccessor去創建,委派的內容簡述就是去CallSiteFactory去產生ServiceCallSite此資料結構,以提供RealizeService這個方法來產生物件使用。RealizeService是一個抽象方法,實際的實作交給子類,也就是上面所說的DSPE和CSPE。詳細內容可以自行看源碼,或者看下面的註解。

問:為何要層層嵌套來執行SPE裡面的RealizeService呢?
答:的目簡單說就是為提升效能,SPE的解析器是用反射機制實現,而CSPE則是增加了使用表達式樹以方便快取的方式來實現;最後交由DSPE來決定使用邏輯:第一次會用反射解析器,之後都會採用表達式樹來提升效能。

服務的生命週期

最後來談談生命週期吧,因為礙於篇幅關係,加上網路上有需多相關文章,以下就簡單整理一張表清楚概述一下即可:

生命週期名稱說明
Transient(一次性)每次創建用完即回收。
Singleton(單例)當容器被銷毀時才會被回收。
Scope(作用域)在同一個IServiceScope裡面會被重複使用,直到IServiceScope被銷毀時才會被回收。

使用依賴注入的注意事項

使用依賴注入也是有需多要注意的,因為使用不當往往會造成系統致命的傷害。以下就羅列一些我知道需要注意的事項:

  • 避免使用有狀態、靜態類與靜態成員當作注入對象。請直接使用Singletone取代即可。
  • 避免在注入對象中實例化對象。
  • 避免Singletone相依到非Singletone的物件。
  • 避免在使用工廠方法來註冊服務時,使用非同步方法,這會造成死鎖。
  • 避免對ServiceProvider做Dispose,這會造成記憶體洩漏。

PS: 在這裡多敘述一點,就是在BuildServiceProvider時竟量將validateScopes設定為true。這樣就可以避免Singletone相依到Scope的物件,可避免掉許多麻煩。還有就是已經提過的,就是在設計Singletone服務時需要注意執行緒安全。但若是使用工廠方法來創建,就沒有任何關係了,因為他就類似static constructor去創建,保證只會被呼叫一次。

有其他的DI容器可以用嗎?

其實我個人認為Dotnet Core預設的依賴注入容器已經非常夠用了。但就注入的功能面,其實算是精簡版的注入容器。它缺少了設定檔注入與自動注入模式,以及在多重註冊關係上的處理非常麻煩,不能透過對注入型別命名來精準注入,還有若是有使用裝飾器和組合模式都是無法支援的。所以就看自己的考量吧。若有以上需求建議可以改用Autofac或是Simple Injector喔~

.Net系列文章