💣

OpenAPI等のジェネレータツールによる更新作業の自動化、失敗時の自動ロールバックの実現

2021/09/12に公開
2

OpenAPI等のジェネレータツールによる更新作業の自動化、失敗時の自動ロールバックの実現

こんにちは、
最近はOpenAPIやgRPCであったり、それにまつわるジェネレータツールがとても熱いですね。

新規にジェネレータを走らせるのは何の憂いもなく素晴らしいのですが、
開発が進むにつれて、ディレクトリ構造が追加、変更されたり、中間処理を挟んだりと、素のジェネレータでは対応できない場合があります。
また、複数のジェネレータを使う場合、それぞれのジェネレータのご機嫌を伺わなければならない状況が発生し、そもそも更新作業にはジェネレータを使わないようになる場合も多いと思います。

ジェネレートした結果が意図した結果にならなかった場合、その問題を解決するために、いちいちGit操作を行ったり、例えば、ある段階のcommitを取り消したりrebaseしたりする操作を行うとそれだけで丸1日が潰れてしまう経験をお持ちの方もいるのではないでしょうか。

今回は、ジェネレータをもっと効率的に使うために、ジェネレート作業の自動化と適切なロールバックを行うことができるスクリプトを作ってみました。

今回の検証環境では以下のようになっています。

ツールや言語等

  • macOS BigSur 11.2.3
  • Git (git version 2.24.3 (Apple Git-128))
  • Bash (GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin20))
  • Docker (Docker version 20.10.7, build f0df350)
  • OpenAPI (Version 3.0.3)
    • openapitools/openapi-generator-cli
  • Go (go version go1.16.3 darwin/amd64)

前提条件

まず、前提として、Git管理されているプロジェクトのホームディレクトリ等の上でスクリプトを走らせる必要があります。
また、今回はOpenAPIのopenapitools/openapi-generator-cliにおけるジェネレート作業を取り扱うので、その他のツールでは、reset_* generate_* replace_* の内容に関して読み替えを行う必要があります。

また、チケット単位で考えるとき、人が行う作業とジェネレート作業を完全に切り分ける必要があります。
ただし、作業を分担する上で、複数人がジェネレート作業を行わなければならない場合は、そのチケットに取り掛かる一番初めにこのジェネレート作業を行うという制約を設定する必要があります。

つまり、要件定義の更新作業を別チケットで対応し(主にopenapi.ymlの修正)所定のブランチにマージします。
その後、実装の修正更新作業は別チケットで対応します。
その実装作業を始める一番最初にこのスクリプトを走らせるという前提です。

スクリプト解説

今回はBashを使うのでシバン等を次のように設定します。

#!/bin/bash
set -eux

また、ロールバックを制御するためにerrtraceを設定します。

set -o errtrace

今回はロールバックにGitを使います。
また、失敗した段階で自動でロールバックを行いたいので、trapを設定します。

function trap_common() {
    trap "git stash && git clean -fd ; echo Failed ;" 2
    trap "git stash && git clean -fd ; echo Failed ; exit 1" ERR
}

trapではスクリプトのステータスコードによって処理を分岐できるので、今回はエラー時と中断時にロールバックが走るように設定します。

次にリセット作業を行います。
更新する際にリセットを行わなければならないと思いますが、こちらはあらかじめ予約語を設定しておいて、ツールが出力する一定の法則に基づいたファイル名を利用してワイルドカードを駆使してリセットを行うというのが妥当だと思います。

次の例ではmodelとapiのリセットを行なっています。

function reset_model_phase() {
    git rm "${SET_PATH}"/web-api/go/model/model_*
    mkdir "${SET_PATH}"/web-api/go/model
}

function reset_api_phase() {
    rm "${SET_PATH}"/web-api/go/api/api_*
    rm "${SET_PATH}"/web-api/go/api/routers.go
}

今回はmodelとapiに対するディレクトリ構造を変更しているので対象のディレクトリの対象のファイルをワイルドカードを使って削除します。

心配な方はこの作業の一つ上でバックアップ処理を挟んでも良いかもしれません。

次にリセットができれば、ジェネレートを行いましょう。

function genarate_phase() {
    docker run --rm -v "${SET_PATH}":/app openapitools/openapi-generator-cli generate -i /app/api/openapi.yml -g go-gin-server -o /app/web-api
    rm "${SET_PATH}"/web-api/Dockerfile
}

この際、ジェネレート時に作成される不要なファイル等があればこの関数内で削除します。つまり、generate時に生成されるファイル自体に改変を行いたい場合はこの関数内で適応するべきということです。

次に新しいディレクトリ構造に対応する作業を行います

function replace_api_model() {
    mv "${SET_PATH}"/web-api/go/api_*.go "${SET_PATH}"/web-api/go/routers.go "${SET_PATH}"/web-api/go/api
    mv "${SET_PATH}"/web-api/go/model_* "${SET_PATH}"/web-api/go/model
}

今回はmodelとapiの場所を追加しそれに則り変更したいので、mvコマンドでリプレースを行います。

次に、ジェネレートが行なった変更と手動で行う修正作業とを完全に切り分けるために自動でcommitを生成します。

まずはadd作業です。

function git_add_phase() {
    git add "${SET_PATH}"/web-api/.openapi-generator/FILES "${SET_PATH}"/web-api/api/openapi.yaml "${SET_PATH}"/web-api/go/README.md "${SET_PATH}"/web-api/go/model/model_* "${SET_PATH}"/web-api/go/api/routers.go
    git checkout "${SET_PATH}"/web-api/main.go
    git add -p
    git diff --cached
}

この際、ジェネレート作業において強制的に変更されてしまう内容を無視したいファイルがある場合にはcheckoutを個別に行なって変更をなかったことにします。

またaddのpオプションで更新するべきファイルとそうでないファイルをインタラクティブに判断します。
最後にdiffでステージ上の変更内容を改めて確認します。

この段階で正しくない更新が発見されたらその箇所の差分等をメモしておいて、処理をctl+cで中断させましょう。
先ほど設定した標準のロールバック処理が走ります。

問題がなければ次に、commitを行います。
commitまでする必要は無いのではと思う方もおられるかもしれませんが、ジェネレータの更新と人が行う更新を完全に切り分ける方がクリーンで効率的なので、commitを自動で行うようにしています。

ここでロールバック処理をカスタマイズします、そもそもスクリプトが勝手にcommitを行うのは大変危険なのでこの処理もロールバックは必須です。
ここではerrtraceを別にセットします。

set +o errtrace

また、カスタマイズされたロールバックを元に戻すには再度errtraceを次のように設定しましょう。

set -o errtrace

commitを行います。
この関数内でtrapを設定し直し、ロールバック処理のカスタマイズを行います。

function commit_phase() {
    git status

    trap "git stash && git clean -fd && git reset --hard HEAD^ ; echo Failed ; exit 1" ERR
        git commit -m "generate code from openapi.yml"
}

次に新規にジェネレータによって生成されたファイルに関してはGit上ではトラッキングされていないので、そのためのcommitを行います。

function new_file_commit_phase() {
    git stash
    CHECK_UNTRACKED="$(git status -s)" ; readonly CHECK_UNTRACKED
    if [[ "${CHECK_UNTRACKED}" = "??"* ]]; then
        git add "${SET_PATH}"/web-api/go/api/api_*

        trap "git stash && git clean -fd && git reset --hard HEAD^ ; echo Failed ; exit 1" ERR
            git commit -m "generate new api_* files from openapi.yml"
    else
        echo "Skip this phase"
    fi
}

まず、すでにcommit済みなのでstashを行いトラッキングされていないファイルのみを作業中のステージに残るようにします。
次に、statusでトラッキングされていないファイルが存在するかどうかを判定します。

s オプションでstatusを省略表記で確認できます。
この際、「??」が存在する時点でトラッキングされていないファイルが存在することになるので、それをフラグとして条件分岐を行います。
条件を満たす場合にはステージにaddしcommitを行います。
条件を満たさない場合にはスキップします。

最後にmain関数で標準ロールバックとカスタマイズロールバックを制御します。
この関数を追加する場合は先ほど時点でのerrtraceの設定は必要ありません。

function main() {
    trap_common
    reset_model_phase
    reset_api_phase
    genarate_phase
    replace_api_model
    git_add_phase
    set +o errtrace
    commit_phase
    new_file_commit_phase
    set -o errtrace
}

スクリプトの全体像は次のようになります。

#!/bin/bash
set -eux
set -o errtrace

SET_PATH=$(pwd) ; readonly SET_PATH

function trap_common() {
    trap "git stash && git clean -fd ; echo Failed ;" 2
    trap "git stash && git clean -fd ; echo Failed ; exit 1" ERR
}

function reset_model_phase() {
    git rm "${SET_PATH}"/web-api/go/model/model_*
    mkdir "${SET_PATH}"/web-api/go/model
}

function reset_api_phase() {
    rm "${SET_PATH}"/web-api/go/api/api_*
    rm "${SET_PATH}"/web-api/go/api/routers.go
}

function genarate_phase() {
    docker run --rm -v "${SET_PATH}":/app openapitools/openapi-generator-cli generate -i /app/api/openapi.yml -g go-gin-server -o /app/web-api
    rm "${SET_PATH}"/web-api/Dockerfile
}

function replace_api_model() {
    mv "${SET_PATH}"/web-api/go/api_*.go "${SET_PATH}"/web-api/go/routers.go "${SET_PATH}"/web-api/go/api
    mv "${SET_PATH}"/web-api/go/model_* "${SET_PATH}"/web-api/go/model
}

function git_add_phase() {
    git add "${SET_PATH}"/web-api/.openapi-generator/FILES "${SET_PATH}"/web-api/api/openapi.yaml "${SET_PATH}"/web-api/go/README.md "${SET_PATH}"/web-api/go/model/model_* "${SET_PATH}"/web-api/go/api/routers.go
    git checkout "${SET_PATH}"/web-api/main.go
    git add -p
    git diff --cached
}

function commit_phase() {
    git status

    trap "git stash && git clean -fd && git reset --hard HEAD^ ; echo Failed ; exit 1" ERR
        git commit -m "generate code from openapi.yml"
}

function new_file_commit_phase() {
    git stash
    CHECK_UNTRACKED="$(git status -s)" ; readonly CHECK_UNTRACKED
    if [[ "${CHECK_UNTRACKED}" = "??"* ]]; then
        git add "${SET_PATH}"/web-api/go/api/api_*

        trap "git stash && git clean -fd && git reset --hard HEAD^ ; echo Failed ; exit 1" ERR
            git commit -m "generate new api_* files from openapi.yml"
    else
        echo "Skip this phase"
    fi
}

function main() {
    trap_common
    reset_model_phase
    reset_api_phase
    genarate_phase
    replace_api_model
    git_add_phase
    set +o errtrace
    commit_phase
    new_file_commit_phase
    set -o errtrace
}

main
echo "done"

最後に

今までは属人的にジェネレート作業は〇〇さんにお願いします、という感じで進んでいました。
仕様が変更されるたびにそれにまつわるジェネレート作業が発生する状況の場合、ジェネレート作業を終えた後に純粋にマージしたくても、マージ条件をクリアできない場合が多々あると思います。
その際、〇〇さんの本来の作業ではないロジックの修正までもマージ条件の制約上、行わなくてはならない状況が発生することが容易に予想できます。

私自身も〇〇さん状況になってしまった経験があります。
この経験があって今回このようなスクリプトを書きました。

今回の内容で、ジェネレート作業そのものをスクリプト化しそれを走らせるだけで、誰でもジェネレート作業を行うことができるようになります。
これで、作業を分担できるようになるので、プロジェクトの開発効率が一気に上がるのでは無いでしょうか。

今回は紹介していませんが、これに加えてさらにDockerでスクリプトを走らせるようにするとさらに良いですね。

今回はBashで自動化を実現したので、汎用性が高いと思います、ジェネレート作業に関係なく、さまざまな場面で応用できると思います。

今後git_add_phaseに関しては、改良の余地があると思うので、引き続き最適化していきたいと思います。

参考文献

GitHubで編集を提案

Discussion