Published on

「Spring Boot API 開發:從 0 到 1」Day 17 Spring Boot 與 H2 資料庫

在開發過程中,我們經常需要一個輕量級的資料庫來快速驗證想法或進行原型開發

在 Java 的 solution 裡面,經常使用 H2 來做為這樣子的資料庫

讓我們來深入了解 H2 資料庫,以及如何在 Spring Boot 中使用它

其它語言比較常用的輕量資料庫大多是 SQLite 比較多

H2 資料庫簡介

H2 是一個用 Java 編寫的關聯型資料庫,它具有以下優點

  • 輕量級:H2 的 JAR 檔案僅約 2MB,啟動速度快
  • 嵌入式:可以直接嵌入到 Java 應用程式中,無需單獨安裝
  • 兼容性:支持標準 SQL,兼容多種資料庫模式
  • 速度快:在記憶體模式下性能極高
  • 支持持久化:可以選擇將資料保存到實體檔案
  • 瀏覽器控制台:提供基於 Web 的管理界面

H2 的運行模式

H2 資料庫支持多種運行模式

  • 嵌入式模式(Embedded):資料庫直接嵌入到應用程式中,資料儲存在記憶體或檔案中
  • 服務器模式(Server):H2 作為獨立的資料庫服務器運行,可以通過網絡連接
  • 記憶體模式(In-memory):資料僅儲存在記憶體中,應用程式關閉後資料就會不見,比較適合測試場景

Spring Boot 中使用 H2 和 JDBC API

增加依賴

對於一些常用的 starter,可以直接用 IDE 在 build.gradle 提供的功能 Edit Starters,直接在視窗裡面勾選添加就好了

這個視窗和一開始建立專案時的視窗是一樣的

選完後,build.gradle 會自動幫我們新增相關的依賴

dependencies {
    runtimeOnly 'com.h2database:h2'
}

基本設定和 h2-console

可以在 application.properties 裡面先設定 console enabled ,就可以用 h2 的預設路徑 /h2-console 來瀏覽 DB

spring.h2.console.enabled=true

打開瀏覽器到 http://localhost:8080/h2-console/,就可以看到登入畫面了

左上角可以調整語系為 繁體中文

預設使用 jdbc:h2:~/test 就可以連接到 db,可以點 測試連結 確認是可以連結上的

為了測試方便,這裡使用了 Embedded (Local) Database 的方式,它會將 test DB 檔案儲存在根目錄 ~/

點連接的話,就會到預設的管理介面

修改程式碼

我們將之前的 TodoController,從使用 static List 改為使用 H2 資料庫

  • 我們在建構子中初始化了資料庫,如果不存在的話,就建立一個 todos 的 table
  • 這裡只用最基本的 Java SQL API 來操作 DB,可以看到需要寫非常多的程式碼,而且很麻煩
    • 基本上現在很少會這樣子操作 DB,我們在後面的文章會慢慢的調整寫法
public class TodoController {

    // 為了方便,在這裡只展示了修改後,所增加的 DB 操作相關程式碼
    private static final String DB_URL = "jdbc:h2:~/test";
    private static final String DB_USER = "sa";
    private static final String DB_PASSWORD = "";

    public TodoController() {
        initDatabase();
    }

    private void initDatabase() {
        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
                Statement stmt = conn.createStatement()) {
            String sql = "CREATE TABLE IF NOT EXISTS todos " +
                    "(id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
                    "title VARCHAR(255), " +
                    "completed BOOLEAN)";
            stmt.executeUpdate(sql);
        } catch (SQLException e) {
            logger.error("初始化資料庫失敗", e);
        }
    }

    @PostMapping
    public ResponseEntity<MyApiResponse<Todo>> createTodo(@RequestBody Todo todo) {

        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
                PreparedStatement pstmt = conn.prepareStatement(
                        "INSERT INTO todos (title, completed) VALUES (?, ?)",
                        Statement.RETURN_GENERATED_KEYS)) {

            pstmt.setString(1, todo.getTitle());
            pstmt.setBoolean(2, todo.isCompleted());
            pstmt.executeUpdate();

            try (ResultSet generatedKeys = pstmt.getGeneratedKeys()) {
                if (generatedKeys.next()) {
                    todo.setId(generatedKeys.getLong(1));
                }
            }

            return ResponseEntity.ok(new MyApiResponse<>(true, todo, null));
        } catch (SQLException e) {
            return ...
        }
    }

    @GetMapping
    public ResponseEntity<MyApiResponse<List<Todo>>> getAllTodos() {

        List<Todo> todos = new ArrayList<>();
        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
                Statement stmt = conn.createStatement();
                ResultSet rs = stmt.executeQuery("SELECT * FROM todos")) {

            while (rs.next()) {
                Todo todo = new Todo();
                todo.setId(rs.getLong("id"));
                todo.setTitle(rs.getString("title"));
                todo.setCompleted(rs.getBoolean("completed"));
                todos.add(todo);
            }

            return ResponseEntity.ok(new MyApiResponse<>(true, todos, null));
        } catch (SQLException e) {
            return ...
        }
    }

    @GetMapping("/{id}")
    public ResponseEntity<MyApiResponse<Todo>> getTodo(@PathVariable Long id) {

        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
                PreparedStatement pstmt = conn.prepareStatement("SELECT * FROM todos WHERE id = ?")) {

            pstmt.setLong(1, id);
            try (ResultSet rs = pstmt.executeQuery()) {
                if (rs.next()) {
                    Todo todo = new Todo();
                    todo.setId(rs.getLong("id"));
                    todo.setTitle(rs.getString("title"));
                    todo.setCompleted(rs.getBoolean("completed"));
                    return ResponseEntity.ok(new MyApiResponse<>(true, todo, null));
                } else {
                    throw new TodoNotFoundException(id);
                }
            }
        } catch (SQLException e) {
            return ...
        }
    }

    @PutMapping("/{id}")
    public ResponseEntity<MyApiResponse<Todo>> updateTodo(@PathVariable Long id, @RequestBody Todo updatedTodo) {

        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
                PreparedStatement pstmt = conn.prepareStatement(
                        "UPDATE todos SET title = ?, completed = ? WHERE id = ?")) {

            pstmt.setString(1, updatedTodo.getTitle());
            pstmt.setBoolean(2, updatedTodo.isCompleted());
            pstmt.setLong(3, id);

            int affectedRows = pstmt.executeUpdate();
            if (affectedRows > 0) {
                updatedTodo.setId(id);
                return ResponseEntity.ok(new MyApiResponse<>(true, updatedTodo, null));
            } else {
                return createNotFoundError(id);
            }
        } catch (SQLException e) {
            return ...
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<MyApiResponse<Todo>> deleteTodo(@PathVariable Long id) {

        try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
                PreparedStatement pstmt = conn.prepareStatement("DELETE FROM todos WHERE id = ?")) {

            pstmt.setLong(1, id);
            int affectedRows = pstmt.executeUpdate();

            if (affectedRows > 0) {
                return ResponseEntity.ok(new MyApiResponse<>(true, null, null));
            } else {
                return createNotFoundError(id);
            }
        } catch (SQLException e) {
            return ...
        }
    }
}

測試 API

使用之前的 api-test.http 來測試相關的 CRUD,應該可以看到返回的訊息都跟之前一樣,沒有什麼問題

也可以使用 /h2-console 來看一下資料有沒有正確寫入

H2 資料庫配置

Spring Boot 提供了許多配置選項來自定義 H2 資料庫的行為

以下是一些常用的配置項,可以在 application.properties 中設置

基本上這些設定就是在使用 h2-console 的時候會用到的

# 啟用 H2 控制台
spring.h2.console.enabled=true

# 資料庫 URL
spring.datasource.url=jdbc:h2:~/test

# 驅動程式類別名稱
spring.datasource.driver-class-name=org.h2.Driver

# 資料庫用戶名
spring.datasource.username=sa

# 資料庫密碼
spring.datasource.password=

# H2 控制台路徑
spring.h2.console.path=/h2-console

自動建立資料庫表

將初始化資料庫的相關動作寫在某個控制器的建構子中,是還蠻奇怪的,下面就來看可以怎麼修改

使用 schema.sql 文件

注意,使用此方法的的話,需要安裝 JDBC 的 starter 才可以啟用自動執行,如果不想安裝的話,可以參考下一個方法

implementation 'org.springframework.boot:spring-boot-starter-jdbc'

src/main/resources 目錄下建立一個 schema.sql 文件:

CREATE TABLE IF NOT EXISTS todos (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN NOT NULL
);

然後在 application.properties 中添加以下配置:

# 表示每次應用程式啟動時都會執行初始化的 script
spring.sql.init.mode=always

# 指定了資料庫 schema 初始化 script 的位置
spring.sql.init.schema-locations=classpath:schema.sql

測試之前,記得先把原本寫在 TodoController 裡面的 initDatabase 刪除

如果在根目錄下面已經有 test.mv.db 的話,可以先砍掉

使用 @PostConstruct 註解

另一種方法是使用 @PostConstruct 註解,允許你在程式碼中動態生成 SQL,在應用程式啟動時執行 SQL script

建立一個新的類別 DatabaseInitializer

@Component
public class DatabaseInitializer {

    @PostConstruct
    public void initDatabase() {

        String url = "jdbc:h2:~/test";
        String user = "sa";
        String password = "";

        String sql = "CREATE TABLE IF NOT EXISTS todos (" +
                "id BIGINT AUTO_INCREMENT PRIMARY KEY," +
                "title VARCHAR(255) NOT NULL," +
                "completed BOOLEAN NOT NULL)";

        try (Connection conn = DriverManager.getConnection(url, user, password);
             Statement stmt = conn.createStatement()) {

            stmt.execute(sql);
            System.out.println("初始化資料庫建立成功");

        } catch (Exception e) {
            System.err.println("初始化資料庫失敗:" + e.getMessage());
        }
    }
}

這個類別會在 Spring 容器初始化後自動執行 initDatabase 方法,確保 todos 被建立

另外,可以看到 log 裡面也有成功的訊息

實務上的話,會使用 flyway 或是 liquibase 這種專門的工具來管理 DB 的 schema 變化

結論

通過使用 H2 資料庫,並結合自動化的資料庫初始化,我們可以快速建立一個具有資料庫功能的原型應用

這種方法不僅簡化了開發過程,還為後續的擴展和優化提供了良好的基礎

無論是用於學習、測試還是快速驗證想法,這種設置都能提供一個輕量且功能完備的開發環境

同步刊登於 iTHome 鐵人賽 「Spring Boot API 開發:從 0 到 1」Day 17 Spring Boot 與 H2 資料庫

圖片來源:AI 產生

參考連結