使用依賴關係注入在 ASP.NET Core 中編寫乾淨程式碼

發表於2016-05-22

ASP.NET Core 1.0 是 ASP.NET 的完全重新編寫,這個新框架的主要目標之一就是更多的模組化設計。即,應用應該能夠僅利用其所需的框架部分,方法是框架在它們請求時提供依賴關係。此外,使用 ASP.NET Core 構建應用的開發人員應該能夠利用這一相同功能保持其應用鬆散耦合和模組化。藉助 ASP.NET MVC,ASP.NET 團隊極大地提高了框架的支援以便編寫鬆散耦合程式碼,但仍非常容易落入緊密耦合的陷阱,尤其是在控制器類中。

緊密耦合

緊密耦合適用於演示軟體。如果你看一下說明如何構建 ASP.NET MVC(版本 3 到 5)站點的典型示例應用程式,你很可能會找到如下所示程式碼(從 NerdDinner MVC 4 示例的 DinnersController 類):

這類程式碼難以進行單元測試,因為 NerdDinnerContext 作為類的構造的一部分而建立,並需要一個要連線的資料庫。毫無疑問,這種演示應用程式通常不包括任何單元測試。但是,你的應用程式可能會從一些單元測試受益,即使你不是測試驅動開發,但最好是編寫程式碼以便進行測試。

另外,此程式碼違反了切勿重複 (DRY) 原則,因為每個執行任何資料訪問的控制器類都在其中具有相同的程式碼以建立 Entity Framework (EF) 資料庫上下文。這使未來更改的成本更高且更容易出錯,尤其是隨著時間的推移應用程式不斷增長。

在檢視程式碼以評估其耦合度時,請記住這句話“新關鍵字就是粘附”。 也就是說,在看到“新”關鍵字例項化類的任何地方,應意識到你正在將你的實現貼上到該特定實現程式碼。依賴關係注入原則 (bit.ly/DI-Principle) 指出: “抽象不應依賴於詳細資訊,詳細資訊應依賴於抽象。” 在本示例中,控制器如何將資料整合在一起以傳入檢視的詳細資訊依賴於如何獲取此資料(即 EF)的詳細資訊。

除了新關鍵字外,“墨守成規”是造成緊密耦合的另一個原因,使得應用程式更加難以進行測試和維護。在上述示例中,執行計算機的系統時鐘上存在一個依賴關係,其形式為對 DateTime.Now 的呼叫。此耦合度可能導致難以建立一組用於某些單元測試的測試 Dinners,因為其 EventDate 屬性需要相對於當前時鐘的設定進行設定。有多種方法可以將耦合度從此方法中刪除,其中最簡單的方法就是讓返回 Dinners 的任何新抽象來處理這一問題,因此,這不再是此方法的一部分。

此外,我賦予此值一個引數,因此方法可能會在提供的 DateTime 引數後返回所有 Dinners,而不是始終使用 DateTime.Now。最後,我建立當前時間的抽象,並通過該抽象引用當前時間。如果應用程式經常引用 DateTime.Now,這將是一個不錯的方法。(另外還應該注意,由於這些 Dinners 可能會出現在不同時區中,所以在實際應用中 DateTimeOffset 型別可能是一個更好的選擇)。

誠實

這類程式碼在可維護性方面的另一個問題在於它對它的協作者並不誠實。你應避免編寫可在無效狀態中例項化的類,因為這些類經常會成為錯誤的來源。因此,類為了執行其任務所需的一切都應通過其建構函式提供。如顯式依賴關係原則 (bit.ly/ED-Principle) 所述:“方法和類應顯式要求正常工作所需的任何協作物件。”

DinnersController 類只有一個預設建構函式,這意味著它應該不需要任何協作者就能正常工作。但是如果你測試這個類會發生什麼情況? 如果你從引用 MVC 專案的新控制檯應用程式執行此類,這個程式碼會執行哪些操作?

在這種情況下,程式碼執行的第一個操作就是嘗試例項化 EF 上下文。程式碼引發 InvalidOperationException: “應用程式配置檔案中找不到名為‘NerdDinnerContext’的連線字串。” 我被騙了! 該類需要比其建構函式所宣告的更多內容才能正常工作。 如果類需要一種訪問 Dinner 例項集合的方法,則應通過其建構函式進行請求(或者,作為其方法上的引數)。

依賴關係注入

依賴關係注入 (DI) 引用將某個類或方法的依賴關係作為引數傳遞的技術,而不是通過新的或靜態呼叫對這些關係進行硬編碼。這是 .NET 開發中一種越來越常見的技術,因為該技術向使用此技術的應用程式提供分離。ASP.NET 的早期版本沒有利用 DI,儘管 ASP.NET MVC 和 Web API 在支援 DI 的問題上取得了一些進展,但都沒有生成對產品的完全支援,包括用於管理依賴關係及其物件生命週期的容器。藉助 ASP.NET Core 1.0,DI 不僅得到現成支援,還被產品本身廣泛使用。

ASP.NET Core 不僅支援 DI,它還包括一個 DI 容器—又稱為控制反轉 (IoC) 或服務容器。每個 ASP.NET Core 應用使用 Startup 類的 ConfigureServices 方法中的此容器配置其依賴關係。此容器提供所需的基本支援,但如果需要,可將其替換為自定義實現。而且,EF Core 還提供對 DI 的內建支援,因此,在 ASP.NET Core 應用程式中配置 DI 就像呼叫擴充套件方法一樣簡單。我為本文建立了 NerdDinner 衍生,稱為 GeekDinner。配置 EF Core,如此處所示:

配置好後,即可非常輕鬆地使用 DI 從諸如 DinnersController 的控制器類請求 GeekDinnerDbContext 的例項。

請注意,沒有新關鍵字的單個例項,控制器所需要的依賴關係全部通過其建構函式傳入,並且 ASP.NET DI 容器會代我負責處理此程式。在專注於編寫應用程式時,我無需擔心通過其建構函式完成我的類請求的依賴關係所涉及的探測。

當然,如果我願意,我可以自定義此行為,甚至可以用其他實現完全替換預設容器。因為我的控制器類現在遵循顯式依賴關係原則,我知道要想使控制器類正常工作,我需要為其提供一個 GeekDinnerDbContext 例項。通過對 DbContext 進行一些設定,我可以單獨例項化控制器,如此控制檯應用程式所示:

構造 EF Core DbContext 所涉及的操作要比構造 EF6 DbContext 稍微多一些,後者只需一個連線字串。這是因為,就像 ASP.NET Core 一樣,EF Core 已設計得更加模組化。通常情況下,你無需直接處理 DbContextOptionsBuilder,因為當你通過擴充套件方法(如 AddEntityFramework 和 AddSqlServer)配置 EF 時會在後臺使用它。

但能否對它進行測試?

手動測試你的應用程式非常重要—你希望能夠執行應用程式,檢視它是否真正執行併產生預期的輸出。但是,每次進行更改都必須進行測試很浪費時間。相比緊密耦合應用,鬆散耦合應用程式的最大好處之一是它們更適合進行單元測試。更妙的是,相比其前身,ASP.NET Core 和 EF Core 都更易於進行測試。

首先,我將通過傳入已配置為使用記憶體儲存的 DbContext 來直接針對控制器編寫一個簡單測試。我將使用 DbContextOptions 引數來配置 GeekDinnerDbContext,它通過其建構函式公開為我的測試的設定程式碼的一部分:

在我的測試類中進行上述配置後,即可輕鬆編寫一個測試,顯示正確的資料已返回到 ViewResult 的模型中:

當然,這裡還沒有大量的邏輯以供測試,因此,本測試不會真的進行那麼多測試。批評家們會辯駁這不是非常有價值的測試,我同意他們的觀點。但是,這是具備更多邏輯時進行操作的起點,因為很快就會有更多邏輯。但首先,儘管 EF Core 可以通過其記憶體選項支援單元測試,我仍會避免直接耦合到我的控制器中的 EF。沒有理由通過資料訪問基礎結構問題來耦合 UI 問題—實際上,這違反了另一條原則,即關注點分離原則。

不要依賴於你不使用的內容

介面分隔原則 (bit.ly/LS-Principle) 指出類應僅依賴於它們實際使用的功能。對於啟用 DI 的新 DinnersController 而言,它仍依賴於整個 DbContext。可以使用僅提供必需功能的抽象,而不將控制器實現整合到 EF 中。

此操作方法真正需要什麼才能正常工作? 當然不是整個 DbContext。它甚至無需訪問上下文的完整 Dinners 屬性。它需要的只是能夠顯示合適頁面的 Dinner 例項。表示此內容的最簡單 .NET 抽象為 IEnumerable<Dinner>。因此,我將定義一個介面,該介面僅返回 IEnumerable<Dinner>,並滿足 Index 方法的(大多數)要求。

我將此稱之為儲存庫,因為它符合該模式: 它抽象出類似集合的介面後的資料訪問。如果出於某些原因,你不喜歡儲存庫模式或名稱,你可以將其稱之為 IGetDinners 或 IDinnerService 或者任何你喜歡的名稱(我的技術審閱者建議將其稱為 ICanHasDinner)。無論你如何為此型別命名,它都能起到相同的作用。

一切就緒後,我現在就可以調整 DinnersController 以接受將 IDinnerRepository 作為建構函式引數,而不是 GeekDinnerDbContext,並呼叫 List 方法,而不直接訪問 Dinners DbSet:

此時,你可以生成並執行你的 Web 應用程式,但如果你導航到 /Dinners 則會遇到異常: Invalid­OperationException: 在嘗試啟用 GeekDinner.Controllers.DinnersController 時,無法解析型別“Geek­Dinner.Core.Interfaces.IdinnerRepository”的服務。

我尚未實現此介面,並且在我進行實現時,我還需要配置在 DI 滿足 IDinnerRepository 請求時要使用的實現。實現介面並不複雜:

請注意,這非常適用於直接將儲存庫實現耦合到 EF。如果我需要換出 EF,則只需建立此介面的新實現。此實現類是我的應用程式的基礎結構的一部分,這是應用程式中我的類依賴於特定實現的一個地方。

若要在類請求 IDinnerRepository 時將 ASP.NET Core 配置為注入正確實現,我需要將以下程式碼行新增到之前所示的 ConfigureServices 方法的末尾:

此語句要求 ASP.NET Core DI 容器在容器解析依賴於 IDinnerRepository 例項的型別時使用 DinnerRepository 例項。作用域意味著一個例項將用於 ASP.NET 處理的每個 Web 請求。還可以使用暫時或單一生存期新增服務。在這種情況下,作用域適用,因為我的 DinnerRepository 依賴於同樣使用作用域生存期的 DbContext。下面是可用物件生存期的摘要:

  • 暫時: 新型別例項在每次請求型別時使用。
  • 作用域: 新型別例項在給定 HTTP 請求中進行第一次請求時建立,然後重用於在該 HTTP 請求期間解析的所有後續型別。
  • 單一: 單一型別例項會建立一次,並由該型別的所有後續請求使用。

內建容器支援多種方法,來構造它將提供的型別。最典型的情況是隻提供容器和型別,容器將嘗試例項化該型別,提供型別執行時需要的任何依賴關係。你還可以提供 lambda 表示式用來構造型別或單一生存期,你可以在註冊時在 ConfigureServices 中提供完全構造的例項。

隨著依賴關係注入關聯,應用程式就可以像以前一樣執行。現在,如圖 1 所示,我可以通過準備就緒的新抽象,使用 IDinner­Repository 介面的虛設或模擬實現對其進行測試,而不在我的測試程式碼中直接依賴於 EF。

圖 1 使用 Mock 物件測試 DinnersController

無論 Dinner 例項的列表來自何處,此測試都能正常執行。你可以重寫資料訪問程式碼以使用其他資料庫、Azure 表儲存或 XML 檔案,並且控制器也會同樣正常執行。當然,在此情況中,並沒有執行很多操作,那麼,你可能想知道…

實際的邏輯會怎麼樣?

到目前為止,我沒有真正實現任何實際的業務邏輯—這只是返回簡單資料集合的簡單方法。測試的真正價值在於,在遇到邏輯和特殊情況時,你需要對其會按照預期執行滿懷信心。為了說明這一點,我打算將一些需求新增到我的 GeekDinner 站點。此站點將公開一個 API,允許任何人訪問 dinner 的 RSVP。

但是,dinner 將擁有可選的最大容量,並且 RSVP 不應超過這一容量。請求超過最大容量的 RSVP 的使用者不應被新增到候補名單中。最後,dinner 可以指定相對於其開始時間必須接收 RSVP 的最後期限,在此期限後它們將停止接收 RSVP。

我可以將此邏輯全部編碼到一個操作中,但我認為這讓一個方法承擔了太多責任,尤其是 UI 方法應專注於 UI 問題,而不是業務邏輯。控制器應確認它接收的輸入有效,並且應確保它返回的響應適合於客戶端。在此之外的決策,尤其是業務邏輯,不屬於控制器。

放置業務邏輯的最佳位置位於應用程式的域模型中,這不應依賴於基礎結構方面的問題(如資料庫或 UI)。Dinner 類在管理需求中所述的 RSVP 問題時最具價值,因為它會為 dinner 儲存最大容量,並知道目前已完成了多少 RSVP。但是,部分邏輯還依賴於 RSVP 發生的時間以及是否超過最後期限,因此方法也需要訪問當前時間。

我可以只使用 DateTime.Now,但這會造成邏輯難以測試,並將我的域模型耦合到系統時鐘。另一種方法是使用 IDateTime 抽象並將其注入到 Dinner 實體。但是,根據我的經驗,最好使 Dinner 等實體沒有依賴關係,尤其是如果你計劃使用類似 EF 的 O/RM 工具將這些實體從永續性層中提取出來。我不希望將實體的依賴關係填充為該程式的一部分,EF 肯定不可能在我沒有執行其他程式碼的情況下做到這一點。

此時一個常用的方法是將邏輯從 Dinner 實體中提取出來,並將其放在可輕鬆注入依賴關係的某類服務(如 DinnerService 或 RsvpService)中。這往往會導致貧乏域模型反模式 (bit.ly/anemic-model),不過,其中實體具有很少行為或沒有行為,只是狀態包。不,在這種情況下,解決方案相當簡單—方法可以將當前時間作為引數,並讓呼叫程式碼將其傳入。

通過此方法,新增 RSVP 的邏輯非常簡單(請參閱圖 2)。有多個測試可說明此方法按預期執行,這些方法在與本文關聯的示例專案中提供。

圖 2 域模型中的業務邏輯

通過將此邏輯轉換為域模型,我確保我的控制器的 API 方法仍然較小並專注於其自身的問題。因此,可以輕鬆測試控制器是否執行它應該執行的操作,因為通過此方法建立的路徑相對較少。

控制器職責

控制器的部分職責是檢查 ModelState 並確保其有效。為清楚起見,我在操作方法中執行此工作,但在大型應用程式中,我會通過使用操作篩選器清除每個操作中的重複程式碼:

假定 ModelState 有效,操作下一步必須使用請求中提供的識別符號來提取適當的 Dinner 例項。如果操作找不到匹配該 ID 的 Dinner 例項,它應返回“未找到”結果:

在完成這些檢查後,操作即可將由請求表示的業務操作委託給域模型,呼叫你之前看到的 Dinner 類上的 AddRsvp 方法,並在返回 OK 響應前儲存域模型的更新狀態(在這種情況下,為 dinner 例項及其 RSVP 集合)。

請記住,我決定 Dinner 類不應對系統時鐘具有依賴關係,而選擇將當前時間傳入此方法。在控制器中,我為 currentDateTime 引數傳入 _systemClock.Now。這是通過 DI 填充的本地欄位,這還會防止控制器緊密耦合到系統時鐘。

適當的做法是使用控制器上的 DI 而非域實體,因為控制器始終由 ASP.NET 服務容器建立,這將實現控制器在其建構函式中宣告的任何依賴關係。_systemClock 是型別 IDateTime 的欄位,只需幾行程式碼即可定義和實現此欄位。

當然,我還需要確保將 ASP.NET 容器配置為在類需要 IDateTime 例項時使用 MachineClockDateTime。此操作可以在 Startup 類的 ConfigureServices 中完成,在這種情況下,儘管任何物件生存期都有效,但我選擇單一生存期,因為一個 MachineClockDateTime 例項將適用於整個應用程式:

在準備好這個簡單抽象後,我能基於 RSVP 是否過期來測試控制器的行為,並確保返回正確的結果。因為我已經對 Dinner.AddRsvp 方法進行了測試,驗證其按預期方式工作,我不需要通過控制器對相同行為進行多次測試來使我確信這一點,在協同工作時,控制器和域模型都能正常工作。

後續步驟

下載關聯的示例專案,檢視 Dinner 和 DinnersController 的單元測試。請記住,相比充斥著依賴於基礎結構問題的“新的”或靜態方法呼叫的緊密耦合程式碼,鬆散耦合程式碼通常更容易進行單元測試。應該在你的應用程式中有意而不是意外使用“新關鍵字就是粘附”和新關鍵字。在 docs.asp.net 上了解有關 ASP.NET Core 及其對依賴關係注入的支援。

相關文章