控制反轉與在 Spring Boot 的應用


對於想開始學 Spring Boot 後端框架的初學者,可能都曾得到「先去弄懂 IOC 和 DI」這樣子的回應。那它們究竟是什麼呢?本文會先講解控制反轉(Inversion of Control,IOC)與依賴注入(Dependency Injection,DI)的意義,接著以實際的 Java 程式碼,比較實踐前後的差別。最後再搭配 Spring Boot 的寫法,幫助讀者了解它是如何應用這項精神。

筆者曾發表 Spring Boot 的教學系列,初學者可以先閱讀本文,補充一些基礎觀念後再前往學習。而對於有開發經驗的讀者,希望能印證在自身寫過的程式上。

一、控制反轉的概念

在撰寫程式邏輯時,我們可能會基於物件導向的觀念,自行設計一些類別,將程式碼拆分出去。請假想在社群平台,有人在文章上留言,則作者便會收到通知。基於這個情境,讀者可以想像會有一個類別專門處理留言相關的事務。還會有另一個類別處理通知的事務,且會在留言的程式中被呼叫。

根據不同「業務範圍」所建立的類別,在程式專案中我們可稱之為「Service」,假設取名叫做 CommentServiceNotificationService。藉由呼叫它們,便可組合出業務邏輯。

當我們在撰寫主程式時,可能會需要其他物件(以下稱輔助物件)來協助完成工作,例如前面提到 CommentService 需要 NotificationService 的幫忙。此處在實作上可以有兩種獲取輔助物件的方式。第一種是直接在需要的類別建立全域變數,或在方法建立區域變數;第二種則是從外部傳遞參數進來,如建構式或 set 方法。

控制反轉是一種程式設計的方式,而不是來自 Spring 的某個概念。它的精神在於主程式中所需要的輔助物件,並不是在自己的類別中建立,而是由外部控制的。而依賴注入,則是輔助物件在外部建立好後,將其傳遞給主程式的過程。


二、未實踐 IOC 的程式

接著筆者以未實踐 IOC 的程式來舉例,藉此觀察出一些問題。請假想一個情境:飲料店推出促銷方案,購買飲料時,針對第二件給予優惠,以價低者折扣。以下的程式包含三個類別,幫助店家計算折扣金額。

PercentDiscountStrategy 是實作折扣計算方式的類別,方案為第二件打折。

public class PercentDiscountStrategy {
private double discountRate;

public PercentDiscountStrategy(double discountRate) {
this.discountRate = discountRate;
}

public int calcDiscount(int... prices) {
int sumOfLowerPrices = Arrays.stream(prices)
.sorted()
.limit(prices.length / 2)
.sum();

return (int) Math.round(sumOfLowerPrices * discountRate);
}
}

FixedPriceDiscountStrategy 是實作折扣計算方式的類別,方案為第二件特價。

public class FixedPriceDiscountStrategy {
private int fixedPrice;

public FixedPriceDiscountStrategy(int fixedPrice) {
this.fixedPrice = fixedPrice;
}

public int calcDiscount(int priceA, int priceB) {
return Math.min(priceA, priceB) - fixedPrice;
}

public int calcDiscount(int... prices) {
return Arrays.stream(prices)
.sorted()
.limit(prices.length / 2)
.map(price -> price - fixedPrice)
.sum();
}
}

DiscountCalculator 是總折扣金額的計算器。 

public class DiscountCalculator {
private PercentDiscountStrategy discountStrategy = new PercentDiscountStrategy(0.2);

public int calcDiscount(int... prices) {
if (prices == null || prices.length == 0) {
throw new IllegalArgumentException("請傳入價格");
}

boolean hasIllegalPrice =
Arrays.stream(prices).anyMatch(price -> price < 0);
if (hasIllegalPrice) {
throw new IllegalArgumentException("價格不得為負");
}

return discountStrategy.calcDiscount(prices);
}
}

計算器中包含一個有關計算方式的物件,此處使用第二件打折的方式。使用計算器時,只要呼叫 calcDiscount 方法,並傳入數個價格,便可根據計算方式得到總折扣金額。以下是簡單的測試程式。

@Test
public void testDiscountCalculator() {
DiscountCalculator calculator = new DiscountCalculator();

Assert.assertEquals(0, calculator.calcDiscount(30));
Assert.assertEquals(10, calculator.calcDiscount(60, 50));
Assert.assertEquals(8, calculator.calcDiscount(40, 55, 50));
Assert.assertEquals(18, calculator.calcDiscount(70, 40, 50, 65)); // (40+50)*0.2
}

讀者可以察覺,我們無法在測試程式中就知道所使用的折扣率(discountRate),必須回到 PercentDiscountStrategy 類別確認,才能在斷言(assert)方法中傳入預期的結果。且一旦這個折扣率改變,測試也會隨之失敗。若撰寫測試時能夠自行定義其他值,就不會發生修復測試程式的不便了。

此外,若想換成另一種計算方式,例如「第二件10元」,由於 FixedPriceDiscountStrategy 提供的方法略有不同,於是將計算器的程式調整成如下。

public class DiscountCalculator {
private FixedPriceDiscountStrategy discountStrategy = new FixedPriceDiscountStrategy(10);

public int calcDiscount(int... prices) {
// 其餘略過

return prices.length == 2
? discountStrategy.calcDiscount(prices[0], prices[1])
: discountStrategy.calcDiscount(prices);
}
}

簡單來說,計算器的程式邏輯需配合所採取的計算方式做出調整。而測試程式也受到影響,我們無法保留「第二件8折」的測試,必須換成「第二件10元」。

@Test
public void testDiscountCalculator() {
DiscountCalculator calculator = new DiscountCalculator();

Assert.assertEquals(0, calculator.calcDiscount(30));
Assert.assertEquals(40, calculator.calcDiscount(60, 50));
Assert.assertEquals(25, calculator.calcDiscount(55, 50, 35));
Assert.assertEquals(30 + 40, calculator.calcDiscount(70, 40, 50, 65)); // (40-10)+(50-10)
}

三、實踐 IOC 的程式

我們在上一節發現未實踐 IOC 時,可能會遇到的問題,這一節就來實踐它。筆者在前面提到,控制反轉的精神,是由外部來控制輔助物件的產生;而依賴注入,則是將其傳遞到需要的地方。

為了將不同的計算方式傳遞給計算器,良好的設計方式應該是讓計算器只用一種資料型態來接收。否則就會為了每一種計算方式都寫一個建構式或 set 方法。此處設計一個叫做 IDiscountStrategy 的介面,作為計算方式的資料型態。

public interface IDiscountStrategy {
int calcDiscount(int priceA, int priceB);
int calcDiscount(int... prices);
}

接著將計算器的實作方式調整成如下,只用 IDiscountStrategy 的型態接收計算方式。而且計算器不需要知道傳入物件的實作細節,只管呼叫該介面的方法即可。

public class DiscountCalculator {
private IDiscountStrategy discountStrategy;

public DiscountCalculator(IDiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}

public int calcDiscount(int... prices) {
// 其餘略過

return prices.length == 2
? discountStrategy.calcDiscount(prices[0], prices[1])
: discountStrategy.calcDiscount(prices);
}
}

接著讓前面用到的兩種計算方式都實作這個介面,並適當地調整程式碼。

public class PercentDiscountStrategy implements IDiscountStrategy {
// 其餘略過

@Override
public int calcDiscount(int priceA, int priceB) {
int minPrice = Math.min(priceA, priceB);
return (int) Math.round(minPrice * discountRate);
}

@Override
public int calcDiscount(int... prices) {
// 其餘略過
}
}

public class FixedPriceDiscountStrategy implements IDiscountStrategy {
// 其餘略過

@Override
public int calcDiscount(int priceA, int priceB) {
// 其餘略過
}

@Override
public int calcDiscount(int... prices) {
// 其餘略過
}
}

經過以上的調整,計算器只依賴 IDiscountStrategy 這個介面,並使用它提供的方法。若有新的折扣方式要實作,介面能夠提供一套規範(方法)讓我們遵循,確保計算器不受影響。

此外,撰寫測試程式時也不會受到限制。由於可自行定義不同的折扣率或特價,測試便更有彈性了。且兩種情境的測試程式得以同時保留。

@Test
public void testPercentDiscountCalculator() {
IDiscountStrategy strategy = new PercentDiscountStrategy(0.2);
DiscountCalculator calculator = new DiscountCalculator(strategy);

Assert.assertEquals(0, calculator.calcDiscount(30));
Assert.assertEquals(10, calculator.calcDiscount(60, 50));
Assert.assertEquals(8, calculator.calcDiscount(40, 55, 50));
Assert.assertEquals(18, calculator.calcDiscount(70, 40, 50, 65)); // (40+50)*0.2
}

@Test
public void testFixedPriceDiscountCalculator() {
IDiscountStrategy strategy = new FixedPriceDiscountStrategy(10);
DiscountCalculator calculator = new DiscountCalculator(strategy);

Assert.assertEquals(0, calculator.calcDiscount(30));
Assert.assertEquals(40, calculator.calcDiscount(60, 50));
Assert.assertEquals(25, calculator.calcDiscount(55, 50, 35));
Assert.assertEquals(30 + 40, calculator.calcDiscount(70, 40, 50, 65)); // (40-10)+(50-10)
}


四、在 Spring Boot 的應用

Spring 在啟動時,會執行元件掃描(component scan),找出具有元件標記的類別(如 @RestController@Service@Component),建立成元件(bean)。由於元件的物件實體不需要自己寫程式「new」出來,而是交給框架的機制來建立,所以 Spring 融入了 IOC 的精神。

另一方面,有些元件會依賴其他元件來完成業務邏輯,就像第一節舉例的 CommentService 與 NotificationService,或是第二、三節的計算器與計算方式的關係那樣。這時 Spring 會找出所依賴的元件,傳遞給需要的元件,這屬於依賴注入的範疇。

以下的範例程式,是將折扣計算器與計算方式的類別改寫成 Spring Boot 元件的形式。其中 DiscountCalculator 依賴 PercentDiscountStrategy

@Component
public class DiscountCalculator {
@Autowired
private PercentDiscountStrategy discountStrategy;

public int calcDiscount(int... prices) {
// 其餘略過
}
}

@Component
public class PercentDiscountStrategy implements IDiscountStrategy {
private final double discountRate = 0.2;

@Override
public int calcDiscount(int priceA, int priceB) {
// 其餘略過
}

@Override
public int calcDiscount(int... prices) {
// 其餘略過
}
}

在 Spring 啟動時,這兩個類別會被建立成元件,存放在記憶體中,我們稱為「容器」。其中 DiscountCalculator 使用了 @Autowired 標記,因此 Spring 會從容器中找出 PercentDiscountStrategy 類別的元件,自動注入進來。

接著根據 IOC 的精神,下面我們改成以介面來注入計算方式。

@Component
public class DiscountCalculator {
@Autowired
private IDiscountStrategy discountStrategy;

// 其餘略過
}

Spring 會從容器中找到有實作該介面的元件來注入。假設專案中只有一種計算方式,如 PercentDiscountStrategy,是不會有問題的。然而一旦有多種計算方式,啟動程式時就會出錯,因為 Spring 不知道要注入哪一個。

解決方式是在注入時透過元件的「名稱」來指定。名稱可以在元件標記中自行定義。而注入時藉由 @Qualifier 標記指定元件的名稱,如下。

@Component("percentDiscountStrategy")
public class PercentDiscountStrategy implements IDiscountStrategy {
// 其餘略過
}

@Component("fixedPriceDiscountStrategy")
public class FixedPriceDiscountStrategy implements IDiscountStrategy {
// 其餘略過
}

@Component
public class DiscountCalculator {
@Autowired
@Qualifier("percentDiscountStrategy")
private IDiscountStrategy discountStrategy;

// 其餘略過
}

最後筆者做個小結。Spring 在啟動時會自行建立元件,並存放於容器中,達到了控制反轉。而開發者可以在元件中使用類別、介面或元件名稱,讓 Spring 從容器中獲取其他需要的元件,達到了依賴注入。

當一個元件具有被替換的可能性,在開發這個元件時,建議事先設計一個介面,再來實作元件。而依賴該元件的主程式則使用該介面來注入,並透過介面提供的方法來使用元件。這麼做除了讓我們在開發其他可替換元件時能夠有規範,在替換後也不必調整依賴它的主程式。

在單元測試方面,當主程式依賴了某個可替換的元件,我們能夠傳入不同實作類別的元件給它。而這些不同實作的元件,還可以定義屬性值,或是模擬(mock)元件的行為,讓測試更有彈性。

留言

  1. 請教一下
    若我還需要一個DiscountCalculator他是autoWired fixedPriceDiscountStrategy
    那是不是代表我需要new 一個新的component 例如叫DiscountCalculator2?
    然後他也需要有IDiscountStrategy這個field還有calcDiscount這個method.
    這樣是不是有點重複的味道了呢?

    回覆刪除
    回覆
    1. 直接這樣做的話,當然會有點重複。不過可以思考一下,實際開發上為什麼會需要兩個 calculator?如果能做成一個 calculator 去 autowired 兩個 strategy,再根據條件使用不同的 strategy,就不需要第二個 calculator 了!

      刪除

張貼留言