gh-ost実行履歴をflywayのバージョン管理下に置くにはどうするか

## gh-ostとflywayについて
- [gh-ost](https://github.com/github/gh-ost) : オンラインスキーマ変更ツール
- [flyway](https://flywaydb.org/) : DBマイグレーションツール

なぜgh-ostを使うのか/なぜflywayを使うのかは公式に任せる

## 解決したい問題
flywayでバージョン管理したいが、gh-ostを直接実行してスキーマ変更すると、その変更はflyway管理下に置けない 
`flyway migrate` したときにgh-ostコマンドが実行されて、成功/失敗がマークされるようにしたい

## どうするのか
flywayの [Custom Migration resolvers & executors](https://flywaydb.org/documentation/v6/api/hooks#custom-migration-resolvers--executors) を使って `gh-ost` コマンドを実行できるようにする
公式は使い方が全く書いてないので、以下使い方を解説する
なお、この記事では flyway-core 6.1.1を使用
OSコマンドを実行することになるので自己責任で

## Custom Migration resolvers & executors について
### 登場人物
- MigrationExecutor
  - flyway migrateしたときの処理を定義するためのクラス
  - `ResolvedMigration` に持たせることで、Flyway本体に実行させることができる
  - executeを実装すればよい
- ResolvedMigration
  - 1つのmigrationのversionやdescription、 `MigrationExecutor` などを定義するためのクラス
  - `getVersion` など実装すべきメソッドは多いが、いちばん大事なのは `getExecutor`
  - flyway infoしたときに表示される表の1行分の情報を持つと思えばいい。flyway infoの表ってのは↓みたいなやつ。

```
+-----------+---------+------------------------------------------+------+--------------+---------+
| Category  | Version | Description                              | Type | Installed On | State   |
+-----------+---------+------------------------------------------+------+--------------+---------+
| Versioned | 1.0     | init                                     | SQL  |              | Pending |
| Versioned | 1.1     | add index for hoge                       | SQL  |              | Pending |
| Versioned | 1.2     | renamed unnecessary tables               | SQL  |              | Pending |
| Versioned | 2.0     | drop unnecessary tables                  | SQL  |              | Pending |
+-----------+---------+------------------------------------------+------+--------------+---------+
```

- MigrationResolver
  - ファイルなどを解決して `Collection<ResolvedMigration>` を返す
  - デフォではSQL-based MigrationsとJava-based Migrationsに必要なResolverが定義されている
  - `resolveMigrations` を実装すればよい

## 実装

`src/main/resources/ghost` に置いたファイルを読み込んでgh-ost使っていくケースを書いてみる

### 概要
1. [Maven Plugin](https://flywaydb.org/documentation/maven/) や [Gradle Plugin](https://flywaydb.org/documentation/gradle/) あたりを使ってflywayを実行できるようにしておく
2. MigrationExecutorを実装したクラスを作成
3. ResolvedMigrationを実装したクラスを作成
4. MigrationResolverを実装したクラスを作成
5. Resolverをconfで指定する

### 1. [Maven Plugin](https://flywaydb.org/documentation/maven/) や [Gradle Plugin](https://flywaydb.org/documentation/gradle/) あたりを使ってflywayを実行できるようにしておく

とりあえず、ここではMaven Pluginの例を。別にMavenじゃなくてもいいです。

```xml:pom.xml
<project>
    ...
    <dependencies>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
            <version>6.1.1</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.7.0</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-maven-plugin</artifactId>
                <version>6.1.1</version>
                <configuration>
                    <locations>
                        <location>classpath:db/migration</location>
                    </locations>
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>mysql</groupId>
                        <artifactId>mysql-connector-java</artifactId>
                        <version>8.0.18</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>

```

### 2. MigrationExecutorを実装したクラスを作成

```java
class GhostMigrationExecutor implements MigrationExecutor {

    private final File file;

    GhostMigrationExecutor(File file) {
        this.file = file;
    }

    @Override
    public void execute(Context context) throws SQLException {
        // fileを読み込んで、ここで具体的なgh-ostのコマンドを実行する。ProcessBuilderなどを使うことになると思う。
    }

    @Override
    public boolean canExecuteInTransaction() {
        return false; //gh-ostはALTER TABLEしか扱えなくて、それはオートコミットなのでトランザクションに入れたらダメ。
    }
}
```

### 3. ResolvedMigrationを実装したクラスを作成

```java
class ResolvedGhostMigration implements ResolvedMigration {

    private final File file;
    private final MigrationVersion version;
    private final String description;

    ResolvedGhostMigration(File file) {
        //この辺はFlywayオリジナルのBaseJavaMigrationがクラス名からversionとdescriptionを割り出すロジックを流用してる
        boolean repeatable = file.getName().startsWith("R");
        if (!file.getName().startsWith("V") && !repeatable) {
            throw new FlywayException("Invalid file name: " + file.getName() + " => ensure it starts with V or R");
        } else {
            String prefix = file.getName().substring(0, 1);
            Pair<MigrationVersion, String> info =
                    MigrationInfoHelper.extractVersionAndDescription(file.getName(), prefix, "__", new String[]{".properties"}, repeatable);
            this.file = file;
            this.version = info.getLeft();
            this.description = info.getRight();
        }
    }

    @Override
    public MigrationVersion getVersion() {
        return this.version;
    }

    @Override
    public String getDescription() {
        return this.description;
    }

    @Override
    public String getScript() {
        return this.file.getName();
    }

    @Override
    public Integer getChecksum() {
        // こいつがnullを返すのはオリジナルのResolvedJavaMigrationに準ずる。コンストラクタで渡されたfileから一意なchecksumを作ってnull以外を返してもよい。
        // checksumを設定しておくと、すでにSuccessになったmigrationに利用したファイルをあとから編集したときにエラーを吐いてくれるようになる。
        return null;
    }

    @Override
    public MigrationType getType() {
        // flyway infoしたときの `Type` カラムの値に使われる。Custom MigrationであることがわかるようにCUSTOMを返す
        return MigrationType.CUSTOM;
    }

    @Override
    public String getPhysicalLocation() {
        return this.file.getAbsolutePath();
    }

    @Override
    public MigrationExecutor getExecutor() {
        return new GhostMigrationExecutor(this.file); //2で作成したExecutorを返すようにする
    }
}
```

### 4. MigrationResolverを実装したクラスを作成
```java
public class GhostMigrationResolver implements MigrationResolver {
    @Override
    public Collection<ResolvedMigration> resolveMigrations(Context context) {
        try {
            return Files.walk(Paths.get(getClass().getResource("/ghost/").toURI())) //src/main/resources/ghostディレクトリを読み込み
                    .map(Path::toFile)
                    .filter((file) -> !file.isDirectory()) // ghostディレクトリ自身を除外
                    .map(file -> new ResolvedGhostMigration(file)) // 3で作ったResolvedMigrationのインスタンスを生成
                    .collect(Collectors.toList());
        } catch (IOException | URISyntaxException e) {
            throw new FlywayException(e.getMessage(), e.getCause());
        }
    }
}

```

### 5. Resolverをconfで指定する

Mavenで作ったので、pom.xmlで指定できる

```xml:pom.xml
<project>
    ...
    <dependencies>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
            <version>6.1.1</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.7.0</version>
                <configuration>
                    <source>11</source>
                    <target>11</target>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.flywaydb</groupId>
                <artifactId>flyway-maven-plugin</artifactId>
                <version>6.1.1</version>
                <configuration>
                    <locations>
                        <location>classpath:db/migration</location>
                    </locations>
                    <!-- ここから -->
                    <resolvers>
                        <resolver>path.to.GhostMigrationResolver</resolver>
                    </resolvers>
                    <!-- ここまで追加 -->
                </configuration>
                <dependencies>
                    <dependency>
                        <groupId>mysql</groupId>
                        <artifactId>mysql-connector-java</artifactId>
                        <version>8.0.18</version>
                    </dependency>
                </dependencies>
            </plugin>
        </plugins>
    </build>
</project>
```

もしくは、flyway.confで指定してもよい

```
# ...その他の設定
flyway.resolvers=path.to.GhostMigrationResolver
```

## 注意
gh-ostとflywayは別々にコネクションを獲得することになり、gh-ostの実行中にflywayのコネクションがタイムアウトする可能性がある 
実行する際は `autoReconnect=true` にするなどの対策をしておいたほうがいい