ASP.NET Core中的依賴注入(4): 建構函式的選擇與服務生命週期管理

發表於2016-05-05

ServiceProvider最終提供的服務例項都是根據對應的ServiceDescriptor建立的,對於一個具體的ServiceDescriptor物件來說,如果它的ImplementationInstance和ImplementationFactory屬性均為Null,那麼ServiceProvider最終會利用其ImplementationType屬性返回的真實型別選擇一個適合的建構函式來建立最終的服務例項。我們知道服務服務的真實型別可以定義了多個建構函式,那麼ServiceProvider針對建構函式的選擇會採用怎樣的策略呢?

目錄
一、建構函式的選擇
二、生命週期管理
ServiceScope與ServiceScopeFactory
三種生命週期管理模式
服務例項的回收

一、建構函式的選擇

如果ServiceProvider試圖通過呼叫建構函式的方式來建立服務例項,傳入建構函式的所有引數必須先被初始化,最終被選擇出來的建構函式必須具備一個基本的條件:ServiceProvider能夠提供建構函式的所有引數。為了讓讀者朋友能夠更加真切地理解ServiceProvider在建構函式選擇過程中採用的策略,我們不讓也採用例項演示的方式來進行講解。

我們在一個控制檯應用中定義了四個服務介面(IFoo、IBar、IBaz和IGux)以及實現它們的四個服務類(Foo、Bar、Baz和Gux)。如下面的程式碼片段所示,我們為Gux定義了三個建構函式,引數均為我們定義了服務介面型別。為了確定ServiceProvider最終選擇哪個建構函式來建立目標服務例項,我們在建構函式執行時在控制檯上輸出相應的指示性文字。

我們在作為程式入口的Main方法中建立一個ServiceCollection物件並在其中新增針對IFoo、IBar以及IGux這三個服務介面的服務註冊,針對服務介面IBaz的註冊並未被新增。我們利用由它建立的ServiceProvider來提供針對服務介面IGux的例項,究竟能否得到一個Gux物件呢?如果可以,它又是通過執行哪個建構函式建立的呢?

對於定義在Gux中的三個建構函式來說,ServiceProvider所在的ServiceCollection包含針對介面IFoo和IBar的服務註冊,所以它能夠提供前面兩個建構函式的所有引數。由於第三個建構函式具有一個型別為IBaz的引數,這無法通過ServiceProvider來提供。根據我們上面介紹的第一個原則(ServiceProvider能夠提供建構函式的所有引數),Gux的前兩個建構函式會成為合法的候選建構函式,那麼ServiceProvider最終會選擇哪一個呢?

在所有合法的候選建構函式列表中,最終被選擇出來的建構函式具有這麼一個特徵:每一個候選建構函式的引數型別集合都是這個建構函式引數型別集合的子集。如果這樣的建構函式並不存在,一個型別為InvalidOperationException的異常會被跑出來。根據這個原則,Gux的第二個建構函式的引數型別包括IFoo和IBar,而第一個建構函式僅僅具有一個型別為IFoo的引數,最終被選擇出來的會是Gux的第二個建構函式,所有執行我們的例項程式將會在控制檯上產生如下的輸出結果。

接下來我們對例項程式略加改動。如下面的程式碼片段所示,我們只為Gux定義兩個建構函式,它們都具有兩個引數,引數型別分別為IFoo&IBar和IBar&IBaz。在Main方法中,我們將針對IBaz/Baz的服務註冊新增到建立的ServiceCollection上。

對於Gux的兩個建構函式,雖然它們的引數均能夠由ServiceProvider來提供,但是並沒有一個建構函式的引數型別集合能夠成為所有有效建構函式引數型別集合的超集,所以ServiceProvider無法選擇出一個最佳的建構函式。如果我們執行這個程式,一個InvalidOperationException異常會被丟擲來,控制檯上將呈現出如下所示的錯誤訊息。

二、生命週期管理

生命週期管理決定了ServiceProvider採用怎樣的方式建立和回收服務例項。ServiceProvider具有三種基本的生命週期管理模式,分別對應著列舉型別ServiceLifetime的三個選項(Singleton、Scoped和Transient)。對於ServiceProvider支援的這三種生命週期管理模式,Singleton和Transient的語義很明確,前者(Singleton)表示以“單例”的方式管理服務例項的生命週期,意味著ServiceProvider物件多次針對同一個服務型別所提供的服務例項實際上是同一個物件;而後者(Transient)則完全相反,對於每次服務提供請求,ServiceProvider總會建立一個新的物件。那麼Scoped又體現了ServiceProvider針對服務例項怎樣的生命週期管理方式呢?

ServiceScope與ServiceScopeFactory

ServiceScope為某個ServiceProvider物件圈定了一個“作用域”,列舉型別ServiceLifetime中的Scoped選項指的就是這麼一個ServiceScope。在依賴注入的應用程式設計介面中,ServiceScope通過一個名為IServiceScope的介面來表示。如下面的程式碼片段所示,繼承自IDisposable介面的IServiceScope具有一個唯一的只讀屬性ServiceProvider返回確定這個服務範圍邊界的ServiceProvider。表示ServiceScope由它對應的工廠ServiceScopeFactory來建立,後者體現為具有如下定義的介面IServiceScopeFactory。

若要充分理解ServiceScope和ServiceProvider之間的關係,我們需要簡單瞭解一下ServiceProvider的層級結構。除了直接通過一個ServiceCollection物件建立一個獨立的ServiceProvider物件之外,一個ServiceProvider還可以根據另一個ServiceProvider物件來建立,如果採用後一種建立方式,我們指定的ServiceProvider與建立的ServiceProvider將成為一種“父子”關係。

3-11雖然在ServiceProvider在建立過程中體現了ServiceProvider之間存在著一種樹形化的層級結構,但是ServiceProvider物件本身並沒有一個指向“父親”的引用,它僅僅會保留針對根節點的引用。如上面的程式碼片段所示,針對根節點的引用體現為ServiceProvider類的欄位_root。當我們根據作為“父親”的ServiceProvider建立一個新的ServiceProvider的時候,父子均指向同一個“根”。我們可以將建立過程中體現的層級化關係稱為“邏輯關係”,而將ServiceProvider物件自身的引用關係稱為“物理關係”,右圖清楚地揭示了這兩種關係之間的轉化。

由於ServiceProvider自身是一個內部型別,我們不能採用呼叫建構函式的方式根據一個作為“父親”的ServiceProvider建立另一個作為“兒子”的ServiceProvider,但是這個目的可以間接地通過建立ServiceScope的方式來完成。如下面的程式碼片段所示,我們首先建立一個獨立的ServiceProvider並呼叫其GetService方法獲得一個ServiceScopeFactory物件,然後呼叫後者的CreateScope方法建立一個新的ServiceScope,它的ServiceProvider就是前者的“兒子”。

如果讀者朋友們希望進一步瞭解ServiceScope的建立以及它和ServiceProvider之間的關係,我們不妨先來看看作為IServiceScope介面預設實現的內部型別ServiceScope的定義。如下面的程式碼片段所示,ServiceScope僅僅是對一個ServiceProvider物件的簡單封裝而已。值得一提的是,當ServiceScope的Dispose方法被呼叫的時候,這個被封裝的ServiceProvider的同名方法同時被執行。

IServiceScopeFactory介面的預設實現型別是一個名為ServiceScopeFactory的內部型別。如下面的程式碼片段所示,ServiceScopeFactory的只讀欄位“_provider”表示當前的ServiceProvider。當CreateScope方法被呼叫的時候,這個ServiceProvider的“子ServiceProvider”被建立出來,並被封裝成返回的ServiceScope物件。

三種生命週期管理模式

只有在充分了解ServiceScope的建立過程以及它與ServiceProvider之間的關係之後,我們才會對ServiceProvider支援的三種生命週期管理模式(Singleton、Scope和Transient)具有深刻的認識。就服務例項的提供方式來說,它們之間具有如下的差異:

  • Singleton:ServiceProvider建立的服務例項儲存在作為根節點的ServiceProvider上,所有具有同一根節點的所有ServiceProvider提供的服務例項均是同一個物件。
  • Scoped:ServiceProvider建立的服務例項由自己儲存,所以同一個ServiceProvider物件提供的服務例項均是同一個物件。
  • Transient:針對每一次服務提供請求,ServiceProvider總是建立一個新的服務例項。

為了讓讀者朋友們對ServiceProvider支援的這三種不同的生命週期管理模式具有更加深刻的理解,我們照例來做一個簡單的例項演示。我們在一個控制檯應用中定義瞭如下三個服務介面(IFoo、IBar和IBaz)以及分別實現它們的三個服務類(Foo、Bar和Baz)。

現在我們在作為程式入口的Main方法中建立了一個ServiceCollection物件,並採用不同的生命週期管理模式完成了針對三個服務介面的註冊(IFoo/Foo、IBar/Bar和IBaz/Baz分別Transient、Scoped和Singleton)。我們接下來針對這個ServiceCollection物件建立了一個ServiceProvider(root),並採用建立ServiceScope的方式建立了它的兩個“子ServiceProvider”(child1和child2)。

為了驗證ServiceProvider針對Transient模式是否總是建立新的服務例項,我們利用同一個ServiceProvider(root)獲取針對服務介面IFoo的例項並進行比較。為了驗證ServiceProvider針對Scope模式是否僅僅在當前ServiceScope下具有“單例”的特性,我們先後比較了同一個ServiceProvider(child1)和不同ServiceProvider(child1和child2)兩次針對服務介面IBar獲取的例項。為了驗證具有“同根”的所有ServiceProvider針對Singleton模式總是返回同一個服務例項,我們比較了兩個不同child1和child2兩次針對服務介面IBaz獲取的服務例項。如下所示的輸出結構印證了我們上面的論述。

服務例項的回收

ServiceProvider除了為我們提供所需的服務例項之外,對於由它提供的服務例項,它還肩負起回收之責。這裡所說的回收與.NET自身的垃圾回收機制無關,僅僅針對於自身型別實現了IDisposable介面的服務例項,所謂的回收僅僅體現為呼叫它們的Dispose方法。ServiceProvider針對服務例項所採用的收受策略取決於服務註冊時採用的生命週期管理模式,具體採用的服務回收策略主要體現為如下兩點:

  • 如果註冊的服務採用Singleton模式,由某個ServiceProvider提供的服務例項的回收工作由作為根的ServiceProvider負責,後者的Dispose方法被呼叫的時候,這些服務例項的Dispose方法會自動執行。
  • 如果註冊的服務採用其他模式(Scope或者Transient),ServiceProvider自行承擔由它提供的服務例項的回收工作,當它的Dispose方法被呼叫的時候,這些服務例項的Dispose方法會自動執行。

我們照例使用一個簡單的例項來演示ServiceProvider針對不同生命週期管理模式所採用的服務回收策略。我們在一個控制檯應用中定義瞭如下三個服務介面(IFoo、IBar和IBaz)以及三個實現它們的服務類(Foo、Bar和Baz),這些型別具有相同的基類Disposable。Disposable實現了IDisposable介面,我們在Dispose方法中輸出相應的文字以確定物件回收的時機。

我們在作為程式入口的Main方法中建立了一個ServiceCollection物件,並在其中採用不同的生命週期管理模式註冊了三個相應的服務(IFoo/Foo、IBar/Bar和IBaz/Baz分別採用Transient、Scoped和Singleton模式)。我們針對這個ServiceCollection建立了一個ServiceProvider(root),以及它的兩個“兒子”(child1和child2)。在分別通過child1和child2提供了兩個服務例項(child1:IFoo, child2:IBar/IBaz)之後,我們先後呼叫三個ServiceProvider(child1=>child2=>root)的Dispose方法。

該程式執行之後會在控制檯上產生如下的輸出結果。從這個結果我們不難看出由child1提供的兩個採用Transient模式的服務例項的回收實在child1的Dispose方法執行之後自動完成的。當child2的Dispose方法被呼叫的時候,對於由它提供的兩個服務物件來說,只有註冊時採用Scope模式的Bar物件被自動回收了,至於採用Singleton模式的Baz物件的回收工作,是在root的Dispose方法被呼叫之後自動完成的。

瞭解ServiceProvider針對不同生命週期管理模式所採用的服務回收策略還會幫助我們正確的使用它。具體來說,當我們在使用一個現有的ServiceProvider的時候,由於我們並不能直接對它實施回收(因為它同時會在其它地方被使用),如果直接使用它來提供我們所需的服務例項,由於這些服務例項可能會在很長一段時間得不到回收,進而導致一些記憶體洩漏的問題。如果所用的是一個與當前應用具有相同生命週期(ServiceProvider在應用終止的時候才會被回收)的ServiceProvider,而且提供的服務採用Transient模式,這個問題就更加嚴重了,這意味著每次提供的服務例項都是一個全新的物件,但是它永遠得不到回收。

為了解決這個問題,我想很多人會想到一種解決方案,那就是按照如下所示的方式顯式地對提供的每個服務例項實施回收工作。實際上這並不是一種推薦的程式設計方式,因為這樣的做法僅僅確保了服務例項物件的Dispose方法能夠被及時呼叫,但是ServiceProvider依然保持著對服務例項的引用,後者依然不能及時地被GC回收。

或者

由於提供的服務例項總是被某個ServiceProvider引用著[1](直接提供服務例項的ServiceProvider或者是它的根),所以服務例項能夠被GC從記憶體及時回收的前提是引用它的ServiceProvider及時地變成垃圾物件。要讓提供服務例項的ServiceProvider成為垃圾物件,我們就必須建立一個新的ServiceProvider,通過上面的介紹我們知道ServiceProvider的建立可以通過建立ServiceScope的方式來實現。除此之外,為我們可以通過回收ServiceScope的方式來回收對應的ServiceProvider,進而進一步回收由它提供的服務例項(僅限Transient和Scoped模式)。下面的程式碼片段給出了正確的程式設計方式。

接下來我們通過一個簡單的例項演示上述這兩種針對服務回收的程式設計方式之間的差異。我們在一個控制檯應用中定義了一個繼承自IDisposable的服務介面IFoobar和實現它的服務類Foobar。如下面的程式碼片段所示,為了確認物件真正被GC回收的時機,我們為Foobar定義了一個解構函式。在該解構函式和Dispose方法中,我們還會在控制檯上輸出相應的指導性文字。

在作為程式入口的Main方法中,我們建立了一個ServiceCollection物件並採用Transient模式將IFoobbar/Foobar註冊其中。藉助於通過該ServiceCollection建立的ServiceProvider,我們分別採用上述的兩種方式獲取服務例項並試圖對它實施回收。為了強制GC試試垃圾回收,我們顯式呼叫了GC的Collect方法。

該程式執行之後會在控制檯上產生如下所示的輸出結果。從這個結果我們可以看出,如果我們使用現有的ServiceProvider來提供所需的服務例項,後者在GC進行垃圾回收之前並不會從記憶體中釋放。如果我們利用現有的ServiceProvider建立一個ServiceScope,並利用它所在的ServiceProvider來提供我們所需的服務例項,GC是可以將其從記憶體中釋放出來的。


對於分別採用 Scoped和Singleton模式提供的服務例項,當前ServiceProvider和根ServiceProvider分別具有對它們的引用。如果採用Transient模式,只有服務型別實現了IDisposable介面,當前ServiceProvider才需要對它保持引用以完成對它們的回收,否則沒有任何一個ServiceProvider保持對它們的引用。

 

相關文章