首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> .NET 網頁程式設計 >> 關於.NET

輕鬆寫碼:透過依賴反轉與注入打造高彈性應用程式架構

     編輯:關於.NET

幾乎所有開發人員都認同鬆散耦合設計的重要性,遺憾的是,我們設計的軟體耦合程度通常遠超預期。如何判斷設計是否緊密耦合?除靜態分析工具(如 NDepend)外,最輕鬆的方法是嘗試獨立實例化一個類別。

耦合的真實面貌:拉一根線拆掉整件毛衣

從商務層挑一個類別(如 InvoiceService),複製到新 Console 專案編譯。極大機率會缺少相依性(Invoice、InvoiceValidator 等)。持續複製缺少的類別,最終能編譯時,新專案往往已包含絕大部分核心代碼。正如拉動毛衣一根鬆動線頭即可拆解整件衣物,你的設計中每個類別都直接或間接與其他類別耦合。更改此類系統極其困難,因任何類別變更都可能牽一髮動全身。

解決之道非完全消除耦合(不可能),而是理解並明智選擇耦合對象。例如:string name = "James Kovacs"; 耦合至 System.String,但 String 極少變更且介面穩定,此耦合無虞。反觀資料層常見代碼:SqlConnection conn = new SqlConnection(connectionString); 或 XmlDocument settings = new XmlDocument(); settings.Load("Settings.xml");。你有多大把握資料層永遠只用 SQL Server、設定永遠只從 Settings.xml 讀取?重點在於可逆性:你能輕易改變設計決策嗎?架構能否良好回應變化?

為何執著於變化?因為軟體業唯一不變的就是變化——需求、技術、人員、業務皆在變。透過鬆散耦合設計,軟體才能更好回應往往無法預測的必然變化。

分層架構的內部依賴陷阱

圖 1:典型分層架構——依賴自上而下傳遞

典型分層架構:UI 層 → 服務層 → 儲存庫層。依賴關係同向向下。雖層次劃分是良好設計技術,但上層直接耦合下層具體實作會增加耦合、導致難以測試。

為何重視可測試性?因為它是衡量耦合的優良指標。若無法在測試中輕易實例化類別,耦合必有問題。服務層深度依賴儲存庫層,無法脫離儲存庫測試服務層,意味著多數測試需存取資料庫、檔案系統或網路,導致三大問題:

  1. 測試緩慢:記憶體測試僅需毫秒;存取外部資源則需百毫秒以上。成百上千測試下,差異在於「秒」與「分/時」。
  2. 錯誤隔離差:資料層故障導致成百上千測試失敗,難以快速定位問題。
  3. 維護成本高:測試需初始資料,涉及資料庫時必須確保每次測試前資料庫處於已知狀態且相互獨立,極其耗時易錯。

此外,變更下層實作往往強迫同步修改上層,因層間存在隱性或顯性依賴。雖已分層,卻未達鬆散耦合。

具體案例:InvoiceService 的測試惡夢

public class InvoiceService {
    private readonly AuthorizationService authoriazationService;
    private readonly InvoiceValidator invoiceValidator;
    private readonly InvoiceRepository invoiceRepository;
    public InvoiceService() {
        authoriazationService = new AuthorizationService();
        invoiceValidator = new InvoiceValidator();
        invoiceRepository = new InvoiceRepository();
    }
    // ... 方法略
}

建構子中直接 new 具體依賴,導致無法在缺少具體依賴下對 InvoiceService 執行單元測試。測試前必須確保:資料庫無主鍵衝突、驗證通過、執行使用者具權限。任何依賴組件出問題(代碼或資料錯誤),InvoiceService 測試即失敗。即使通過,設置資料、執行、清理總耗時數百毫秒,遠超記憶體測試。更微妙的是:若要為 InvoiceRepository 加入稽核支援,受限於耦合,選擇極少。

依賴反轉原則 (DIP):朝向抽象而非具體

透過介面而非具體類別與依賴互動,可分離上層組件:

public class InvoiceService : IInvoiceService {
    private readonly IAuthorizationService authService;
    private readonly IInvoiceValidator invoiceValidator;
    private readonly IInvoiceRepository invoiceRepository;
    // ...
}

改用介面意味著可替換任何依賴的其他實作(如 AuditingInvoiceRepository),也可在測試期間替換為虛設或模擬物件。此技術稱為契約式程式設計。Robert C. Martin 定義的依賴反轉原則:「上層模組不應依賴下層模組,兩者都應依賴抽象。」此例中,InvoiceService 與 InvoiceRepository 現皆依賴 IInvoiceRepository 抽象。

但問題僅被轉移:具體類別如何「發現」彼此?InvoiceService 仍需具體實作。在建構子中實例化依賴與之前無異。若想用 AuditingInvoiceRepository,仍須修改 InvoiceService 及所有依賴 IInvoiceRepository 的類別,無法全域切換。

服務定位器模式:集中建立依賴

引入工廠建立 IInvoiceRepository 實例,透過修改工廠方法集中切換至 AuditingInvoiceRepository。此技術又稱服務定位器:

public InvoiceService() {
    this.authorizationService = ServiceLocator.Find<IAuthorizationService>();
    this.invoiceValidator = ServiceLocator.Find<IInvoiceValidator>();
    this.invoiceRepository = ServiceLocator.Find<IInvoiceRepository>();
}

ServiceLocator 可基於設定檔、資料庫或程式碼。配合虛設/模擬物件配置,可對單一組件執行單元測試。但服務定位器有缺點:

  1. 高層類別中看不出依賴關係,公共簽名無法揭示 InvoiceService 依賴哪些服務。
  2. 同一介面需不同具體類型時,必須重載 Find 方法,且無法在部署時重新配置替代實作。

儘管有缺點,服務定位器易懂且優於硬編碼依賴。

依賴注入 (DI):將依賴傳入而非查找

虛設依賴明智嗎? 許多人擔心虛設依賴會產生錯誤結果。實際上,你應有測試驗證實際依賴(如 InvoiceRepository)正常運作。若已知 InvoiceRepository.Save 有效,為何每個依賴它的測試都要重複驗證?結果只會降低上層測試速度,且若 InvoiceRepository 出問題,不僅其測試失敗,所有依賴它的組件測試也會失敗。若 InvoiceService 測試失敗而 InvoiceRepository 未失敗,表示缺少針對 InvoiceRepository 的測試。整合測試(使用具體依賴)較擅長發現此類缺口,雖較慢但執行頻率低。選擇:維護複雜腳本確保資料庫狀態,或建立虛設/模擬實作回傳預期資料。後者更易實現、更實用。

在單元測試上層組件時,提供虛設/模擬依賴。但不應配置服務定位器再讓上層查找,而應透過參數化建構子直接傳遞依賴——這就是依賴注入:

[Test]
public void CanSubmitNewInvoice() {
    var invoice = new Invoice();
    var validationResults = new ValidationResults();
    var authService = mockery.CreateMock<IAuthorizationService>();
    var validator = mockery.CreateMock<IInvoiceValidator>();
    var repo = mockery.CreateMock<IInvoiceRepository>();
    using(mockery.Record()) {
        Expect.Call(authService.IsActionAllowed(invoice, InvoiceAction.Submit)).Return(true);
        Expect.Call(validator.Validate(invoice)).Return(validationResults);
        repo.Save(invoice);
    }
    using(mockery.Playback()) {
        var service = new InvoiceService(authService, validator, repo);
        service.Submit(invoice);
    }
}

此處建立虛設物件並傳入建構子。透過定義 InvoiceService 與虛設的互動行為(而非事後驗證狀態)來指定行為。

執行時與整合測試的依賴解析難題

DI 解決單元測試依賴提供問題,但應用程式執行時或整合測試中,誰來提供類別依賴?期望 UI 層提供服務層依賴、服務層提供儲存庫層依賴並不明智:

var presenter = new InvoiceSubmissionPresenter(
    new InvoiceService(
        new AuthorizationService(),
        new InvoiceValidator(),
        new InvoiceRepository())); // UI 需知曉依賴的依賴的依賴...

這顯然非理想方案。最輕鬆的出路是「廉價的依賴注入」:使用預設建構子提供依賴,並委托給最重載建構子:

public InvoiceService() : this(new AuthorizationService(), new InvoiceValidator(), new InvoiceRepository()) { }

唯一與具體依賴耦合處在預設建構子,重載建構子保留單元測試注入能力。

控制反轉容器:集中管理依賴的進階方案

引入依賴解析器概念,將介面對應至具體實作,提供泛型方法解析依賴:

public interface IDependencyResolver { T Resolve<T>(); }

實作 SimpleDependencyResolver 使用字典儲存對應,提供 Register 方法於啟動時填充。為避免到處傳遞 IDependencyResolver,採用靜態閘道模式(命名為 IoC)放置配置好的解析器實例:

public class IoC {
    private static IDependencyResolver s_Inner;
    public static void Initialize(IDependencyResolver resolver) { s_Inner = resolver; }
    public static T Resolve<T>() { return s_Inner.Resolve<T>(); }
}

啟動時初始化 IoC,預設建構子改用 IoC.Resolve:

public InvoiceService() :
    this(IoC.Resolve<IAuthorizationService>(),
         IoC.Resolve<IInvoiceValidator>(),
         IoC.Resolve<IInvoiceRepository>()) { }

IoC 類還可作為防腐層:若更換容器,只需部署實現 IDependencyResolver 的適配器,全應用程式無需修改。

成熟 IoC 容器的關鍵能力

SimpleDependencyResolver 功能有限,成熟容器(以 Castle Windsor 為例)提供:

  • 豐富配置選項:XML、程式碼、腳本 (Binsor/Boo)。XML 修改無需重新編譯但易冗長、運行時才發現錯誤;程式碼配置隔離至獨立組件則只需重新編譯配置組件;Binsor 為 DSL,允許自動掃描組件註冊類型。
  • 生命週期管理:Singleton、Transient、PerThread、Pooled 等。XML 中指定 lifestyle="transient" 即可每次請求回傳新實例。
  • 自動繫結依賴:容器檢查請求類型建構子參數,自動建立依賴鏈,免除維護預設建構子,簡化代碼並移除對 IoC 靜態閘道的依賴。
  • 裝飾器模式擴展功能:如為 InvoiceRepository 加入稽核,緊密耦合架構必須修改類別本身。鬆散耦合下,實作 AuditingInvoiceRepository 裝飾 IInvoiceRepository,委託給真實 Repository。容器配置回傳裝飾後實例,客戶端無感。優勢:
    1. 不修改原類別,不可能破壞原碼。
    2. 可獨立實作測試稽核功能。
    3. 可為稽核、安全、快取等疊加多層裝飾器,不增加核心複雜度,擴展性極佳。
    4. 容器提供應用程式擴展機制,第三方組件可輕鬆實作裝飾器。

結語:擁抱變化的架構

即使架構已分層,層間緊密耦合仍阻礙測試與演進。透過依賴反轉與依賴注入,從編碼抽象(而非具體實作)受益;引入 IoC 容器增加架構彈性。最終,你的鬆散耦合設計將更優雅地適應變化。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved