【Spring Boot】第15課-元件的作用範圍(scope)

一般來說,元件在 Spring 的運行期間只會存在一個,任何地方所使用的某個類別的元件都是指向同一個,也就是「單例」。然而我們可以進一步調整元件的作用範圍(scope)。例如讓 A、B、C 元件分別擁有自己的 D 元件,或者讓每次的請求都有全新的 A 元件可用等等。

本文將介紹幾種作用範圍,並應用於元件的建立上。透過觀察元件所儲存的狀態,了解它們被建立出來的時機。

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

一、程式專案介紹

在本文的練習用專案,會發現筆者基於第14課的完成範例,事先做一些調整。首先是 ServiceConfigMailConfig,在元件的建立方法被執行時,會印出訊息。

接著是 ProductService,它依賴了 MailService。在新增或刪除產品時,會寄出一封信。讀者可自行修改收件者的 email 地址。

MailService 多了 mailMessagestag 這兩個資料成員。前者儲存目前已寄出的訊息,後者儲存建立時的時間戳記。MailService 也提供了不同情境的寄信方法,並在寄出後印出目前 mailMessages 的所有內容。

最後,MailService 還有一個 preDestroy 方法,它冠上了 @PreDestroy 標記。該方法會在物件即將被銷毀,準備回收記憶體空間時執行。

做了這些調整,第一是為了讓 Service 之間有依賴關係,第二是為了方便觀察元件的建立與銷毀。


二、Singleton 作用範圍

當元件的類別沒有利用全域變數來儲存「狀態」時,適合將元件作用範圍設為「Singleton」,而這也是預設的。比方說我們不預期 ProductService 會宣告 int 來計數,或宣告 Map 來儲存會變動的資料。此時通常會設為 Singleton 範圍,因為只需要一個實例(instance)就夠了。

在 Spring 啟動時會建立一次元件,供整個運行期間使用,即便它沒有被注入到任何元件,Spring 也會建立。雖然是這是預設的範圍,但我們仍可透過 @Scope 標記來設置。

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public ProductService productService(ProductRepository repository,
MailService mailService) {
System.out.println("Product Service is created.");
// 其餘略過
}

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public MailService mailService() {
System.out.println("Mail Service is created.");
// 其餘略過
}

傳入參數即可設置元件的作用範圍。這邊使用 Spring 提供的字串常數,以免打錯字。

若還是要在 Singleton 範圍的元件儲存狀態,那麼該狀態將能被其他請求「獲取」。就算另外寫程式碼,在方法執行完畢後立刻清除狀態,仍可能存在執行緒安全的問題。下圖的範例是後端收到第四次請求,並取得別人先前在 MailService 產生的訊息。其中第三次是呼叫「POST /mail」的 API 所產生的訊息。


二、Prototype 作用範圍

假設我們有多個元件都依賴元件 A,且希望每個元件都有專屬的實例,可將作用範圍設為「Prototype」。甚至如果想在每次呼叫方法時,都有一個全新的元件,也可設置這個範圍。以生活的例子來比喻,就像每間臥室都有獨立的衛浴設備。臥室是主元件,衛浴設備是被依賴的元件。而 Singleton 範圍如同一間房子只有一套衛浴。

會考慮這個作用範圍,通常是因為元件存有「狀態」,例如 String、Map 或其它自己的資料。若無特別需求,其實當成一般物件使用也行,不一定要使其成為 Spring 元件,例如以下的做法:

  1. 在需要這個物件的元件類別宣告全域變數,並且 new 一個物件給它。
  2. 需要這個物件時,直接在程式邏輯中 new 出物件來使用。

但在特定情況,還是得讓它成為元件。比方說非同步任務的 @Async 標記,以及資料庫交易的 @Transactional 標記,都僅限用於 Spring 元件才會生效。

本節就來解說這個作用範圍的使用方式。 Prototype 的作用範圍分為兩種情形。第一是隨著被注入的元件建立而建立,第二是用完即丟。請參考以下的範例。

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public ProductService productService(ProductRepository repository,
MailService mailService) {
System.out.println("Product Service is created.");
// 其餘略過
}

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public MailService mailService() {
System.out.println("Mail Service is created.");
// 其餘略過
}

Spring 啟動時會將 MailService 注入到 MailController,此時會產生第一個。另外也會注入到 ProductService,因此又產生第二個。最後 MailControllerProductService 都擁有自己的 MailService

下圖的範例是後端收到五次請求,前三次是新增及刪除產品,後兩次是直接對 MailController 的 API 發出請求。由於同時存在2個 MailService,所以訊息也是個別儲存並印出的。

接著我們將 MailService 恢復成 Singleton 範圍,並調整 ProductService 為 Prototype 範圍,以便觀察差別。

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)
public ProductService productService(ProductRepository repository,
MailService mailService) {
System.out.println("Product Service is created.");
// 其餘略過
}

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public MailService mailService() {
System.out.println("Mail Service is created.");
// 其餘略過
}

上面的設置額外添加了 proxyMode 參數,值為 ScopedProxyMode.TARGET_CLASS。這會將 ProductService 設置成每次呼叫時就建立一個全新的元件。且 Spring 啟動時不會馬上建立,而是等到它的方法被呼叫時才建立。

下面筆者故意在 ProductController 的某個 API 呼叫多次 ProductService 的方法,會發現每次都建立一個元件,並印出「Create product service.」的訊息。而注入 MailService 時,則是取用一開始建立的單例。

// GET /products
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable("id") String id) {
ProductResponse product = productService.getProductResponse(id); // 呼叫第一次
product = productService.getProductResponse(id); // 呼叫第二次
product = productService.getProductResponse(id); // 呼叫第三次
return ResponseEntity.ok(product);
}

若接著將 MailService 設置成一般的 Prototype 範圍(不加 proxyMode 參數),它會隨著 ProductService 的重新產生而再產生。但 MailController 則始終持有原先注入的那個實例。

在後端回應完成後,ProductService 不會馬上被銷毀。這是因為 Prototype 範圍的元件在產生之後,生命週期就不歸 Spring 管理了,MailServicepreDestroy 方法也不會被執行。Spring 官方文件提到,設置 Prototype 範圍相當於直接「new」一個物件的替代做法。而該元件的使用方(client)需要自行釋放元件持有的資源。

三、Request 作用範圍

「Request」的作用範圍,是指從業務邏輯第一次使用該元件的方法,到後端完成回應的這段期間,元件對於該項請求都是單例的。會考慮這個範圍,通常也是因為元件存有狀態。但這個狀態具有專屬性,會暫存處理該項請求時所產生的資料,且不可被其他請求取得。

本節就來解說這個作用範圍的使用方式,請參考以下範例。

@Bean
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public ProductService productService(ProductRepository repository,
MailService mailService) {
System.out.println("Product Service is created.");
// 其餘略過
}

@Bean
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public MailService mailService() {
System.out.println("Mail Service is created.");
// 其餘略過
}

上面的程式在 MailService 的建立方法的 @Scope 標記傳入 WebApplicationContext.SCOPE_REQUESTScopedProxyMode.TARGET_CLASS 的參數。這會在第一次呼叫 MailService 時,才建立該元件。若後續再度呼叫,則繼續使用同一個實例。附帶一提,該標記可替換成 @RequestScope

@Bean
@RequestScope
public MailService mailService() {
System.out.println("Mail Service is created.");
// 其餘略過
}

下面筆者故意在 ProductController 也呼叫 MailService,會發現只有第一次呼叫時才會建立元件。由於它的作用範圍僅限該次的請求,若後端收到下一個請求,則又會建立新的元件。

@RestController
@RequestMapping(value = "/products", produces = MediaType.APPLICATION_JSON_VALUE)
public class ProductController {
@Autowired
private MailService mailService;

@PostMapping
public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
ProductResponse product = productService.createProduct(request);
mailService.sendNewProductMail(product.getId());
// 其餘略過
}

@DeleteMapping("/{id}")
public ResponseEntity deleteProduct(@PathVariable("id") String id) {
productService.deleteProduct(id);
mailService.sendDeleteProductMail(id);
// 其餘略過
}
// 其餘略過
}

後端完成回應後,MailService 會被 Spring 銷毀,此時 preDestroy 方法將自動被執行,印出「Spring Boot is about to destroy Mail Service.」的訊息。

下圖的範例是後端收到新增與刪除產品的請求各1次。讀者可以發現,每個請求在第一次呼叫 MailService 時會建立元件,第二次呼叫時則不產生。在回應完成後,元件會被銷毀,並印出訊息與時間戳記。這兩次請求所印出的時間戳記不同,證明它們是不同的實例。

本文的完成範例:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch15-fin

上一篇:【Spring Boot】第14課-自行建構元件

下一篇:【Spring Boot】第16課-使用 Filter 擷取請求與回應

留言

  1. 【For 那些可能曾看到說spring 預設/推薦 使用JDK 的人】:
    https://docs.spring.io/spring-framework/docs/3.0.0.M3/reference/html/ch08s06.html
    (JDK dynamic proxies are preferred whenever you have a choice).
    應該是for Spring-Framework 而不是現在springboot的資訊

    跟著做突然接觸這個proxyMode 會突然被混淆 因為這邊也有很像是AOP 中CGLIB跟JDK proxy的概念。
    自己實際測試完畢(Base on Springboot,springboot預設AOP為CGLIB代理要手動切換JDK)
    對比調度2500次(proxyMode用TargetClass 跟Interfaces 每次方法調用都會生成新instance)。

    發現(生成+調度+AOP) 平均時間上CGLIB 會更快。
    方法與內容與調用我都盡可能一致了。
    看起來springboot會預設CGlib玩AOP不是沒原因的。

    回覆刪除
    回覆
    1. 反正各有好處 該需要decouping還是會用JDK的~

      刪除

張貼留言