前一篇已經簡單的描述了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結構有關聯?以下列出關聯的點:
- Startup建構方法與產生Startup實例呼應
- ConfigureServices方法與創建configureServicesBuilder呼應
- 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,它定義了服務的型別,實作的類,以及生命週期。由它的建構子重載可以發現有三種設定方式。
- 直接給一個類
- 給一個抽象介面和一個實作
- 給一個抽象介面(類)和一個委派方法
在實務上多採用擴充方法直接使用,就是在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系列文章