GoのAtlasとBunを使ったマイグレーション環境を構築する

ogp

この記事は enechain Advent Calendar 2023 の 10 日目の記事です。

はじめに

こんにちは!enechain でソフトウェアエンジニアをしている @tomohiko-tanihata です。 これまではモバイルデスクに所属していましたが、現在は 『日本気候取引所 - Japan Climate Exchange』 (以下 JCEX)の主にバックエンドを担当しています。

JCEX とは環境価値取引を実施できるプラットフォームであり、3 ヶ月の立ち上げ期間で開発した新規プロダクトです。 バックエンドは Go を使用し、DB スキーマ管理には Atlas を、ORM には Bun を採用しています。

この組み合わせにより、Go でモデルを定義するだけで、宣言的なスキーマファイルと命令的なマイグレーションファイルを自動的に生成することができました。 今回は、迅速な開発をサポートする Atlas と Bun を使ったマイグレーション環境構築について紹介していきたいと思います。

Atlas と Bun について

まずは軽く Atlas と Bun について触れておこうと思います。

Atlas とは

Atlas は DB スキーマ管理およびマイグレーションのための言語から独立したツールです。 Declarative Workflows と Versioned Workflows の 2 つのワークフローを提供しています。

Go の ORM である Ent ではマイグレーションのバージョン管理に Atlas を利用していることでも有名です。

Bun とは

Bun は、PostgreSQL、MySQL/MariaDB、MSSQL、および SQLite 用の SQL ファーストの Go の ORM です。 SQL ファーストとは、Go で SQL クエリを作成できることを意味しており、古き良き SQL の書き心地と乖離がないように設計されています。

ちなみに、JCEX に Bun を採用した決定的な要因としては、SQL を抽象化し過ぎず、実際に生の SQL を書いているかのような直感的な体験が得られる点が挙げられます。

マイグレーション環境の設計方針

マイグレーション環境を構築するにあたり 3 つの設計方針を立てました。

モデルの定義以外は全て自動化したい

Go でモデルさえ定義すれば、スキーマファイルとマイグレーションファイルが自動生成され、マイグレーションの適用が出来るというところを目指します。

バージョン管理されたマイグレーションファイルを生成したい

Atlas には宣言的にスキーマを定義するだけでターゲットとなる DB との差分を考慮してマイグレーションをよしなにやってくれる Automatic Schema Inspection という機能があります。 これは非常に便利なのですが、宣言的であるが故に事前にどのようなマイグレーションが実行されるのかが分からないという特性があります。 DB に思いもよらぬマイグレーションが実行されてしまうリスクを避けるために、今回は命令的なマイグレーションファイルを適用する方針にします。 つまり Versioned Workflows に則ったマイグレーション環境を構築していきます。

宣言的なスキーマファイルを生成したい

マイグレーションファイルが増えることによって、現状の DB スキーマがコードから読み取りにくくなるという問題があると思います。 この問題を避けるためにマイグレーションファイルとは別に宣言的なスキーマファイルを生成するという方針にします。

構築手順

これらの方針を満たすためには

  • Go で定義されたモデルからスキーマファイルを自動生成
  • スキーマファイルからマイグレーションファイルを自動生成
  • マイグレーションファイルの適用

の 3 つが出来れば今回のマイグレーション環境が構築できるということになります。 これらの仕組みをそれぞれ構築していきます。

Go で定義されたモデルからスキーマファイルを自動生成

まずは Go でモデルおよびインデックスを定義します。

type User struct {
    bun.BaseModel `bun:"table:users,alias:u"`

    ID    int64   `bun:"id,pk,autoincrement"`
    Name  string  `bun:"name,notnull"`
}

var IdxCreators = []dbcommon.IndexQueryCreator{
    func(db *bun.DB) *bun.CreateIndexQuery {
        return db.NewCreateIndex().
            Model((*User)(nil)).
            Index("user_name_idx").
            Column("name")
    },
}

これらのモデルとインデックスからスキーマファイルを生成するスクリプトを実装します。 Bun は他の ORM と同様にクエリビルダを備えておりますが、組み立てたクエリを Exec で実行するのではなく AppendQuery メソッドによりクエリを出力することが出来ます。 これを利用してテーブル作成とインデックス作成のクエリを生成します。

テーブルの作成は

func modelsToByte(db *bun.DB, models []interface{}) []byte {
    var data []byte
    for _, model := range models {
        query := db.NewCreateTable().Model(model).WithForeignKeys()
        rawQuery, err := query.AppendQuery(db.Formatter(), nil)
        if err != nil {
            panic(err)
        }
        data = append(data, rawQuery...)
        data = append(data, ";\n"...)
    }
    return data
}

インデックスの作成は

func indexesToByte(db *bun.DB, idxCreators []dbcommon.IndexQueryCreator) []byte {
    var data []byte
    for _, idxCreator := range idxCreators {
        idx := idxCreator(db)
        rawQuery, err := idx.AppendQuery(db.Formatter(), nil)
        if err != nil {
            panic(err)
        }
        data = append(data, rawQuery...)
        data = append(data, ";\n"...)
    }
    return data
}

として AppendQuery を利用してスキーマファイル

CREATE TABLE "users" (
    "id" bigserial NOT NULL,
    "name" varchar NOT NULL,
    PRIMARY KEY ("id")
);

CREATE INDEX "user_name_idx" ON "users" ("name");

を生成するスクリプト実装できました。

スキーマファイルからマイグレーションファイルを自動生成

atlas migrate diff コマンドを使用することでマイグレーションファイルを生成できます。 初回はスキーマファイルがそのままマイグレーションファイルとして生成されます。 2 回目以降はスキーマファイルとこれまでのマイグレーションファイルの差分を考慮してマイグレーションファイルを生成します。

先ほどの User に email を追加するという例を見ていきましょう。

type User struct {
    bun.BaseModel `bun:"table:users,alias:u"`

    ID    int64   `bun:"id,pk,autoincrement"`
    Name  string  `bun:"name,notnull"`
    Email string  `bun:"email,notnull"` // 追加
}

こちらのモデルを使って再度スキーマファイルを生成すると email が追加されます。

CREATE TABLE "users" (
    "id" bigserial NOT NULL,
    "name" varchar NOT NULL,
    "email" varchar NOT NULL,  --- 差分
    PRIMARY KEY ("id")
);

CREATE INDEX "user_name_idx" ON "users" ("name");

次に atlas migrate diff コマンドを使用すると 2 つ目のマイグレーションファイルが生成されます。

-- Modify "users" table
ALTER TABLE "public"."users" ADD COLUMN "email" character varying NOT NULL;

スキーマファイルとこれまでのマイグレーションファイルの差分を考慮して、users テーブルにカラム email を追加してくれていることが確認できました。

これまでは、マイグレーションファイルの自動生成について説明してきましたが、手動でマイグレーションファイルを追加したい場合もあるかと思います。 その場合は atlas migrate new コマンドを使うことで手動でマイグレーションファイルを追加することが出来ます。 このとき、マイグレーションファイルの編集が完了した後に atlas migrate hash コマンドを使って atlas.sum で管理されている checksum の再計算を実施する必要があります。

マイグレーションファイルの適用

マイグレーションの実行ももちろん Atlas がサポートしており atlas migrate apply を使用することでマイグレーションファイルの適用が可能です。 これまでの作業は ローカル PC 上で立てている Docker 環境で実行してきたのですが、こちらのコマンドは Kubernetes の Job の中で実行しています。

さいごに

本記事では、JCEX のバックエンド開発において利用している Atlas と Bun を使ったマイグレーション環境構築について説明しました。

Go でモデルを定義することで、それ以外の部分が自動的に生成されるため、仕様変更やテーブル設計の見直しが発生しやすい新しいプロジェクトの立ち上げにおいては非常に有用で、開発プロセスが大幅に効率化されたと感じています。

また、ORM に Bun を採用していなくともテーブルとインデックスの作成のクエリを出力する機能を備えている ORM であれば、同様の環境を構築することが出来るはずなのでぜひ試していただければ嬉しいです。

明日は同じチームの @eji が Go の Atlas と Bun を使ったテスト方針と環境構築の紹介についての記事を出すので乞うご期待!!

enechain では、事業拡大のために共に新しい領域に飛び込んでいける仲間を募集しています。

herp.careers