前言

現在很多系統在撰寫時都會使用到一些常見 DI Container 尤其在使用一些編譯式語言(ex. .Net, Java)時(Autofac for .Net, Unity for .Net, guice for Java, di-ninja for NodeJS 等族繁不及備載…)。不過系統即使用上這些 container 只要使用方式不正確,還是可以寫出強耦合性的程式,因此這邊要來介紹一些常見的 Anti-patterns,避免自己寫出高耦合性的程式。

這一篇基本上是 Dependency Injection in .Net 這本書第二版在第五章的心得 (第二版叫 Dependency Injection Principles, Practices, and Patterns)。雖然他連簡體中文的翻譯都還沒有出來,不過還是相當推薦買來看看。

常見類型

Anti-patternDescription
Control Freak依賴反轉(Inversion of Control)的對立面,直接控制實體
Service Locator使用一個內隱服務提供依賴給呼叫端
Ambient Context使用靜態存取子,提供單一的依賴
Constrained Construction讓建構子有一個特定的方法簽章

Control Freak

  • 定義

就是直接了當的把你需要的實體給產生出來,這個與依賴反轉的原則剛好相違背。

  • 案例

標準案例

1
2
3
4
5
6
7
8
9
public string GetContent(string fileName)
{
    // 這個直接把要使用的實體寫在呼叫時
    var saveService = new FileSaveService();

    var fileContent = saveService.Read($"{fileName}.txt");

    return fileContent;
}

基本上這個寫法比較容易出現在所謂的 legacy code 裡,近年來 program to an interface 的意識抬頭,工程師應該都不會這樣寫了。

看起比較像樣的案例 (Factories)

1
2
3
4
5
6
7
private readonly IProductRepository repository;

// 建構子
public ProductService()
{
    this.repository = new SqlProductRepository();
}

這個看起來比上一個案例好,感覺有把產生實體的地方集中在建構子裡,不過本質上依然是直接產生實體,關係綁的太緊密。

一般常見用來修改這樣依賴關係的錯誤方法是使用一些工廠家族模式,以下是三種常見到拿來修復 Control Freak 的 Factory Pattern:

  • Concrete Factory (實體工廠)
  • Abstract Factory (抽象工廠)
  • Static Factory (一種簡單工廠方法的實現)

Concrete Factory

新增一個 Factory 類別來產生需要的實體

1
2
3
4
5
6
7
public class ProductRepositoryFactory
{
    public IProductRepository Create()
    {
        return new SqlProductRepository();
    }
}

但這樣會衍生另外一個問題,在需要使用 IProductRepository 時,會需要 new 一個 Factory 類別,再呼叫 Creat 方法,取得真正執行的實體。一來當我們提供的實體增多時,我們需要新增不同的 Factory 類別來提供,同時我們也會改動到呼叫端。這樣的方式好處,也只不過是讓產生實體的行為從呼叫端移到 Factory 類別,但 Factory 類別自身卻也造成依賴問題。

實體工廠方法他在 解決/封裝 實體建立時的一些邏輯是很好用的,不過對於依賴反轉的幫助卻是不大。那抽象工廠又是如何呢?

Abstraction Factory

先產生一個抽象工廠的抽象類別,作為這個模式的起手式。

1
2
3
4
public interface IProductRepositoryFactory 
{
    IProductRepository Create(); 
}

恩…雖然我們將 Factory 類別改成介面,不過這樣在使用上,就會需要另外新創一個實做這個介面的類別,並在使用時 new 出來,嗯哼~看起來狀況就會回到剛剛 Concrete Factory 的窘境。

最後我們來看看三個工廠方法中最後一個方法 Static Factory 又是如何對依賴反轉這題束手無策的。

Static Factory

直接來看看若使用 Static Factory 會如何來解依賴問題

1
2
3
4
5
6
7
public static class ProductRepositoryFactory
{
    public static IProductRepository Create()
    {
        return new SqlProductRepository();
    }
}

這樣的寫法雖然不會要呼叫端建立 Factory 實體,不過卻將某一個實體給綁定,所以通常會改為類似下列的樣子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public static class ProductRepositoryFactory
{
    public static IProductRepository Create()
    {
        IConfigurationManager configuration = new ConfigurationManager("appsettings.json");

        string repositoryType = configuration["productRepository"];

        switch (repositoryType)
        {
            case "sql": 
                return new SqlProductRepository();
            case "mongodb": 
                return MongoDBProductRepository();
            default: 
                throw new InvalidOperationException("...");
        }
    }
}

利用這樣的寫法,將實體的建立使用參數來調整,會產生的實體在 appsettings.json 檔案中會依據 productRepository 的值的不同而不同。呼叫端只需要直接呼叫這個 Factory 方法的靜態方法(Creat)就好,實際拿到那一個實體由參數檔控制。 看起來這個 Static Factory 方法會是工廠方法對於依賴問題的好解答,但這樣真的沒有致命的缺點嗎?

說好的相依關係解耦呢

使用 Static Factory 使用上看起來可以解決直接 new instance 的問題 (control freak),不過這會讓負責產生實體的 Factory 類別的依賴關係變得相當難解。

ProductRepositoryFactory 這個類別因為會提供 IProductRepository 介面的方法出來,所以跟這個介面相依。另外,它也負責產生相對應的實體,所以也跟 SqlProductRepository, MongoDBProductRepository 相依。另外,使用這個 Factory 的類別(通常會是 Service 類的 business logic layer),直接相依 Factory 同時也會操作 IProductRepository 介面的方法。可以看得出來,雖然看似解決建立實體的問題,但是對於我們想要解決的 Inversion of Control 卻是剪不斷理還亂。

關於把 Factory Patern 列為 Anti-Pattern 這件事

可能有些人會對於把工廠方法視為是一種 Anti-Pattern 感到不以為然,不過這邊有個前提…我們想解決的問題是依賴關係解耦,而不是建立實體邏輯。工廠方法在封裝實體建立邏輯的問題上面有相當出色的表現,當你想將建立實體的程式集中在一起時,通常首選會是工廠方法。不過工廠方法在面對類別關係的依賴解耦上,就不是這麼好的選擇了,使用工廠方法的方式來解會讓類別之間的相依關係變高,造成明顯的反效果,所以在 DI 這個問題上拿工廠方法來解可以視為是種反模式。

看起來更像樣的案例 (overloaded constructors)

上述的作法,可能你已經看過不少,但接下來的案例在你查找一些重構解依賴時,有可能會跟你說這是種解法但其實也是 Anti-Pattern 的方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private readonly IProductRepository repository;

public ProductService() : this(new MongoDBProductRepository())
{
}

public ProductService(IProductRepository repository)
{
    if (repository == null)
        throw new ArgumentNullException("repository");

    this.repository = repository;
}

Yep, 藉由 overload 建構子,並提供預設的依賴,讓 repository 類別看起來變得可測,我在之前很多重構既有 Code 的方法中看過這樣的解法。你可能會說:「這樣我們就可以在撰寫 Unit test 時,將依賴抽換成 mock 來針對必要的邏輯進行測試!」,不過這樣的做法也可以讓以的下寫法成立

1
var service = new ProductService();

恩,沒錯,這樣就跟直接在建構時固定給它一個 repository 是一樣的效果。這個解法雖然比 Factory 家族的解法還要好上那麼一點,但還是不能說它解偶關係了。不過要是團隊強迫使用時一定要用有參數的建構子的話,那感覺上算是提供了一個,雖然有缺陷但也不是不可接受的解法。不過要注意的是,因為會跟其中一個類別有緊密的關聯,所以該類別變成稍微特殊的存在,在類別圖畫出來的時候,會顯得有點尊絕不凡。

小結

通常大多數會遇到的相依問題就是 Control Freak 以及嘗試解決它時因為作法不妥所衍生的問題。而 Control Freak 會造成的負面效果也不少

  • 難以並行開發(類別之間彼此相依)
  • 可測性幾乎為零
  • module (service) 很難重複被使用,因為相依於特定的實做

若要從 Control Freak 中解放出來可以參照一些步驟

  • 首先將相依的類別抽象出來(通常會建議是介面)
  • 若一個類別有很多地方將這個依賴用 new 的方式來取得的話,這這些全部集中在一個 create 方法,並讓呼叫端取得這個類別的抽象
  • 現在你只有一個地方在產生這個相依,依據 DI Patterns (Constructor Injection, Property Injection, Method Injection) 來解除相依關係