資料庫也能做版控!? Flyway使資料庫遷移更容易

Flyway 是一套開源數據庫遷移工具,能夠做到資料庫的版本管理,並即時的完成資料遷移作業。

本文將在 20 分鐘內,透過 2 個觀念建立、3 個實際用例,帶你明白 Flyway 是什麼,並彙整 3 步驟實踐資料庫版控、4 步驟立即導入既有專案中。

本文架構

首先,為何需要資料庫遷移?

在 Flyway 中,任何對資料庫進行的異動都稱為 Migration(遷移)

在開發新專案、維護既有系統、處理各式 CR 的開發週期中,面對各種不同狀況,勢必會變更到資料庫的設計,例如: 異動欄位、索引變更等等…

而上述這些異動也都將由不同的開發人員進行,通常會需要將異動統一進行維護,以利開發週期各個階段執行。

在實務上,不同開發人員、多個環境,此時手動執行資料庫的異動將可能出現如下狀況

  1. 線上問題修復的資料庫異動沒有同步到各個測試環境
  2. 更新版本尚未預先執行異動腳本導致出錯
  3. 異動腳本指令錯誤並未被及時發現

要減少上述問題,我們希望能夠實現下列事項:

  1. 有效追蹤資料庫異動歷程。
  2. 各個環境的資料庫異動不必手動執行,降低錯誤率。
  3. 每次程式進行更版時能夠立即執行 DDL & DML 變更。

為了達到上述目標,我們將使用 Flyway 來替我們實現。

Flyway 資料庫遷移運作方式?

❖ 基本運作原理

❶ 透過歷史紀錄表 (flyway_schema_history) 紀錄每一次的資料庫異動

Flyway 首先會檢查資料庫使否有此紀錄表 (flyway_schema_history) ,若沒有則會優先建立此表,接著針對需要被執行的 SQL 檔案,會先計算出 checksum 作為驗證,於每次 Flyway 啟動時根據 checksum 驗證資料庫是否需要被更改。

備註:checksum 計算採用 crc-32 checksum。

❷ 透過 SQL 或 Java 編寫 DDL/DML,並定義其版號使 Flyway 進行掃描並執行

Flyway 將可設定執行順序,預設依據版號進行排序執行,並且寫入歷史紀錄表 (flyway_schema_history) ,Flyway 提供多種 Migration 的方式,以下將針對 Migration 的類型、實作做更詳細的介紹。

❖ 遷移的類型

❶ Versioned Migrations(版本遷移)

用於創建、更新、刪除:表、索引、外鍵。

❷ Undo Migrations(撤銷遷移)

即為 Versioned Migratios 的回滾機制。

❸ Repeatable Migrations(重複遷移)

用於建立 views/procedures/functions/packages/…以及批次寫入特定數據。

❖ 遷移的實作

❶ SQL-based migrations

這是最常使用也最便利的實作方式,主要用於實作 DDL 的變更及簡單的資料異動。

命名規則: (擷取官方圖片)

❷ Java-based migrations

實作情境用於 BLOB & CLOB 的變更以及較為複雜的資料異動。

類別命名規則: (擷取官方圖片)

❸ Script migrations

目前官方支援 .ps1, .bat, .cmd, .sh, .bash, .py 的腳本撰寫,其命名方式與 SQL-based migrations 相同,只差在腳本的副檔名不同。

如何開始使用 Flyway ?

瞭解基本 Flyway 的 Migration 類型與實作種類,接著我將以 Spring Boot 與測試框架 TestContainers 進行實務上開發與測試的說明。

❖ 實作一. 搭配 Spring Boot

以下三步驟即完成 Flyway 基於 Spring Boot 的實際用例配置

❶ Maven 配置

<properties>
    <java.version>11</java.version>
    <testcontainers.version>1.15.2</testcontainers.version>
</properties>
<dependencies>
    <!--Spring-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!--Logger-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-logging</artifactId>
    </dependency>

    <!--Database-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.23</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <!--Flyway-->
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
        <version>6.5.7</version>
    </dependency>

    <!--Testing-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>mysql</artifactId>
        <scope>test</scope>
    </dependency>

</dependencies>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>${testcontainers.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

❷  設定檔配置

# ==============================================================

# = 資料庫設定(非 Testcontainers 資料庫設定)

# ==============================================================

spring.datasource.url=jdbc:mysql://localhost:3307/flyway_db?useUnicode=true&characterEncoding=utf-8&socketTimeout=60000&connectTimeout=30000
spring.datasource.username=flyuser
spring.datasource.password=123456
spring.jpa.show-sql=true
spring.jpa.open-in-view=true
spring.jpa.properties.hibernate.connection.release_mode=AUTO
spring.jpa.hibernate.ddl-auto=validate

#最小空閒連接數
spring.datasource.hikari.minimum-idle=10 #連接池最大大小
spring.datasource.hikari.maximum-pool-size=50 #連接最大空閒時長
spring.datasource.hikari.idle-timeout=60000 #連接生命時長
spring.datasource.hikari.max-lifetime=1800000 #連接的超時時長
spring.datasource.hikari.connection-timeout=30000

## ==============================================================

## FLYWAY 配置

### ==============================================================

spring.flyway.url=jdbc:mysql://localhost:3307/flyway_db?useUnicode=true&characterEncoding=utf-8&socketTimeout=60000&connectTimeout=30000
spring.flyway.user=flyuser
spring.flyway.password=123456
spring.flyway.locations=classpath:doc/migration/common,classpath:db/migration
spring.flyway.table=flyway_schema_history
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1.0
spring.flyway.out-of-order=true
spring.flyway.validate-on-migrate=true
spring.flyway.enabled=true

# ==============================================================

# = LOGGING

# ==============================================================

logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
logging.level.org.flywaydb=debug
logging.level.root=debug

❸  建置 Migration 檔案(此例使用 SQL & Java)

SQL Base Migration

● V1.0__sql_base_migration_ddl.sql

CREATE TABLE IF NOT EXISTS book_case
(
  id char(36) NOT NULL comment '流水號',
  title varchar(20) NOT NULL comment '書名',
  author varchar(20) NOT NULL comment '作者',
  translator varchar(20) NOT NULL comment '譯者',
  publisher varchar(50) NOT NULL comment '出版社',
  publication_date datetime NOT NULL comment '出版日期',
  create_time datetime NOT NULL comment '創建時間',
  update_time datetime NOT NULL comment '更新時間',
  PRIMARY KEY (id),
  KEY index_createTime (create_time)
)ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

● V1.1__sql_base_migration_dml.sql

INSERT INTO book_case (
    id,
    title,
    author,
    translator,
    publisher,
    publication_date,
    create_time,
    update_time)
  VALUES (
    '0157e79c-b2dd-4efe-ad40-320cc94c051e',
    'THE INFINITE GAME',
    'Simon Sinek',
    'Huang Tingmin',
    'Commonwealth Magazine',
    '2020-12-30 00:00:00',
    '2021-04-13 20:55:36',
    '2021-04-13 20:55:36');

Java Base Migration

● R__java_base_migration_sample.java

package db.migration;

import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementSetter;

import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.UUID;

public class R__uuid_generator extends BaseJavaMigration {

    @Override
    public void migrate(Context context) throws Exception {
        final UUID id = UUID.randomUUID();
        LocalDateTime publishDate = LocalDateTime.of(2020, 12, 30, 0, 0, 0);
        String sql = "INSERT INTO book_case (id, title, author, translator, publisher, publication_date, create_time, update_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)";

        JdbcTemplate template = new JdbcTemplate(context.getConfiguration().getDataSource());
        template.update(sql, new PreparedStatementSetter() {
            public void setValues(PreparedStatement preparedStatement) throws SQLException {
                preparedStatement.setString(1, id.toString());
                preparedStatement.setString(2, "THE INFINITE GAME");
                preparedStatement.setString(3, "Simon Sinek");
                preparedStatement.setString(4, "Huang Tingmin");
                preparedStatement.setString(5, "Commonwealth Magazine");
                preparedStatement.setTimestamp(6, Timestamp.valueOf(publishDate));
                preparedStatement.setTimestamp(7, Timestamp.valueOf(LocalDateTime.now()));
                preparedStatement.setTimestamp(8, Timestamp.valueOf(LocalDateTime.now()));
            }
        });
    }

}

專案架構以及配置參數說明

● 專案結構 (分別列出 SQL Base Migration & Java Base Migration)

├── pom.xml
├── src
│ ├── main
│ │ ├── java
│ │ │ ├── META-INF
│ │ │ ├── com
│ │ │ │ └── tpisoftware
│ │ │ └── db
│ │ │ └── migration
│ │ │ └── R_java_base_migration_sample.java
│ │ └── resources
│ │ ├── doc
│ │ │ ├── migration
│ │ │ │ └── common
│ │ │ │ ├── V1.0_sql_base_migration_ddl.sql
│ │ │ │ ├── V1.1_sql_base_migration_dml.sql

● 配置參數概述

◆ 連線配置
與 datasource 配置相同,當 Application 啟動時,flyway 將會進行連線,並執行 migration
‣ spring.flyway.url
‣ spring.flyway.user
‣ spring.flyway.password

◆ 檔案存取位置
可接受多個參數(請用逗號分隔),指定 flyway 讀取執行 migration 的路徑
‣ spring.flyway.locations

◆ 歷史紀錄表定義
Flyway 歷史紀錄表預設命名為 flyway_schema_history,若有需要可以更改名稱
‣ spring.flyway.table

◆ 是否執行起始版號
當資料庫不為空,是否要執行起始版本,並建立歷史紀錄表,預設為 false,如果並非在專案一開始就導入 flyway,就需要設定為 true
‣ spring.flyway.baseline-on-migrate

◆ 起始版本設定
設定 migration 的起始版號
‣ spring.flyway.baseline-version

◆ 執行 migration 是否允許無序執行
‣ spring.flyway.out-of-order

◆ 執行 migration 是否自動驗證
‣ spring.flyway.validate-on-migrate

◆ 是否啟用 Flyway
‣ spring.flyway.enabled

❖ 實作二. 搭配測試框架 TestContainers 的驗證

以下說明 Flyway 基於 Spring Boot Test With TestContainers 的測試驗證。

❶ 容器化測試基本建立方式可以點選此查閱此篇文章,有詳細介紹容器化測試框架,此文不贅述。

❷ 動態載入 datasource 參數,指定給 flyway 的連線配置(與 spring datasource 相同)

/**
 * 動態設定參數
 * @param registry
 */
@DynamicPropertySource
static void mssqlProperties(DynamicPropertyRegistry registry) {
    //mysql properties setting
    registry.add("spring.datasource.driver-class-name", mySQLContainer::getDriverClassName);
    registry.add("spring.datasource.url", () -> mySQLContainer.getJdbcUrl());
    registry.add("spring.datasource.username", mySQLContainer::getUsername);
    registry.add("spring.datasource.password", mySQLContainer::getPassword);

    //flyway properties setting
    registry.add("spring.flyway.url", () -> mySQLContainer.getJdbcUrl());
    registry.add("spring.flyway.user", mySQLContainer::getUsername);
    registry.add("spring.flyway.password", mySQLContainer::getPassword);
    registry.add("spring.flyway.enabled", () -> "true");
}

❸ 完成配置運行 Spring Test 時會進行以下動作:

● TestContainers 啟動本地端的 MySQL Container

● Flyway 進行驗證

● Flyway 執行 Migration

● 運行測試程式

❖ 實作三. 使用 Maven 運行 Flyway

以下為 Flyway 基於 Maven 的實際用例。

❶ 新增 Maven-Plugin

<plugin>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-maven-plugin</artifactId>
    <version>6.5.7</version>
</plugin>

❷ Maven Flyway Configuration 配置

請點選這裡查看資料庫支援及設定說明(本例使用 MySQL,並在本地端啟用 Docker mysql/mysql-server:8.0 作測試)

<configuration>
    <driver>com.mysql.jdbc.Driver</driver>
    <url>jdbc:mysql://localhost:3307/flyway_db</url>
    <user>flyuser</user>
    <password>123456</password>
    <connectRetries>3</connectRetries>
    <createSchemas>true</createSchemas>
    <table>flyway_schema_history</table>
    <locations>
        <location>classpath:doc/migration/common</location>
        <location>classpath:db/migration</location>
    </locations>
    <encoding>utf-8</encoding>
    <target>1.0</target>
    <outOfOrder>true</outOfOrder>
    <validateOnMigrate>true</validateOnMigrate>
    <baselineOnMigrate>true</baselineOnMigrate>
    <baselineVersion>1.0</baselineVersion>
    <baselineDescription>Let's go!</baselineDescription>
</configuration>

❸ 執行 Migration(-X 查看完整紀錄)

mvn flyway:migrate -X

❹ 運行結果(附上分段截圖)

Maven 執行,Flyway 讀取 Migration 檔案中

Flyway 的 Migration 檔案驗證通過後,會開始執行 Migration

查看資料庫,確認 DDL & DML 確實執行完畢

瞭解上述的運作原理及實作方式,另外可依據專案需求自行決定以下執行 Migration 的方式

❶ Command-line tool

Linux / Docker / Windows /Mac OS 皆有支援。

點選此直接查看官網使用說明。

❷ Gradle

支援 Gradle 3.x, Gradle 4.x, Gradle 5.x, and Gradle 6.x 可運行在 Java 8, Java 9, Java 10, Java 11 or Java 12.

點選此直接查看官網使用說明。

既有系統如何導入 Flyway ?

前面的情境都是以資料庫初始化的狀態說明,那如果既有系統也想導入呢? 以下列出四個步驟

❶ 備份 DML 以及 匯出 DDL。

❷ 先在本地端實際運行一次 Migration,執行 DML&DDL。

❸ 匯出上一步驟寫入 flyway_schema_history 的資料。

❹ 連線至測試環境手動建立 flyway_schema_history 並匯入資料。

執行完上述步驟,Flyway 啟用後即不會再重複執行 DML & DDL,只會運行後續新增的版本。

總結

本篇文章整理出 Flyway 的實際案例,盡可能點出大部分的使用情境,從 Migration 觀念建立、搭配專案常使用的 Spring Boot、測試框架驗證、Flyway Maven Plugin 使用,以及既有系統的導入指引。

Flyway 本身有提供付費版本,在官方文件中皆以[Flyway Teams]標記標註,另外 Flyway 還提供了 Callback 功能,方便我們可以自行依據需求在 Migration 前執行一些動作,本篇文章介紹的 Migration 功能基本上就能應付大多數情境,而 Maven 的方式則可以搭配 CICD,相信能夠一定程度節省專案開發協作與交付的時間。

更多的細節,以下提供官方提供的文檔以及相關資訊作為參考。



References: