The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

認識 Spring Boot 應用程式的啟動生命週期

我在學習一個全新框架時,很喜歡去看那些初學者不太愛看或看不太懂的內容。例如我在學 Angular 的時候,明明 ng new 就可以建立新專案,就可以開始寫程式,但我就會深入研究啟動的完整過程。而我在學 Spring Boot 的時候也一樣,雖然 Spring Initializr 真的很好用,相依套件選一選就可以開始開發應用程式,但我就會想瞭解這些神奇設計的背後做了什麼事,藉此瞭解一個框架的核心原理。這個過程看似沒效率,但事實上此舉可以學習到非常廣泛的知識,而且可以很好的連結不同技術細節。今天這篇文章我們就回歸基礎,看看 Spring Boot 應用程式的啟動生命週期。

Spring Boot

建立範例應用程式

  1. 使用 Spring Boot CLI 快速建立專案 (也可以用 Spring Initializr 建立)

    spring init --dependencies=web,lombok --groupId=com.duotify starter1
    
  2. 使用 Visual Studio Code 開啟該專案

    code starter1/
    

目前專案的資料夾與檔案結構如下:

.
├── HELP.md
├── mvnw
├── mvnw.cmd
├── pom.xml
└── src
    ├── main
    │   ├── java
    │   │   └── com
    │   │       └── duotify
    │   │           └── starter1
    │   │               └── DemoApplication.java
    │   └── resources
    │       ├── application.properties
    │       ├── static
    │       └── templates
    └── test
        └── java
            └── com
                └── duotify
                    └── starter1
                        └── DemoApplicationTests.java

14 directories, 7 files

應用程式進入點

所有 Java 應用程式都需要一個進入點才能執行,因此你只要找到包含 main() 方法的類別,就是我們的進入點:

package com.example.myapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

  public static void main(String[] args) {
    SpringApplication.run(DemoApplication.class, args);
  }

}

在這個類別上有個 @SpringBootApplication 標注(Annotation),用來宣告這是一個 Spring Boot 應用程式。但這個 @SpringBootApplication 標注本身又套用了好幾個標注,我們按下 F12 即可查看原始碼:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
    @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {

由此上述程式碼,我們可以發現另外三個重要的標注:

  1. @SpringBootConfiguration

    這個 @SpringBootConfiguration 標注,主要用來將目前類別(DemoApplication)套上 @Configuration 標注,你可以看一下 @SpringBootConfiguration 的原始碼部分,他有套用一個 @Configuration 標注:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Configuration
    @Indexed
    public @interface SpringBootConfiguration {
    

    你其實也可以直接在 DemoApplication 套用 @Configuration 標注,但是套用 @SpringBootConfiguration 的語意更加清晰,可讀性更高!👍

    基本上,套用 @Configuration 標注會讓 DemoApplication 類別加入到 Spring IoC container 之中,若類別中有方法(Method)套用 @Bean 標注,該方法就可以在應用程式啟動時被 Spring Boot 呼叫,並將執行結果也加入到 Spring IoC container 之中,讓其他元件(@Component)可以透過建構式注入屬性注入(@Autowired)或方法注入(@Qualifier)使用。

    這個行為算是一種手動建立註冊 Spring Bean 元件的過程。

  2. @EnableAutoConfiguration

    這個 @EnableAutoConfiguration 標注,會先讓 Spring Boot 透過 @ComponentScan 的定義,自動找出所有相依套件中 JAR 檔案中有套用 @Component 標注的類別,並自動建立註冊Spring Bean 元件。這個過程就如同自動幫你在這些類別加上 @Configuration 標注的意思。

    例如 TomcatSpring MVC 等相依套件,都有類似的設計,所以使用 @EnableAutoConfiguration 可以大幅簡化程式碼數量。

  3. @ComponentScan

    這個 @ComponentScan 標注,主要用來將目前 package 與所有 sub-package 下有標注 @Component 標注的類別都自動掃描出來,並提供給 @EnableAutoConfiguration 進行自動設定。這個過程又稱 Component scanning (元件掃描)。

這三個標注的用途不同,又彼此相關,因此非常重要。一般來說,@EnableAutoConfiguration@SpringBootConfiguration 只會套用一次在根類別(root class),而 @ComponentScan 通常也只會套用在 根類別 上,但如果你有其他函式庫,也可以自己決定要不要套用 @ComponentScan 將當前類別 packagesub-packages 下找出所有套用 @Component 的類別。

注意: 一般來說,比較少人會直接在類別上套用 @Component 標注,而是用繼承 @Component 的標注為主,這些繼承 @Component 的標注又被稱為 Stereotype Annotations (刻板印象標注),像是 @Controller, @Service, @Repository, @Configuration 都屬於這類標注。因為 @RestController 繼承自 @Controller,所以這也算是一種 Stereotype Annotations (刻板印象標注)。

正常的情況下,你在根類別上使用 @SpringBootApplication 標注即可,應該 99% 的情境下都可以忽略底層的技術細節。

示範幾種不同的套用情境

  1. 整個應用程式只有一個類別,不需要 @ComponentScan 自動掃描 sub-packages

    package com.duotify.starter1;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    @EnableAutoConfiguration
    public class DemoApplication {
    
      @RequestMapping("/")
      String home() {
        return "Hello World!123";
      }
    
      public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
      }
    
    }
    

    這種例子是比較少見,大多出現在剛學習 Spring Boot 框架時會看到的程式碼範例中。

    這裡的 @EnableAutoConfiguration 標注會自動設定所有 Spring Bean,但我們並沒有套用 @ComponentScan,所以他並不會自動去找 com.duotify.starter1com.duotify.starter1.* 的類別。

    這裡的 @RestController 標注,其原始碼長這樣,他包含了 @Controller@ResponseBody 標注:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Controller
    @ResponseBody
    public @interface RestController {
    

    @Controller 標注的原始碼長這樣:

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Component
    public @interface Controller {
    

    所以其實他有套用 @Component,因此會被 @EnableAutoConfiguration 標注自動設定,所以 DemoApplication 類別本身就會被註冊成 Spring Bean 元件,因此可以直接當 Controller 來執行。

  2. 整個應用程式不只有一個類別,我們希望把 Controller 類別拆開來撰寫,因此需要 @ComponentScan 自動掃描 sub-packages 中的類別

    我先把 DemoApplication.java 改成這樣:

    package com.duotify.starter1;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    
    @EnableAutoConfiguration
    public class DemoApplication {
    
      public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
      }
    
    }
    

    然後加入一個 com.duotify.starter1.controllers.HomeController 類別:

    package com.duotify.starter1.controllers;
    
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class HomeController {
      @RequestMapping("/")
      String home() {
        return "Hello World!";
      }
    }
    

    啟動網站後,將無法找到 HomeController 控制器,因為他並沒有被註冊成 Spring Bean 元件。

    $ curl localhost:8080
    {"timestamp":"2022-09-19T08:02:19.812+00:00","status":404,"error":"Not Found","path":"/"}
    

    我們在 DemoApplication.javaDemoApplication 類別加上 @ComponentScan 標注:

    package com.duotify.starter1;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    import org.springframework.context.annotation.ComponentScan;
    
    @EnableAutoConfiguration
    @ComponentScan
    public class DemoApplication {
    
      public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
      }
    
    }
    

    重新啟動應用程式就可以順利呼叫 API 了!

    $ curl localhost:8080
    Hello World!
    
  3. 我們有個共用的物件,透過 @Bean 註冊,並讓其他也註冊在 Spring IoC container 的元件使用

    因為所有使用 @Component 標注的類別,最終都會被註冊到 Spring IoC container 裡面。而所有註冊到 Spring IoC container 的元件都可以注入到其他元件中。所以我們可以在 DemoApplication 類別加入一個 Spring Bean 方法:

    @Bean
    public String appName() {
      return "Starter1";
    }
    

    加入之後,這個有套用 @Bean 標注的 appName() 方法,其實並不會在 Spring Boot 應用程式啟動時自動執行,除非你在 DemoApplication 類別上套用 @Configuration 標注。結果如下:

    package com.duotify.starter1;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    
    @EnableAutoConfiguration
    @ComponentScan
    @Configuration
    public class DemoApplication {
    
      public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
      }
    
      @Bean(name = "appName")
      public String appName() {
        return "Starter1";
      }
    
    }
    

    由於我們已經收集到 @EnableAutoConfiguration, @ComponentScan, @Configuration 這三個標注,因此現在可以直接合併成 @SpringBootApplication 標注:

    package com.duotify.starter1;
    
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.ComponentScan;
    import org.springframework.context.annotation.Configuration;
    
    @SpringBootApplication
    public class DemoApplication {
    
      public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
      }
    
      @Bean(name = "appName")
      public String appName() {
        return "Starter1";
      }
    
    }
    

    最後,我們修改 HomeController 類別,使用建構式注入 String 這個 Spring Bean 來用:

    package com.duotify.starter1.controllers;
    
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class HomeController {
    
        private String appName;
    
        public HomeController(String appName) {
            this.appName = appName;
        }
    
        @RequestMapping("/")
        String home() {
            return "Hello " + this.appName + "!";
        }
    }
    

    若你想用「屬性注入」也可以,程式碼也更簡潔,只要在欄位套用 @Autowired 即可:

    package com.duotify.starter1.controllers;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    @RestController
    public class HomeController {
    
        @Autowired
        private String appName;
    
        @RequestMapping("/")
        String home() {
            return "Hello " + this.appName + "!";
        }
    }
    

    測試執行:

    $ curl localhost:8080/
    Hello Starter1!
    

總結

其實這篇文章已經牽涉許多基礎知識,其中包含:

因為這些都是 Spring 的核心機制,只要能完整理解上述概念,你就可以很好的理解其他部分!👍

相關連結