【Spring Boot】第7課-手動進行元件的初始化

本文最後更新於:2025-03-25

在 Spring Boot 的 IOC 容器中,存放著各種元件。元件除了依賴其他元件,它們也可以擁有自己的資料成員。比方說建立 List、Map 或其它物件的全域變數,來儲存自己的資料,或者說狀態。

但這些用來儲存元件狀態的全域變數,可能需要先完成初始化,才能開始使用。本文將介紹 @Bean 注解,讓我們透過程式碼手動控制元件的初始化。


本文的練習用專案,請點我

一、範例程式介紹

在本文的練習用專案,能找到筆者事先準備好的範例程式。

簡單來說,就是讓 controller 去呼叫 repository,存取使用者資料。而 repository 層又提供 2 種實作方式,分別透過 Map 與 List 資料結構來儲存資料。

以下是使用者的類別。

public class User {
    private String id;
    private String name;

    public static User of(String id, String name) {
        var u = new User();
        u.id = id;
        u.name = name;

        return u;
    }

    // getter, setter ...
}

以下是 repository 層的介面,提供新增、取得與刪除的方法。此介面會有 2 個類別分別去實作。

public interface IUserRepository {
    void insert(User user);
    User findById(String id);
    void deleteById(String id);
}

以下是透過 Map 結構來儲存資料的 repository。

@Repository
public class MapUserRepository implements IUserRepository {
    private static final Map<String, User> userMap = new HashMap<>();

    public void insert(User user) {
        if (userMap.containsKey(user.getId())) {
            throw new RuntimeException("User id " + user.getId() + " is existing.");
        }

        userMap.put(user.getId(), user);
    }

    public User findById(String id) {
        return userMap.get(id);
    }

    public void deleteById(String id) {
        userMap.remove(id);
    }
}

以下是透過 List 結構來儲存資料的 repository。

@Repository
public class ListUserRepository implements IUserRepository {
    private static final List<User> userList = new ArrayList<>();

    public void insert(User user) {
        var isExisting = userList
                .stream()
                .anyMatch(u -> u.getId().equals(user.getId()));
        if (isExisting) {
            throw new RuntimeException("User id " + user.getId() + " is existing.");
        }

        userList.add(user);
    }

    public User findById(String id) {
        return userList
                .stream()
                .filter(u -> u.getId().equals(id))
                .findFirst()
                .orElse(null);
    }

    public void deleteById(String id) {
        userList.removeIf(u -> u.getId().equals(id));
    }
}

以下是 controller,它會依賴 IUserRepository,並透過介面提供的方法來操作。此處採用的實作類別為 MapUserRepository。

@RestController
@RequestMapping(path = "/users")
public class UserController {

    @Autowired
    @Qualifier("mapUserRepository")
    private IUserRepository userRepository;

    @PostMapping
    public ResponseEntity<Void> createUser(@RequestBody User user) {
        try {
            userRepository.insert(user);
            return ResponseEntity.status(HttpStatus.CREATED).build();
        } catch (RuntimeException e) {
            return ResponseEntity.unprocessableEntity().build();
        }
    }

    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable("id") String id) {
        var user = userRepository.findById(id);
        return user == null
                ? ResponseEntity.notFound().build()
                : ResponseEntity.ok(user);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable("id") String id) {
        userRepository.deleteById(id);;
        return ResponseEntity.noContent().build();
    }
}

二、簡易的初始化做法

在第一節的範例程式中,不論是 MapUserRepository 或 ListUserRepository,它們都透過全域變數來儲存資料。假設我們希望程式啟動時,裡面已經有預先準備好一些初始資料了,那可以怎麼做呢?

若讀者有看過筆者 Spring Boot 系列的前幾篇文章,就知道可以在類別中寫一個 static 區塊,如下:

@Repository
public class MapUserRepository implements IUserRepository {
    private static final Map<String, User> userMap = new HashMap<>();

    static {
        var users = List.of(
                User.of("U1", "Vincent"),
                User.of("U2", "Ivy"),
                User.of("U3", "Dora")
        );
        users.forEach(u -> userMap.put(u.getId(), u));
    }

    // ...
}

或者我們也可宣告一個方法,並冠上 @PostConstruct 注解,如下:

@Repository
public class MapUserRepository implements IUserRepository {
    private static final Map<String, User> userMap = new HashMap<>();

    @PostConstruct
    private void init() {
        var users = List.of(
                User.of("U1", "Vincent"),
                User.of("U2", "Ivy"),
                User.of("U3", "Dora")
        );
        users.forEach(u -> userMap.put(u.getId(), u));
    }

    // ...
}

這兩種做法的差別,在於執行的時間點。static 區塊會在程式一啟動時就執行,並且只能使用類別中的 static 資料成員及方法。

@PostConstruct 方法,則是在物件建立完成後才會執行。它可以使用 static 與 non-static 的資料成員及方法。由於物件(或者說元件)已經建立完成了,所以當然也能使用注入進來的其他元件。

三、使用 @Bean 注解建立元件

(一)認識 @Bean 注解

不論是 static 區塊,還是 @PostConstruct 所注解的方法,都能幫助我們完成全域變數的初始化。但這些做法,其實也會增加元件類別的程式碼。

本節將示範如何將元件的初始化過程,從自身的類別中分離出去。具體的做法是將元件所需要的資料或物件,先在外部初始化完成,再透過建構子傳入元件中。

Spring Boot 提供了 @Bean 注解,它會冠在元件類別中的方法上。其用途是將該方法的回傳值建立為元件,用法示意如下:

@Configuration
public class RepositoryConfig {

    @Bean
    public IUserRepository mapUserRepository() {
        return new MapUserRepository();
    }
}

以上建立了 Configuration 類別。接著宣告一個方法,回傳值型態為 IUserRepository 介面,而實際回傳的是 MapUserRepository 物件。

如此一來,Spring Boot 便會將 MapUserRepository 建立為元件。

(二)實作初始化過程

既然 @Bean 所注解的方法的回傳值會被建立為元件,那在回傳之前,不就可以客製我們想要的初始化過程了嗎?

根據前面筆者提到的做法,接下來就要在該方法中準備好測試資料,再傳入 MapUserRepository 或 ListUserRepository 的建構子中。

首先,請讀者調整這 2 個實作類別,讓它們的建構子能接收使用者資料。同時也要移除 @Repository 注解,因為我們即將改為透過 @Bean 注解來建立元件。

public class MapUserRepository implements IUserRepository {
    private final Map<String, User> userMap;

    public MapUserRepository(Map<String, User> userMap) {
        this.userMap = userMap;
    }

    // ...
}
public class ListUserRepository implements IUserRepository {
    private final List<User> userList;

    public ListUserRepository(List<User> userList) {
        this.userList = userList;
    }

    // ...
}

接著回到 Configuration 類別,分別宣告方法,對 MapUserRepository 與 ListUserRepository 進行初始化。

@Configuration
public class RepositoryConfig {

    @Bean(name = "mapUserRepo")
    public IUserRepository mapUserRepository() {
        var users = List.of(
                User.of("U1", "Vincent"),
                User.of("U2", "Ivy"),
                User.of("U3", "Dora")
        );

        var userMap = new HashMap<String, User>();
        users.forEach(u -> userMap.put(u.getId(), u));

        return new MapUserRepository(userMap);
    }

    @Bean(name = "listUserRepo")
    public IUserRepository listUserRepository() {
        var users = new ArrayList<User>();
        users.add(User.of("U1", "Vincent"));
        users.add(User.of("U2", "Ivy"));
        users.add(User.of("U3", "Dora"));

        return new ListUserRepository(users);
    }
}

這樣就能把元件初始化的過程,從自身類別中分離出去。

附帶一提,我們可在 @Bean 注解中定義元件的名稱(預設為方法名稱),供其他元件指定要注入哪一個實作。因此,controller 仍可透過 @Qualifier 注解進行指定。

public class UserController {

    @Autowired
    @Qualifier("mapUserRepo")
    private IUserRepository userRepository;

    // ...
}

四、綜合應用

(一)搭配其他注解

@Bean 所注解的元件建立方法,也可搭配其他注解一起使用。

比方說,當有回傳值型態相同的多個方法,可透過 @Primary 注解,來指定預設採用的元件。

@Configuration
public class RepositoryConfig {

    @Primary
    @Bean(name = "mapUserRepo")
    public IUserRepository mapUserRepository() {
        // ...
    }

    @Bean(name = "listUserRepo")
    public IUserRepository listUserRepository() {
        // ...
    }
}

另外,我們也可將元件所依賴的其他元件類別或介面,寫在方法參數中。示意如下:

@Configuration
public class ServiceConfig {
    // ...

    @Bean
    public UserService userService(IUserRepository userRepository) {
        return new UserService(userRepository);
    }
}
public class UserService {
    private IUserRepository userRepository;

    public UserService(IUserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

當然,也能使用 @Qualifier 注解,指定要取用的實作類別。

@Configuration
public class ServiceConfig {
    // ...

    @Bean
    public UserService userService(
        @Qualifier("mapUserRepo") IUserRepository userRepository
    ) {
        return new UserService(userRepository);
    }
}

(二)結合配置檔

在程式中,controller 只會取用其中一個實作類別。而另一個用不到的元件,其實不必建立出來。

因此,我們可將這兩個 @Bean 注解的方法合併,再透過 if 判斷決定要建立哪一個。

接下來的範例,筆者會結合第 6 課介紹的 application.properties 配置檔,提供元件初始化的一些選項。請先添加以下 2 個設定值:

user-repository.storage=map
user-repository.test-data.amount=3

第一個設定值代表要使用何種資料結構來儲存,可選擇「map」或「list」。第二個設定值代表要產生幾筆測試資料。

回到 Configuration 類別,在方法參數中使用 @Value 注解,即可將配置檔的設定值讀取進來使用。

@Configuration
public class RepositoryConfig {

    @Bean
    public IUserRepository userRepository(
            @Value("${user-repository.storage}") String storage,
            @Value("${user-repository.test-data.amount:0}") int amount
    ) {
        // 建立測試資料
        var users = new ArrayList<User>();
        for (var i = 1; i <= amount; i++) {
            var user = User.of("U" + i, "Test User " + i);
            users.add(user);
        }

        // 選擇資料結構
        if ("map".equalsIgnoreCase(storage)) {
            var userMap = new HashMap<String, User>();
            users.forEach(u -> userMap.put(u.getId(), u));
            System.out.println("Create MapProductRepository.");

            return new MapUserRepository(userMap);
        } else if ("list".equalsIgnoreCase(storage)) {
            System.out.println("Create ListProductRepository.");
            return new ListUserRepository(users);
        } else {
            throw new IllegalArgumentException("Please provide correct user repository storage type.");
        }
    }
}

在程式邏輯中,首先產生指定數量的測試資料(預設為 0 筆)。接著根據設定值是指定 Map 或 List 資料結構,再建立出對應的 repository 物件。

同時也將測試資料傳入 repository 的建構子中,隨即回傳,完成元件的初始化。

最後讀者別忘了確認注入 IUserRepository 的 Controller,已經不必再透過 @Qualifier 注解,來指定要注入的元件了。


本文的完成後專案,請點我

上一課:【Spring Boot】第6課-在 application.properties 配置檔提供設定值(以 Java Mail 為例)

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


張貼留言: