[轉]使用依賴關系注入在 ASP.NET Core 中編寫干凈代碼


本文轉自:http://blog.jobbole.com/101270/

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 及其對依賴關系注入的支持。

 


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2020 ITdaan.com