DI等於介面? 誤會可大了! — 2
前幾天在靠北工程師看到一則貼文,內容大致如下:
抱怨公司的專案,整體架構上使用過多的介面,每個類別都有一個介面,假設N個類別,就有2*N個元件需要維護,過度複雜。
當爭議的議題出現時,可想像留言區通常會多方激烈論戰,觀點非常多,有人說DI就是這樣、有人說Java就是這樣、有人說Spring就是這樣、也有人說介面不等於DI等等。
看到大家的辯論,讓我意識到大部分開發者對介面與DI的概念有一定程度的誤解,因此想撰寫一篇文章來跟大家闡述介面與DI之間的關係。(當然這只是我開發多年與看書消化的觀點,也不一定正確,如果有任何疑問,歡迎下方留言多多討論)
此文章總共有三篇,每篇皆嘗試釐清一個概念,此篇為第二篇:
- 了解介面所要解決的問題與使用時機 (1)。
- 了解DI所要解決的問題與使用時機 (2)。
- 了解為什麼DI不等於介面、介面不等於DI (3)。
回歸初衷,沒有DI的世界
再介紹DI是什麼之前,讓我們先回到沒有DI的世界,唯有瞭解沒有DI之前有哪些問題,才能讓我們更深刻了解DI所要解決的問題。
此時此刻讓我們先忘記DI的存在,只要知道目前我們撰寫的是物件導向語言、設計的是物件導向架構,而不管是透過OOAD、DDD進行領域問題的分析,最終會產出多個代表領域問題的物件,並了解每個物件負責的責任、物件與物件之間的互動等等。
假設我們要解決購物車問題,那麼經過分析後,我們會有以下的類別圖。
- 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 (請忽略圖片語法的正確性,單純範例溝通使用)
- 使用者跟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不等於介面,雖然我們可以注入實作特定介面的類別的物件,我們也能單純注入屬於實體類別的物件。
- 注入實作特定介面的類別的物件是為了解決多型選擇物件的問題。
- 注入屬於實體類別的物件是為了解決組裝複雜物件的問題。