DBスキーマのバージョン管理、皆さんはどうしていますか。Spring Bootだと標準サポートされているFlywayを選ぶケースが多いですが、ロールバックや記法の柔軟さで Liquibase を選びたい場面もありますよね。
この記事ではSpring BootにLiquibaseを組み込み、changelogの書き方からロールバック実行までを一通り見ていきます。Flywayとの違いもあわせて整理するので、選定の参考にしてください。
LiquibaseとFlywayの違い
まず採用判断のために、両者の特徴を比較します。
| 観点 | Liquibase | Flyway |
|---|---|---|
| 記法 | XML/YAML/JSON/SQL | SQL(OSS)/Java |
| ロールバック | ネイティブ対応 | Undo機能はTeams/Enterprise版のみ(OSS版はSQLでの手動運用) |
| 学習コスト | やや高い | 低い |
| DB対応 | 非常に広い | 主要DBに対応 |
SQLだけで完結させたいならFlyway、ロールバックや複雑な変更を抽象化したいならLiquibaseが向いています。Flywayの詳細は Spring BootでFlywayを使ったDBマイグレーション を参照してください。
依存追加と初期設定
Mavenであれば liquibase-core を追加するだけです。
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
application.yml 側ではchangelogのパスやcontextを指定します。
spring:
liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.xml
contexts: dev
change-log を指定しない場合、Spring Bootは classpath:/db/changelog/db.changelog-master.{yaml,yml,json,xml,sql} を自動検出します。XMLで書く場合は明示しておくと安心ですね。
自動マイグレーションは LiquibaseAutoConfiguration の仕業です。アプリ起動時にLiquibaseが DATABASECHANGELOG と DATABASECHANGELOGLOCK テーブルを作成し、未適用のchangeSetを順番に流してくれます。無効化したいときは spring.liquibase.enabled=false を設定します。
master changelogとファイル分割
changelogが肥大化すると見通しが悪くなるので、リリース単位や機能単位でファイルを分けます。masterから include や includeAll で読み込む構成が定番です。XSDは使うLiquibaseのバージョンに合わせるか、dbchangelog-latest.xsd を指定すると追従が楽です。
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="db/changelog/v1.0/001-create-users.xml"/>
<include file="db/changelog/v1.0/002-create-orders.xml"/>
<includeAll path="db/changelog/v1.1/"/>
</databaseChangeLog>
includeAll はファイル名のアルファベット順に取り込まれるので、001-、002- のようなプレフィックスで順序を制御しましょう。
XML形式のchangeSet基本構文
changeSetは id と author の組み合わせで一意に管理されます。一度適用されたchangeSetは内容を後から変更しないのが鉄則です。
<changeSet id="001-create-users" author="mizucoffee">
<createTable tableName="users">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="email" type="VARCHAR(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
</changeSet>
外部キーは参照先テーブルが既に存在している必要があるので、別changeSetに分けます。先に orders テーブルを作る前提です。
<changeSet id="002-create-orders" author="mizucoffee">
<createTable tableName="orders">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="user_id" type="BIGINT">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="003-fk-orders-users" author="mizucoffee">
<addForeignKeyConstraint
baseTableName="orders" baseColumnNames="user_id"
referencedTableName="users" referencedColumnNames="id"
constraintName="fk_orders_user"/>
</changeSet>
constraints 要素で nullable や unique、primaryKey を指定できます。エンティティ設計の話は JPAエンティティのリレーションマッピング も参考になります。
YAML形式とSQL形式
XMLが冗長に感じるならYAMLでも書けます。
databaseChangeLog:
- changeSet:
id: 001-create-users
author: mizucoffee
changes:
- createTable:
tableName: users
columns:
- column:
name: id
type: BIGINT
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: email
type: VARCHAR(255)
constraints:
nullable: false
unique: true
SQLが既にある、あるいはチームがSQL慣れしているならformatted SQLも便利です。
--liquibase formatted sql
--changeset mizucoffee:001-create-users
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
--rollback DROP TABLE users;
形式は混在させず、プロジェクト内で統一しておくとレビューが楽になります。
preConditionsとcontextで条件付き実行
環境差に応じて適用を切り替えたい時は preConditions と context を使います。
<changeSet id="010-seed-dev-data" author="mizucoffee" context="dev">
<preConditions onFail="MARK_RAN">
<tableExists tableName="users"/>
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM users</sqlCheck>
</preConditions>
<insert tableName="users">
<column name="email" value="[email protected]"/>
</insert>
</changeSet>
context="dev" は application.yml の contexts と照合され、開発環境でのみ実行されます。なお contexts を未指定で起動するとフィルタは無効になり、全changeSetが対象になる点に注意です。
contexts は「どの環境で流すか」、labels は「どの目的タグの変更を流すか」という観点の違いがあります。環境分離はcontexts、機能フラグ的に変更を束ねたい場合はlabels、と使い分けるとすっきりします。
ロールバックの書き方
Liquibase最大の魅力がロールバックです。createTable のような可逆な変更は自動で逆操作を導出してくれますが、データ変更や sql タグでは手動で書きます。
<changeSet id="020-add-status-column" author="mizucoffee">
<addColumn tableName="users">
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"/>
</addColumn>
<rollback>
<dropColumn tableName="users" columnName="status"/>
</rollback>
</changeSet>
<changeSet id="021-tag-v1" author="mizucoffee">
<tagDatabase tag="v1.0.0"/>
</changeSet>
tagDatabase でチェックポイントを打っておけば、後からそのタグまで戻せます。Maven pluginから実行する例はこちらです。
# 直近1件を戻す
mvn liquibase:rollback -Dliquibase.rollbackCount=1
# 特定タグまで戻す
mvn liquibase:rollback -Dliquibase.rollbackTag=v1.0.0
DROP系の物理削除(dropTable や delete 後の状態)は、rollbackタグを書いてもデータ自体は復元できません。スキーマは戻せても中身は戻らない、と覚えておきましょう。ロールバック前提なら論理削除や別テーブル退避を検討します。
プロファイル別の運用
環境ごとの挙動制御は application-{profile}.yml で切り替えるのがシンプルです。
# application-prod.yml
spring:
liquibase:
enabled: false # 本番はCIから明示実行する想定
contexts: prod
本番で enabled: false にした場合は、CI/CDから次のように明示実行します。
mvn liquibase:update -Dliquibase.contexts=prod
プロファイルの切替方法は プロファイルで環境別設定を安全に切り替える も参考にしてください。
よくあるトラブル
導入直後に詰まりやすいポイントを軽くまとめておきます。
- checksum不一致エラー : 適用済みchangeSetを編集すると発生します。原則編集せず、修正用の新しいchangeSetを追加する運用に。やむを得ない場合は
mvn liquibase:clearCheckSumsで再計算します。 - DATABASECHANGELOGLOCKが解除されない : 強制終了などでロックが残った場合は
mvn liquibase:releaseLocksか、該当テーブルを直接UPDATEで解放します。 - changelogが読み込まれない :
classpath:パスやファイル配置(src/main/resources/db/changelog/)を確認しましょう。includeAllで空ディレクトリを指していないかも要チェックです。 - Hibernate
ddl-autoとの併用 :updateなどとの併用は予期しないスキーマ変更の原因になるので、本番ではnoneにしてLiquibaseに一本化するのが安全です。
まとめ
LiquibaseはXML/YAML/SQLで柔軟に書け、ロールバックが標準サポートされている点が強みです。チームのスキルセットや変更の複雑さに応じて、FlywayとLiquibaseを使い分けるとよいでしょう。
まずは小さなchangeSetから始めて、tagDatabase でリリース単位の戻り先を確保しておくと、運用がぐっと楽になります。