DI等於介面? 誤會可大了! — 2

Z-xuan Hong
18 min readMay 21, 2022

--

前幾天在靠北工程師看到一則貼文,內容大致如下:

抱怨公司的專案,整體架構上使用過多的介面,每個類別都有一個介面,假設N個類別,就有2*N個元件需要維護,過度複雜。

當爭議的議題出現時,可想像留言區通常會多方激烈論戰,觀點非常多,有人說DI就是這樣、有人說Java就是這樣、有人說Spring就是這樣、也有人說介面不等於DI等等。

看到大家的辯論,讓我意識到大部分開發者對介面與DI的概念有一定程度的誤解,因此想撰寫一篇文章來跟大家闡述介面與DI之間的關係。(當然這只是我開發多年與看書消化的觀點,也不一定正確,如果有任何疑問,歡迎下方留言多多討論)

此文章總共有三篇,每篇皆嘗試釐清一個概念,此篇為第二篇:

  • 了解介面所要解決的問題與使用時機 (1)。
  • 了解DI所要解決的問題與使用時機 (2)。
  • 了解為什麼DI不等於介面、介面不等於DI (3)。

回歸初衷,沒有DI的世界

再介紹DI是什麼之前,讓我們先回到沒有DI的世界,唯有瞭解沒有DI之前有哪些問題,才能讓我們更深刻了解DI所要解決的問題。

此時此刻讓我們先忘記DI的存在,只要知道目前我們撰寫的是物件導向語言、設計的是物件導向架構,而不管是透過OOAD、DDD進行領域問題的分析,最終會產出多個代表領域問題的物件並了解每個物件負責的責任物件與物件之間的互動等等。

假設我們要解決購物車問題,那麼經過分析後,我們會有以下的類別圖。

購物車class diagram
  • ShoppingCart的責任為計算總價錢、記錄使用者有興趣的訂單。
  • LineItem的責任為計算每一筆訂單的價錢。
  • Product的責任為記錄商品的詳細資訊。
  • DiscountStrategy的責任為處理購物車配合活動所享有的優惠。

但光有類別圖與知道各物件的責任還是遠遠不夠,我們需要了解這些物件在runtime時彼此之間如何互動,所以需要sequence diagram或是interaction diagram來描述這些互動場景。

在UML中,兩個設計物件架構最常使用到的圖分別為:class diagram、sequence diagram or interaction diagram,class diagram負責描述物件靜態的關係、sequence diagram與interaction diagram負責描述物件與物件動態互動關係。

下面為購物車計算價錢的interaction diagram (請忽略圖片語法的正確性,單純範例溝通使用)

購物車interaction diagram
  • 使用者跟shoppingCart要求價錢的計算,送price訊息給shoppingCart。
  • shoppingCart接收到訊息跟lineItem要求價錢的計算,送price訊息給lineItem。
  • lineItem接收到訊息跟product要求價錢並考慮數量,計算後回傳給shoppingCart。
  • shoppingCart收到lineItem計算的價錢後,送apply訊息給discountStrategy。
  • discountStrategy接收到訊息開始計算,計算後回傳給shoppingCart。
  • shoppingCart最終將計算結果回傳給使用者。

經過兩張圖片的介紹後,我們可以了解有哪些物件、物件的責任、物件之間的互動。

但不知道各位有沒有發現一件事:shoppingCart送apply訊息給discountStrategy,但discountStrategy其實是介面,那是哪個類別真正接收到訊息呢?是SizeDiscountStrategy?還是PriceDiscountStrategy?

上述問題會伸出另一個問題:即便在設計物件互動時我們只考慮介面,但終究需要產生實作此介面的物件,那我們要如何產生呢…

已知用火,套用creational pattern

上述問題就是設計模式中,creational pattern想要解決的事情。在物件的產生中,除了最基礎的new指令,設計模式提供了一系列pattern,像是factory method、abstract factory、singleton、prototype等等。

由於此篇不是介紹設計模式的文章,不會針對上述模式進行介紹,如果讀者有興趣,請自行查閱資料。

回到主題,為了解決如何產生實作discountStrategy介面的類別的物件,常常使用factory與singleton一起解決問題。

  • Factory:透過工廠模式封裝所有實作介面的類別,並且透過runtime資訊、環境變數等等來產生正確的物件。
  • Singleton:即便使用工廠模式後,我們依然需要拿到工廠的物件,此時能透過singleton來獲取工廠的物件。

下面為套用factory、singleton的範例程式碼:

public static class ShoppingCart {
private final List<LineItem> lineItems;

ShoppingCart() {
lineItems = new ArrayList<>();
}

public double price() {
double price = lineItems
.stream()
.map(LineItem::price)
.reduce(0d, Double::sum)
;
return DiscountStrategyFactory.instance().discountStrategy().apply(price, this);
}

public int lineItemSize() {
return this.lineItems.size();
}
}

public static class DiscountStrategyFactory {
private static final DiscountStrategyFactory instance = new DiscountStrategyFactory();

public static DiscountStrategyFactory instance() {
return instance;
}

public DiscountStrategy discountStrategy() {
if (System.getProperty("discountType").equals("size")) {
return new SizeDiscountStrategy();
} else if (System.getProperty("discountType").equals("price")) {
return new PriceDiscountStrategy();
}

throw new RuntimeException("Invalid discountType");
}
}

public interface DiscountStrategy {
double apply(double price, ShoppingCart shoppingCart);
}

public static class SizeDiscountStrategy implements DiscountStrategy {
@Override
public double apply(double price, ShoppingCart shoppingCart) {
if (shoppingCart.lineItemSize() > 5) {
return price * 0.85;
}
return price;
}
}

public static class PriceDiscountStrategy implements DiscountStrategy {
@Override
public double apply(double price, ShoppingCart shoppingCart) {
if (price > 1000) {
return price * 0.85;
}
return price;
}
}

透過factory與singleton能解決產生物件的問題,但同時會衍伸出其他問題。

  • 由於設計模式提供多種建立物件的pattern,當專案一大,對於如何產生物件這件事,每個工程師都有自己做法,最終會產生不一致性。像我使用factory + singleton、其他人只使用factory、也有人會把factory放到registry並且使用singleton存取registry內的factory。
  • 每次遇到這種問題,都需要增加對應的factory與singleton,導致過多無意義的間接層,使專案過度複雜。
  • 雖然使用singleton能幫助測試替換假factory,但沒設定好容易產生shared dependency的問題 (singleton為global,所以為shared),導致測試之間沒辦法有效隔離。
  • 每當要組裝複雜的物件,即便沒有介面多型選擇的問題,我們也需要使用簡單工廠進行封裝,一樣會導致元件過多的問題。

以下範例程式碼示範如何使用簡單工廠封裝複雜物件的產生過程。

public static class ComplexObjectClient {
public void doSomething() {
new ComplexObjectFactory().complexObject().doSomething();
// ...
}
}

public static class ComplexObjectFactory {
// 封裝複雜物件組裝邏輯,降低客戶端對複雜物件耦合
public ComplexObject complexObject() {
return new ComplexObject(
new PartA(),
new PartB(),
new PartC(),
new PartD()
);
}
}
public static class ComplexObject {
private PartA partA;
private PartB partB;
private PartC partC;
private PartD partD;

public ComplexObject(PartA partA, PartB partB, PartC partC, PartD partD) {
this.partA = partA;
this.partB = partB;
this.partC = partC;
this.partD = partD;
}

public void doSomething() {

}
}

public static class PartA {

}

public static class PartB {

}

public static class PartC {

}

public static class PartD {

}

混沌世界,DI下凡拯救眾生

到了這邊,我們已經了解為什麼會有產生物件的問題、如何使用設計模式解決這些問題,儘管能解決問題,但解決方式不是很完美 (會有元件過多、不好測試等副作用)。

最初意識到此問題並提出來的人就是Spring框架的創始者Rod Johnsn,在Expert One-on-One J2EE Design and Development一書中,作者提出了他的見解,認為與其讓每個模組產生的方式都不一致最後導致混亂,不如設計一個集中式控管的工廠,並且把config、物件組裝等責任都交給集中式控管的工廠。

而這個集中式控管的工廠就是今天大家耳熟能詳的DI Container,在Spring中為ApplicationContext。接下來集中式控管的工廠我將直接稱之為容器。

為了讓容器幫助我們組裝、產生物件,我們需要做到幾件事情。

  • 物件需要放棄自己獲取dependency的權利,全數交給容器。
  • 物件需要放棄自己控制物件生命週期的權利,全數交給容器。
  • 物件需要放棄自己組裝物件的權利,全數交給容器。
  • 這一系列的放棄,就是inversion of control (IoC)的概念,物件放棄對於dependency的control、放棄對於生命週期的control、放棄對物件組裝的control。

當我們知道物件需要放棄上述條件後,問題來了:我們要如何拿到dependency呢?

答案很簡單:物件的建構子只要能接收他想要的dependency即可,剩下的事情交付給容器處理。

上述就是dependency injection的核心概念,透過注入dependency而不是自己獲取dependency,再搭配容器幫忙組裝、產生物件。

接下來我們將上述兩個例子改寫為DI型態的程式碼。(一個為購物車、一個為產生複雜物件)

以下為DI購物車範例程式碼

從建構子可以看到我們要求注入實作特定介面的類別的物件,而使用dependency的客戶端不會知道他跟哪個類別互動。

單純用這個例子來看,就會讓大家誤解DI一定需要搭配介面,但這只是DI能解決的眾多問題中的其中一項而已。

public static class ShoppingCart {
private final List<LineItem> lineItems;
private DiscountStrategy discountStrategy;

ShoppingCart(DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
lineItems = new ArrayList<>();
}

public double price() {
double price = lineItems
.stream()
.map(LineItem::price)
.reduce(0d, Double::sum)
;
return discountStrategy.apply(price, this);
}

public int lineItemSize() {
return this.lineItems.size();
}
}

以下為DI產生複雜物件範例程式碼

我們看到所有的元件皆為類別,沒有任何一個介面,但我們還是使用DI的方式來獲取dependency。

  • ComplexObjectClient要求我們注入ComplexObject,這兩個為實體類別。
  • ComplexObject要求我們注入PartA、PartB、PartC、PartD,這五個為實體類別。

上述我們可以知道一件事情:DI不一定要使用介面,當使用DI是為了組裝複雜物件時,跟介面沒有任何關係

public static class ComplexObjectClient {
private final ComplexObject complexObject;
// 建構子接收需要的dependency,讓外面的人注入。
public ComplexObjectClient(ComplexObject complexObject) {
this.complexObject = complexObject;
}
public void doSomething() {
this.complexObject.doSomething();
// ...
}
}

public static class ComplexObject {
private PartA partA;
private PartB partB;
private PartC partC;
private PartD partD;

// 建構子接收需要的dependency,讓外面的人注入。
public ComplexObject(PartA partA, PartB partB, PartC partC, PartD partD) {
this.partA = partA;
this.partB = partB;
this.partC = partC;
this.partD = partD;
}

public void doSomething() {

}
}

public static class PartA {

}

public static class PartB {

}

public static class PartC {

}

public static class PartD {

}

上述兩段程式碼改善了許多問題:

  • 不在有factory與singleton,專案的元件少了很多,降低複雜度。
  • client與dependency之間的耦合很低,client只獲取dependency,不知道dependency的內部結構,這正是原本factory想解決的問題。
  • 測試性提高,我們不需要再透過singleton來複寫假factory,只要在測試注入fake物件即可。
@Test
public void get_the_price_of_the_shopping_cart() {
// 測試替換變得異常簡單,只要注入測試物件即可。
FakeDiscountStrategy strategy = new FakeDiscountStrategy();
ShoppingCart cart = new ShoppingCart(strategy);

cart.price();

// assertion
}

public static class FakeDiscountStrategy implements DiscountStrategy {
@Override
public double apply(double price, ShoppingCart shoppingCart) {
// test
return price;
}
}

DI vs DI Container

看到這邊的讀者相信對於什麼是DI、什麼是DI Container應該非常了解了。

  • DI:透過注入依賴到建構子的方式協助物件獲取需要的dependency。
  • DI Container:統一、集中式的工廠,負責組裝物件、產生物件、控制物件生命週期。

下面列出幾項常見的誤解,相信各位讀者應該能理解為什麼說誤解。

  • DI等於DI Container。
  • 沒有DI Container不能DI。
  • DI一樣需要使用factory、singleton。
  • DI只是開一堆介面。

DI、DI Container解決哪些問題

我們可以列出DI能夠解決的問題為:

  • 如何讓物件生成的方式統一:把物件產生的責任交給DI Container,物件只透過建構子獲取dependency,不需要factory、singleton等元件。
  • 如何獲取需要的dependency:讓物件透過建構子要求外部注入需要的dependency,包含介面注入、實體類別注入。

常見的例子為:當使用介面隔離shared、volatile dependency時,透過DI的方式注入實作隔離介面的物件,測試時也能替換成fake物件。

我們可以列出DI Container能夠解決的問題為 (如果沒有DI Container,也能達到一樣的效果,只是比較麻煩):

  • 組裝應用程式:由於所有應用程式內的物件都放棄生成物件、組裝物件,DI Container會在程式進入點 (main entry point)組裝應用程式所需要的全部物件。
  • 控制生命週期:由於所有應用程式內的物件都放棄控制生命週期,DI Container會控制這些物件的生命週期,常見有singleton、prototype等等。
  • AOP:由於物件組裝只發生在程式進入點,我們對於物件如何組裝有極大的彈性,因此很多容器會提供runtime proxy來裝飾被控制物件的行為。

上述DI Container組裝的地方也稱之為composition root,只有composition root會使用到DI Container,其他物件皆使用DI獲取dependency。

結論

  • 在沒有DI時,我們會使用creational pattern來產生不同的物件,但會造成實作方式不一致、間接層過多等問題。
  • DI的概念很簡單,就是放棄物件組裝、物件生成、物件生命週期控制,並透過建構子要求需要的dependency,而不是自己獲取。
  • DI Container為解決產生物件不一致問題,提供集中式的物件組裝、生成、生命控制等方案。
  • DI不等於DI Container,我們需要讓整個應用程式都先DI,才能在程式進入點透過DI Container組裝所有物件。
  • DI不等於介面,雖然我們可以注入實作特定介面的類別的物件,我們也能單純注入屬於實體類別的物件。
  • 注入實作特定介面的類別的物件是為了解決多型選擇物件的問題。
  • 注入屬於實體類別的物件是為了解決組裝複雜物件的問題。

--

--

Z-xuan Hong

熱愛軟體開發、物件導向、自動化測試、框架設計,擅長透過軟工幫助企業達到成本最小化、價值最大化。單元測試企業內訓、導入請私訊信箱:t107598042@ntut.org.tw。