Featured image of post GradleにFlywayを組み込んでDBマイグレーションを自動化【SpringBoot2】

GradleにFlywayを組み込んでDBマイグレーションを自動化【SpringBoot2】

SpringBoot2のプロジェクトにGradle経由でFlywayを組み込み、DBのパッチ適用を自動化する手順。application.ymlとbuild.gradleの設定、パッチファイルとafterMigrateの用意、flywayCleanでのリセットまで♪

3488文字

パッチ管理を簡単にしよう

前回は、APIシステムをレイヤー構造にリファクタリングして動くところまでをご紹介しました。

関連記事 【マイクロサービス】流行りのSpring Boot 2 + Gradle + Java8でマルチプロジェクトな個人開発をしていこう レイヤー構造化編【DDD(ドメイン駆動設計)】 2019/04/10 3090文字 約7分で読めます IT Java Mac Windows アプリケーション

今回は、データベース接続をするための準備として、DB定義用のパッチSQLファイルを用意し、Gradle経由でFlywayを用いて自動で適用させるところまでを整えていこうと思います。

Flywayとは

Flywayは、オープンソースで開発されているデータベースのマイグレーションツールです。

Version control for your database. Robust schema evolution across all your environments. With ease, pleasure and plain SQL.Flyway by Boxfuse • Database Migrations Made Easy.

特定のルールに沿ってパッチファイルを管理することで、コマンドライン一発でDBのマイグレーションを行うことが出来るので、当て忘れや順番を間違えたりなどのヒューマンエラーを避ける事が出来ます。

[adsense-responsive]

また、簡単にデータベースをリセットしたり、afterMigrate機能を使って開発データを入れる事が出来るので開発においても非常に有効なツールとなっています。

今回はGradleのSpringBootプロジェクトへのFlywayの導入方法簡単なマイグレーション処理をご紹介していこうと思います。

手順

完成系

今回は以下のような非常にシンプルなテーブル構造を作ってみようと思います。

テーブル名用途
account.accountsアカウントのメインレコードを管理
account.account_infomationsアカウントの基本情報を管理
account.sex_types性別マスター
account.current_account_infomations各アカウントの基本情報レコードは蓄積を想定するため、現在の有効な基本情報をマッピングするためのテーブル

application-{profile}.ymlの設定

まずは、Flywayを実行するにあたって必要な項目をapplication-{profile}.ymlに定義していきます。

なお、springbootには公式でFlywayの設定項目がありますが、今回は設定が反映されてしまって起動時に自動実行させたくないのもあるため、意図的にflywayMigrateという独自階層で定義していきます。

api/src/main/resources/application-local.yml
spring:
  flywayMigrate: # 独自項目
    driver: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/local_account_db
    username: root
    password: root
    encoding: UTF-8
    sqlMigrationPrefix: patch_
    migrateLocation: filesystem:../datasource/flyway/migration/migrate
    isAfterMigratable: true # afterMigrateは環境によっては実行したくないこともあると思うのでbool値を定義
    afterMigrateLocation: filesystem:../datasource/flyway/migration/aftermigrate

build.gradleの修正

datasourceプロジェクト

今回はシステム内部からFlywayを呼ぶことは想定していないので、dependenciesへの追加は不要です。

代わりにGradleから実行するためにpluginsに指定をする必要があります。

datasource/build.gradleを以下のように修正してみました。

datasource/build.gradle
plugins {
    id "org.flywaydb.flyway" version "5.1.3" // pluginsブロックはConstantの必要があるとのこと
}

def env = System.getenv( 'SPRING_PROFILES_ACTIVE' ) // 実行時に定義した環境変数からprofileを取得
def path = "${accountService.applicationYmlPath}";
def applicationYml = new org.yaml.snakeyaml.Yaml().load( new File( path + "application-" + env  +".yml").newInputStream() )// 対象プロファイルのymlを読み込み

flyway { // flywayの初期設定
    driver="${applicationYml.spring.flywayMigrate.driver}"
    url = "${applicationYml.spring.flywayMigrate.url}"
    user = "${applicationYml.spring.flywayMigrate.username}"
    password = "${applicationYml.spring.flywayMigrate.password}"
    encoding = "${applicationYml.spring.flywayMigrate.encoding}"
    sqlMigrationPrefix = "${applicationYml.spring.flywayMigrate.sqlMigrationPrefix}"
    if("${applicationYml.spring.flywayMigrate.isAfterMigratable}".toBoolean()){ // afterMigrateをする場合のみafterMigrateのパスを追加
        locations = [
            "${applicationYml.spring.flywayMigrate.migrateLocation}",
            "${applicationYml.spring.flywayMigrate.afterMigrateLocation}"
        ]
    }else{
        locations = [
            "${applicationYml.spring.flywayMigrate.migrateLocation}"
        ]
    }

}
dependencies {
	implementation project(":value")
	implementation project(":entity")
}

devtoolsプロジェクト

devtools/build.gradleではgradle実行時に必要なPostgreSQLのドライバーとymlファイルを読み込むためのライブラリをクラスパスに追加します。

devtools/build.gradle
buildscript {
  apply from: 'property.gradle'

  ext {
  }
  repositories {
      mavenCentral()
  }
  dependencies {
      classpath("org.springframework.boot:spring-boot-gradle-plugin:${accountService.springBootVersion}")
      classpath "org.yaml:snakeyaml:${accountService.snakeyamlVersion}"// 追加
      classpath "org.postgresql:postgresql:${accountService.postgresVersion}"// 追加
  }
}
allprojects {
	apply plugin: 'eclipse'
}
subprojects {
	apply plugin: "java"
	apply plugin: "io.spring.dependency-management"

	group = accountService.group
	version = accountService.version
	sourceCompatibility = accountService.sourceCompatibility

	repositories {
		mavenCentral()
	}

    dependencyManagement {
    }

	dependencies {
		implementation "org.springframework.boot:spring-boot-starter:${accountService.springBootVersion}"
		testImplementation "org.springframework.boot:spring-boot-starter-test:${accountService.springBootVersion}"
	}

}

パッチファイル

次にパッチファイルを用意しましょう。今回は以下のような形で用意しました。

[adsense-responsive]

tree
.datasource
... 省略
└── flyway
     └── migration
         ├── afterMigrate
         │   └── afterMigrate.sql
         └── migrate
             └── 001
                 └── 000
                     ├── patch_001.000.001__create_schema_account.sql
                     ├── patch_001.000.002__create_table_account.accounts.sql
                     ├── patch_001.000.003__create_sequence_account.account_id.sql
                     ├── patch_001.000.004__create_sequence_account.account_infomation_id.sql
                     ├── patch_001.000.005__create_table_account.sex_type.sql
                     ├── patch_001.000.006__insert_account.sex_type.sql
                     └── patch_001.000.007__create_table_account.account_infomations.sql

patch_001.000.001__create_schema_account.sql
DROP SCHEMA IF EXISTS account CASCADE;
CREATE SCHEMA IF NOT EXISTS account;

COMMENT ON SCHEMA account IS 'アカウント';

patch_001.000.002__create_table_account.accounts.sql
CREATE TABLE account.accounts
(
  id integer NOT NULL,
  created_at timestamp without time zone NOT NULL DEFAULT now(),
  updated_at timestamp without time zone NOT NULL DEFAULT now(),
  CONSTRAINT pk_accounts PRIMARY KEY (id)
);

COMMENT ON TABLE account.accounts IS 'アカウント一覧';

COMMENT ON COLUMN account.accounts.id IS 'ID';
COMMENT ON COLUMN account.accounts.created_at IS '作成日時';
COMMENT ON COLUMN account.accounts.updated_at IS '更新日時';

patch_001.000.003__create_sequence_account.account_id.sql
CREATE SEQUENCE account.account_id START WITH 10001;

patch_001.000.004__create_sequence_account.account_infomation_id.sql
CREATE SEQUENCE account.account_infomation_id START WITH 20001;

patch_001.000.005__create_table_account.sex_type.sql
CREATE TABLE account.sex_types
(
  type character varying(50) NOT NULL,
  name character varying(50) NOT NULL,
  priority smallint NOT NULL,
  created_at timestamp without time zone NOT NULL DEFAULT now(),
  updated_at timestamp without time zone NOT NULL DEFAULT now(),
  CONSTRAINT pk_sex_types PRIMARY KEY (type)
);

COMMENT ON TABLE account.sex_types IS '性別種別マスタ';

COMMENT ON COLUMN account.sex_types.type IS '性別種別';
COMMENT ON COLUMN account.sex_types.name IS '性別種別名';
COMMENT ON COLUMN account.sex_types.priority IS '優先度';
COMMENT ON COLUMN account.sex_types.created_at IS '作成日時';
COMMENT ON COLUMN account.sex_types.updated_at IS '更新日時';

patch_001.000.006__insert_account.sex_type.sql
INSERT INTO
	account.sex_types
(
	type,
	name,
	priority
) VALUES (
	'NONE',
	'未設定',
	0
),(
	'MALE',
	'男性',
	1
),(
	'FEMALE',
	'女性',
	2
);

patch_001.000.007__create_table_account.account_infomations.sql
CREATE TABLE account.account_infomations
(
  id integer NOT NULL,
  account_id integer NOT NULL,
  name character varying(50) NOT NULL,
  age integer NOT NULL,
  sex_type character varying(50) NOT NULL,
  display_name character varying(50) NOT NULL,
  created_at timestamp without time zone NOT NULL DEFAULT now(),
  updated_at timestamp without time zone NOT NULL DEFAULT now(),
  CONSTRAINT pk_account_infomations PRIMARY KEY (id),
  CONSTRAINT fk_this_accounts FOREIGN KEY (account_id)
      REFERENCES account.accounts (id) MATCH SIMPLE
      ON UPDATE CASCADE ON DELETE CASCADE,
  CONSTRAINT fk_this_sex_types FOREIGN KEY (sex_type)
      REFERENCES account.sex_types (type) MATCH SIMPLE
      ON UPDATE NO ACTION ON DELETE NO ACTION
);

COMMENT ON TABLE account.account_infomations IS 'アカウント情報';

COMMENT ON COLUMN account.account_infomations.id IS 'ID';
COMMENT ON COLUMN account.account_infomations.account_id IS 'アカウントID';
COMMENT ON COLUMN account.account_infomations.name IS '名前';
COMMENT ON COLUMN account.account_infomations.age IS '年齢';
COMMENT ON COLUMN account.account_infomations.sex_type IS '性別';
COMMENT ON COLUMN account.account_infomations.display_name IS '画面表示名称';
COMMENT ON COLUMN account.account_infomations.created_at IS '作成日時';
COMMENT ON COLUMN account.account_infomations.updated_at IS '更新日時';

patch_001.000.008__create_table_account.current_account_infomations.sql
CREATE TABLE account.current_account_infomations
(
  account_id integer NOT NULL,

CREATE TABLE account.current_account_infomations
(
  account_id integer NOT NULL,
  account_infomation_id integer NOT NULL,
  created_at timestamp without time zone NOT NULL DEFAULT now(),
  updated_at timestamp without time zone NOT NULL DEFAULT now(),
  CONSTRAINT pk_latest_account_infomations PRIMARY KEY (account_id),
  CONSTRAINT fk_this_accounts FOREIGN KEY (account_id)
      REFERENCES account.accounts (id) MATCH SIMPLE
      ON UPDATE CASCADE ON DELETE CASCADE,
  CONSTRAINT fk_this_account_infomations FOREIGN KEY (account_infomation_id)
      REFERENCES account.account_infomations (id) MATCH SIMPLE
      ON UPDATE CASCADE ON DELETE CASCADE,
  CONSTRAINT uq_current_account_infomations_account_infomation_id UNIQUE (account_infomation_id)
);

COMMENT ON TABLE account.current_account_infomations IS '現行アカウント情報';

COMMENT ON COLUMN account.current_account_infomations.account_id IS 'アカウントID';
COMMENT ON COLUMN account.current_account_infomations.account_infomation_id IS 'アカウント情報ID';
COMMENT ON COLUMN account.current_account_infomations.created_at IS '作成日時';
COMMENT ON COLUMN account.current_account_infomations.updated_at IS '更新日時';

afterMigrateファイル

次にafterMigrateファイルを用意しましょう。

afterMigrateとは、パッチファイルの適用が終了した際に一度だけ適用されるSQLファイルの事を指します。

こちらを使うことで、各環境で同一データを使って動作確認を行ったり、簡単にデータベースのデータを同一の状態に戻すことが出来るのでとても便利です♪

afterMigrate.sql
-- ####################
-- # アカウント1
-- ####################
INSERT INTO
	account.accounts
(
	id
) VALUES (
	(SELECT nextval('account.account_id'))
);

INSERT INTO account.account_infomations
(
	id,
	account_id,
	name,
	age,
	sex_type,
	display_name
) values (
	(SELECT nextval('account.account_infomation_id')),
	(SELECT MAX(id) FROM account.accounts),
	'山田 太郎',
	19,
	'MALE',
	'山田 ローカル 太郎'
);

INSERT INTO account.current_account_infomations
(
	account_id,
	account_infomation_id
) values (
	(SELECT MAX(id) FROM account.accounts),
	(SELECT MAX(id) FROM account.account_infomations)
);

-- ####################
-- # アカウント2
-- ####################
INSERT INTO
	account.accounts
(
	id
) VALUES (
	(SELECT nextval('account.account_id'))
);

INSERT INTO account.account_infomations
(
	id,
	account_id,
	name,
	age,
	sex_type,
	display_name
) values (
	(SELECT nextval('account.account_infomation_id')),
	(SELECT MAX(id) FROM account.accounts),
	'田中 花子',
	20,
	'FEMALE',
	'田中 ローカル 花子'
);

INSERT INTO account.current_account_infomations
(
	account_id,
	account_infomation_id
) values (
	(SELECT MAX(id) FROM account.accounts),
	(SELECT MAX(id) FROM account.account_infomations)
);

-- ####################
-- # アカウント3
-- ####################
INSERT INTO
	account.accounts
(
	id
) VALUES (
	(SELECT nextval('account.account_id'))
);

INSERT INTO account.account_infomations
(
	id,
	account_id,
	name,
	age,
	sex_type,
	display_name
) values (
	(SELECT nextval('account.account_infomation_id')),
	(SELECT MAX(id) FROM account.accounts),
	'山田 二郎',
	1,
	'NONE',
	'山田 ローカル 二郎'
);

INSERT INTO account.current_account_infomations
(
	account_id,
	account_infomation_id
) values (
	(SELECT MAX(id) FROM account.accounts),
	(SELECT MAX(id) FROM account.account_infomations)
);

実行確認

以下のコマンドを実行してみてください。

SPRING_PROFILES_ACTIVE=local ./gradlew flywayMigrate
Starting a Gradle Daemon, 1 busy Daemon could not be reused, use --status for details

BUILD SUCCESSFUL in 7s
2 actionable tasks: 2 executed

確認

それでは、DBの中身を見てみましょう。

スキーマ一覧
local_account_db=# \dn
  List of schemas
  Name   |  Owner
---------+----------
 account | root
 public  | postgres
(2 rows)

[adsense-responsive]

テーブル一覧
local_account_db=# \dt
               List of relations
 Schema  |            Name             | Type  | Owner
---------+-----------------------------+-------+-------
 account | account_infomations         | table | root
 account | accounts                    | table | root
 account | current_account_infomations | table | root
 account | sex_types                   | table | root
(4 rows)

データ確認
local_account_db=# select * from account.accounts INNER JOIN account.account_infomations ON accounts.id = account_infomations.account_id INNER JOIN account.sex_types ON account_infomations.sex_type = sex_types.type INNER JOIN account.current_account_infomations ON accounts.id = current_account_infomations.account_id ;

id   |         created_at         |         updated_at         |  id   | account_id |    name    | age | sex_type |     display_name     |         created_at         |         updated_at         |  type  |  name  | priority |         created_at         |         updated_at         | account_id | account_infomation_id |         created_at         |         updated_at
-------+----------------------------+----------------------------+-------+------------+------------+-----+----------+----------------------+----------------------------+----------------------------+--------+--------+----------+----------------------------+----------------------------+------------+-----------------------+----------------------------+----------------------------
10001 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925 | 20001 |      10001 | 山田 太郎 |  19 | MALE     | 山田 ローカル 太郎 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925 | MALE   | 男性   |        1 | 2019-04-20 20:29:26.269542 | 2019-04-20 20:29:26.269542 |      10001 | 20001 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925
10002 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925 | 20002 |      10002 | 田中 花子 |  20 | FEMALE   | 田中 ローカル 花子 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925 | FEMALE | 女性   |        2 | 2019-04-20 20:29:26.269542 | 2019-04-20 20:29:26.269542 |      10002 | 20002 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925
10003 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925 | 20003 |      10003 | 山田 二郎 |   1 | NONE     | 山田 ローカル 二郎 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925 | NONE   | 未設定 |        0 | 2019-04-20 20:29:26.269542 | 2019-04-20 20:29:26.269542 |      10003 | 20003 | 2019-04-20 20:29:26.320925 | 2019-04-20 20:29:26.320925
(3 rows)

正常にデータベース構造が構築され、AfterMigrateのデータも登録されていることが分かりますね♪

Flywayの適用状態はpublic.flyway_schema_historyが自動で生成されてそちらで管理されます。

Flyway管理情報
local_account_db=# select * from public.flyway_schema_history;
 installed_rank |   version   |                  description                  | type |                                    script        |  checksum   | installed_by |        installed_on        | execution_time | success
----------------+-------------+-----------------------------------------------+------+----------------------------------------------------------------------
--------+-------------+--------------+----------------------------+----------------+---------
              1 | 001.000.001 | create schema account                         | SQL  | 001/000/patch_001.000.001__create_schema_account.sql        | -1992181989 | root         | 2019-04-16 22:34:44.833448 |             10 | t
              2 | 001.000.002 | create table account.accounts                 | SQL  | 001/000/patch_001.000.002__create_table_account.accounts.sql        | -1355234609 | root         | 2019-04-16 22:34:44.860296 |             14 | t
              3 | 001.000.003 | create sequence account.account id            | SQL  | 001/000/patch_001.000.003__create_sequence_account.account_id.sql        | -1825999221 | root         | 2019-04-16 22:34:44.886777 |              5 | t
              4 | 001.000.004 | create sequence account.account infomation id | SQL  | 001/000/patch_001.000.004__create_sequence_account.account_infomation_id.sql |   882609325 | root         | 2019-04-16 22:34:44.908961 |              5 | t
              5 | 001.000.005 | create table account.sex type                 | SQL  | 001/000/patch_001.000.005__create_table_account.sex_type.sql        |  -326431366 | root         | 2019-04-16 22:34:44.965301 |             20 | t
              6 | 001.000.006 | insert account.sex type                       | SQL  | 001/000/patch_001.000.006__insert_account.sex_type.sql        | -1712490080 | root         | 2019-04-16 22:34:45.005898 |              5 | t
              7 | 001.000.007 | create table account.account infomations      | SQL  | 001/000/patch_001.000.007__create_table_account.account_infomations.sql      | -1486580974 | root         | 2019-04-16 22:34:45.034252 |             24 | t
(7 rows)

また、afterMigrateを行いたくない場合はisAfterMigratablefalseにするだけでスキップされます。

検証や本番ではfalseにしておくと良いでしょう。

最初からDB定義を作り直したい場合

ローカル開発等で現在のデータベース構造をリセットして、最初からパッチやafterMigrateを当て直したい場合は、SPRING_PROFILES_ACTIVE=local ./gradlew flywayClean flywayMigrateで簡単に出来ちゃいます。

終わりに

これでDB定義の更新がコマンド一つでサクッと出来るようになりました。

次回はStubにしているデータソースの処理を実際にDBから取得するように変えていきたいと思います。