【Spring Boot】第6課-元件的控制反轉、依賴注入與多型呼叫 #2024 年更新


https://unsplash.com/photos/LmyPLbbUWhA

控制反轉(IoC)與依賴注入(DI)是 Spring Boot 中息息相關的重要觀念。而筆者選擇在第 5 課(三層式架構)結束,練習用專案的架構成形後,才開始介紹。

本文首先透過範例專案,讓讀者知道那些用來封裝程式邏輯的物件,存在著依賴關係。接著說明在運行期間,為何要求這些物件只能存在唯一一個,以及如何做到。

經由這些議題,筆者將開始介紹控制反轉與依賴注入,以及如何在 Spring Boot 使用。最後帶入物件導向的「多型」特性,示範以介面來操作物件,有助於實作細節的抽換。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch5


一、依賴關係

在介紹控制反轉與依賴注入的概念前,讓我們先看看範例專案大概的架構。

以下是產品和使用者的 Repository 層,它們使用 Java 的 Map 資料結構模擬出 DB。並且提供一些方法供外部呼叫,藉此對 DB 做存取。

以下是產品的 Service 層,會提供商業邏輯。並且在全域變數建立 Repository 物件,讓商業邏輯的程式碼呼叫。

以下是 Controller 層,提供了 RESTful API。並且在全域變數建立了產品的 Service 物件。

讀者可以看出它們之間的關係:Controller 需要呼叫 Service;而 Service 需要呼叫 Repository。這種「誰需要誰」的關係,正式的稱呼為「依賴」(depend)。

範例專案中並未實作使用者的 Controller 和 Service。但如果有的話,原則上也會是「UserController」依賴「UserService」;而 UserService 依賴 UserRepository 的關係。


二、元件與單例的概念

本節將討論,同樣是封裝程式邏輯,為何要特別建立物件呢?接著向讀者介紹「單例」(singleton)的概念。

(一)元件

無論是 ProductService、ProductRepository 或 UserRepository,雖然它們都被建立成物件,但目的都是封裝程式邏輯,而非攜帶資料到處傳遞。

像這樣子的物件,我們給它一個更正式的稱呼,叫做「元件」,英文為「bean」或「component」。元件可以提供方法,來實現商業邏輯、資料處理或存取 DB 等功能。

既然只是用來封裝邏輯,那為什麼不宣告成靜態(static)的方法就好呢?這樣連物件都不必建立了。示意如下:

要知道系統功能是可以很複雜的,若元件一律提供靜態方法,那就不能善用物件導向中,繼承與多型的特性了。

(二)單例

筆者先前在全域變數建立元件,其用意是避免每呼叫一次方法,就建立一次元件,如下:

建立物件會在記憶體佔一個空間,而用完就又要回收,其實沒必要如此反覆。更別提在用戶多的系統,伺服器是很忙碌的。

為了確保應用程式運行期間,特定類別的物件只會存在一個,並能讓各個地方共享,於是出現了「單例」的概念。「單」是單一的意思,而「例」是實例(instance)。

讀者在網路上,能找到各種單例的程式寫法,以下是簡單的範例:

但也能顧及執行緒安全而變得複雜:

考慮到元件的依賴關係,可能會無意間建立出多餘的物件,以及手動實作單例的不便,Spring Boot 提供了「控制反轉」和「依賴注入」的功能來解決問題。


三、控制反轉(Inversion of Control,IoC)

在 Java 語言中建立物件的方式,是在想要的地方使用 new 關鍵字。而「控制反轉」的精神,則是將建立物件的工作轉移給外界。

也就是說,即便是透過建構子或 setter 方法,只要物件並非在類別內部建立,而是由外部提供,那就可以稱之為控制反轉。

讓 Spring Boot 建立單例物件的做法,是在類別冠上特定的注解(annotation)。Spring Boot 啟動時,會透過 Java 的「反射」(reflection)機制,掃描哪些類別具有這種注解。

可使用的注解如下,它們有不同的涵義。

  • @Controller:代表提供 Web API 的表現層。「@RestController」注解便是繼承於它。
  • @Service:代表商業邏輯層。
  • @Repository:代表資料存取層。
  • @Configuration:代表這裡搭配「@Value」注解,存放了「application.properties」配置檔的值。或搭配「@Bean」注解,控制元件的建立過程。分別於第 13 課第 14 課介紹。
  • @Component:以上 4 項都繼承自此注解,是泛用的選擇。

接著,我們在範例專案中的元件類別,冠上適當的注解。

如此一來,Spring Boot 建立出單例物件後,便會放在記憶體中管理。這個地方稱為「IoC 容器」。為了方便說明,筆者後續都用「元件」來指稱 IoC 容器中的單例物件。

四、依賴注入(Dependency Injection,DI)

(一)介紹

第一節展示的範例專案,說明了元件的依賴關係。而依賴注入便是要取代在程式碼中 new 物件的做法。

類別必須具備前面提到的注解,才能被建立為元件。而 Spring Boot 會「注入」到需要該元件的其他元件。簡單來說,只有元件才能被注入,或注入到別處。

範例程式中的 ProductService 依賴 ProductRepository 與 UserRepository。那麼這些 Repository 元件,就會被注入到 Service 元件中。而 ProductController 又依賴 ProductService,則 Controller 也會被注入。

我們可以使用 @Autowired 注解,讓 Spring Boot 注入元件。根據使用此注解的方式,又分為「欄位注入」與「建構子注入」

(二)欄位注入(Field Injection)

欄位注入的做法,是在元件類別的全域變數冠上 @Autowired 注解。

Spring Boot 會先建立出所有的元件,接著才把每個元件所依賴的其他元件逐一注入。因此元件可能有短暫時間處於初始化不完整的空窗期,這是一項缺點。而優點是寫法方便,讓程式碼更簡潔。

(三)建構子注入(Constructor Injection)

建構子注入的做法,是宣告包含所有依賴的建構子,再對其加上 @Autowired 注解。

Spring Boot 會先確認正在建立的元件,它所依賴的其他元件是否都建立好了。是的話,便從建構子注入進來。否則就先建立其他元件。意即建立與注入元件是同時進行的。

這項做法的缺點是讓程式碼變得較冗長。然而瑕不掩瑜,優點是確保元件在被使用時,已經處於完整初始化的狀態。另外也有利於撰寫單元測試(unit test),因為我們可以將設計好的「模擬物件」(mock)由建構子傳入。

Spring 官方也建議採取這樣的注入方式。

五、使用介面注入元件

為了善用物件導向的「多型」特性,可以讓元件的類別實作「介面」。而進行依賴注入時,則以介面代替類別。這樣有助於未來更換元件時,不會影響到外部使用的方式。

(一)多型呼叫

先請讀者透過 Java 語言回想一下,我們能讓類別實作「介面」(interface),此時類別必須完成介面所定義的 public 方法。

當建立這種類別的物件時,宣告的型態可以用介面來取代類別。比方說「ArrayList」與「LinkedList」這兩種資料結構,均實作「List」介面。

用 List 宣告後,使用物件一律都是呼叫該介面所定義的方法。而不必在意實際上是 ArrayList 或者 LinkedList 在運作。示意如下:

回到範例專案,ProductRepository 使用 Map 資料結構來儲存資料。我們同樣也能為 Repository 層設計一個介面,並開發出其他儲存方式的元件。例如用 List 儲存,甚至連接到真實的 DB。

而 ProductService 則固定以該介面操作 Repository 元件。

(二)實作介面注入

以下設計一個叫做「IProductRepository」的介面,並進行實作。此處為避免混淆,故將原先的 ProductRepository 改名為「MapProductRepository」,強調儲存方式。

接著調整 ProductService,改以 IProductRepository 介面來注入。

此時 ProductService 在外部呼叫的都是 IProductRepository 介面的方法,而實際執行的是被注入元件的內部邏輯。

經過這樣的修改,Spring Boot 啟動時,會尋找程式專案中,有哪些元件實作了 IProductRepository 介面。目前只有一個,理所當然注入 MapProductRepository。

六、抽換相同介面的元件

為了說明如何在相同介面更換元件,下面準備了另一個實作 IProductRepository 介面的元件。此處筆者改以 List 來儲存產品資料,程式寫法截然不同。

上面的 ListProductRepository 應該按照介面的規範,完成所有 public 方法。完整的範例程式,請參考文末附上的專案。

此時專案中存在多個相同介面的元件。由於 Spring Boot 不知道要注入哪一個,於是啟動會失敗,錯誤訊息節錄如下:

Parameter 0 of constructor in com.example.demo.service.ProductService required a single bean, but 2 were found:
    - listProductRepository: ...
    - mapProductRepository: ...
...
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

為了告訴 Spring Boot 要注入哪一個元件,我們可在其中一個類別冠上 @Primary 注解。

這會讓所有依賴 IProductRepository 介面的地方,都注入 ListProductRepository。

或者也能透過 @Qualifier 注解,在不同的元件注入不同的依賴。

以上是在建構子注入的場合使用 @Qualifier 注解,需傳入「元件名稱」作為參數。

元件名稱預設是類別名稱的駝峰字。若想自定義,可在 @Repository、@Service 等元件的注解,傳入參數做設定。

至於在欄位注入的場合使用 @Qualifier 注解,則與 @Autowired 注解一併冠在全域變數之上即可。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch6

上一篇:【Spring Boot】第5課-實作三層式架構的 Service 與 Repository

下一篇:【Spring Boot】第7.1課-MongoDB 介紹與準備資料庫環境

留言

  1. 作者已經移除這則留言。

    回覆刪除
  2. 請問產品Service 透過其他 Service 取得需要的資料的具體實踐作法是?

    回覆刪除
    回覆
    1. 先假設開發時具有「PO」與「VO」的概念,也就是從資料庫讀取出來的是 PO,經過其他資料處理或包裝後成為 VO,並回傳給前端。

      想像系統中有個取得會員詳情的 API,會回傳「會員 VO」,另外還有個取得產品詳情的 API,會回傳「產品 VO」。而產品 VO 會包含會員 VO。

      那麼產品 service 其實就能直接透過會員 service 取得會員 VO,避免寫出重複的 code。

      刪除

張貼留言