この記事は traP アドベントカレンダー2025 14 日目の記事です。
@ikura-hamu です。traP では主に Go でサーバーアプリを書いています。SysAd 班では部内メッセージングサービス traQ をはじめとしていくつかの部内サービスを開発・運用していますが、僕はゲーム管理・配布アプリである traP Collection というサービスのサーバーを書いています。
このプロジェクトでは、DB 操作のライブラリとして gorm を使っています。以前は DB (MariaDB)のマイグレーションに github.com/go-gormigrate/gormigrate (以下 gormigrate)を使っていましたが、現在は Atlas というツールを使っています。Atlas という名前がついているものは、MongoDB のクラウドのやつとか、OpenAI が出したブラウザとか、いろいろたくさんありますが、DB スキーマ管理ツールの Atlas です。
この記事は、gormigrate によるマイグレーションから Atlas によるマイグレーションへの移行を行った動機と手順を説明します。
gormigrate によるマイグレーションの課題
gormigrate によるマイグレーションは、規模が大きくなるにつれていくつかの課題が出てきます。
例があるとわかりやすいので、以下のような2段階のマイグレーションを考えます。
usersテーブル(idカラムとnameカラム)を作るusersテーブルにemailカラムを追加する
また、traP Collectionの設計上の制約として、DB スキーマを表す構造体をマイグレーションのパッケージに public で配置することを設けます。
gormigrate では1段階目のマイグレーションを以下のように書けます。
package migrate
import (
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// パッケージ外からアクセスできるようにする
type Users = usersV1
// スキーマの構造体
type usersV1 struct {
id int
name string
}
// テーブル名の指定
func (userV1) TableName() string {
return "users"
}
func Migrate(db *gorm.DB) error {
migrations := []*gormigrate.Migration{
{
// マイグレーションの処理
Migrate: func(tx *gorm.DB) error {
return tx.Migrator().CreateTable(&userV1{})
},
// ロールバックの処理
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("users")
},
},
}
m := gormigrate.New(db, gormigrate.DefaultOptions, migrations)
if err := m.Migrate(); err != nil {
return fmt.Errorf("Migration failed: %w", err)
}
return nil
}
Migrate 関数の中でマイグレーションを定義して実行していることが分かると思います。
次にマイグレーションの2ステップ目を書き足すと以下のようになります。
package migrate
import (
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// パッケージ外からアクセスできるようにする
- type Users = usersV1
+ type Users = usersV2
// スキーマの構造体
type usersV1 struct {
id int
name string
}
// テーブル名の指定
func (userV1) TableName() string {
return "users"
}
+ // 新しいスキーマの構造体
+ type usersV2 struct {
+ id int
+ name string
+ email string
+ }
+
+ // テーブル名の指定
+ func (userV2) TableName() string {
+ return "users"
+ }
func Migrate(db *gorm.DB) error {
migrations := []*gormigrate.Migration{
{
// マイグレーションの処理
Migrate: func(tx *gorm.DB) error {
return tx.Migrator().CreateTable(&userV1{})
},
// ロールバックの処理
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("users")
},
},
+ {
+ // マイグレーションの処理
+ Migrate: func(tx *gorm.DB) error {
+ return tx.Migrator().AddColumn(&userV2{}, "email")
+ },
+ // ロールバックの処理
+ Rollback: func(tx *gorm.DB) error {
+ return tx.Migrator().DropTable("users")
+ },
+ },
}
m := gormigrate.New(db, gormigrate.DefaultOptions, migrations)
if err := m.Migrate(); err != nil {
return fmt.Errorf("Migration failed: %w", err)
}
return nil
}
Go のシンタックスハイライトついてるバージョン
package migrate
import (
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// パッケージ外からアクセスできるようにする
type Users = usersV2
// スキーマの構造体
type usersV1 struct {
id int
name string
}
// テーブル名の指定
func (userV1) TableName() string {
return "users"
}
// 新しいスキーマの構造体
type usersV2 struct {
id int
name string
email string
}
// テーブル名の指定
func (userV2) TableName() string {
return "users"
}
func Migrate(db *gorm.DB) error {
migrations := []*gormigrate.Migration{
{
// マイグレーションの処理
Migrate: func(tx *gorm.DB) error {
return tx.Migrator().CreateTable(&userV1{})
},
// ロールバックの処理
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("users")
},
},
{
// マイグレーションの処理
Migrate: func(tx *gorm.DB) error {
return tx.Migrator().AddColumn(&userV2{}, "email")
},
// ロールバックの処理
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable("users")
},
},
}
m := gormigrate.New(db, gormigrate.DefaultOptions, migrations)
if err := m.Migrate(); err != nil {
return fmt.Errorf("Migration failed: %w", err)
}
return nil
}
このようなマイグレーションには以下のような課題があります。
- スキーマを表す構造体が型エイリアスになっている
- 型エイリアスになっているとエディタを使って参照を追いたいときに手間が増えます
- 同じテーブルを表す構造体が複数ある
- 1 つのテーブルを変更するたびに構造体が増えます
- Gorm では構造体のフィールドに他の構造体を使うことでテーブルのリレーションを表すので、テーブルのカラムそのものに変更が無くても構造体を書き換える必要があることがあります。
- マイグレーションの処理を毎回書く必要がある
- それほど簡単ではない処理をマイグレーションの度に書き足していく必要があります。
これらの課題から、gormigrate は中・大規模なサービスの継続的な開発に向いていないことが分かると思います。実際、gormigrate のドキュメントでも、シンプルで最低限のツールとして作られたことが示されています。
Gormigrate was born to be a simple and minimalistic migration tool for small projects that uses Gorm.
https://github.com/go-gormigrate/gormigrate/blob/6c430d86d5bd92d4ffbb6a30cb9236f051d093ec/README.md
実際にはこの処理を複数のファイルに分割して記述することになりますが、課題が残ることに変わりはありません。
traP Collection では gormigrate を使ったマイグレーションを行っていましたが、これらの課題が大きくなってきたこと、今後の開発を進めるうえでマイグレーションが多く発生すると想定されることを踏まえて、異なるマイグレーション方法への移行を行いました。
移行前のマイグレーションコードはリポジトリに残っているため、確認することができます。
Atlas の特徴と採用した理由
Atlas は、様々な言語、ライブラリ、データベースに対応しているスキーマ管理ツールです。SQL や独自の HCL による宣言的なスキーマからマイグレーションコードを生成することができます。宣言的なスキーマと現在の DB の状態の差分を取り出してマイグレーションの SQL を生成します。マイグレーションの実行・ロールバックも行うことができます。traP Collection では採用していませんが、お金を払うことでクラウド上のサービスを含めてすべての機能を使うことができるようになるようです。
Atlas では宣言的なスキーマを書くわけですが、このスキーマには様々なフレームワーク・ORMのコードそのものを使うこともできます。Gorm も対応に含まれており、atlas-provider-gorm というツールを使うことで、 Gorm の構造体からマイグレーションのコードを生成することができます。実体としては、Gorm の構造体から理想的な状態のスキーマの SQL を生成し、それと現在のDBスキーマを比較することでマイグレーションコードを生成しているようです。
traP Collection で Atlas を採用した理由としては、以前の gormigrate による課題を宣言的なスキーマによって解決できると考えたからです。以前も理想のテーブルスキーマを構造体で記述していたので、宣言的と言うこともできるかもしれませんが、その構造体を使ってマイグレーションを実行するコードは自分で書く必要があり、これが負担になっていました。Atlas を使うことで、構造体を書き換えたらマイグレーションのコードは Atlas が勝手に作ってくれるため、開発時の負担を軽減することができます。さらに、テーブルスキーマを構造体で表す運用を変えずに済むため、移行が楽にできると考えられることも決定の要因となりました。
gormigrate から Atlas への移行手順
いくつかの手順を踏んで gormigrate から Atlas への移行を行いました。起動時にマイグレーションを実行するプログラムへの組み込みや設定ファイルの記述なども行っていますが、この記事ではテーブルスキーマ周りの処理について記述します。
以下の PR で移行を行っています。あまりコミットは整理されていませんが、興味がある方は見てみてください。
移行前
移行前は、migrate というパッケージにテーブルスキーマを表す構造体が private の状態で配置されていました。この構造体には以前の例で出てきた usersV1 と usersV2 のように異なるマイグレーションのバージョンのスキーマが含まれています。この構造体のうち、最新のスキーマを表す構造体は型エイリアスを使って public にして他のパッケージからも使えるようにしていました。
現在のスキーマの生成
atlas migrate diff というコマンドを使うと、現在の DB の状態と Atlas が管理しているスキーマの差分を調べてマイグレーションのコードを生成してくれます。このコマンドを使って、gormigrate によって作られた現在のスキーマと同じスキーマを作るための SQL を生成します。
マスタデータ系のテーブルの挿入のための SQL を書く
traP Collection のサーバーでは、例えば扱うことができる画像ファイルの種類(png, jpegなど)をマスタデータとして DB のテーブルに格納しています。gormigrate を使っていた際はレコードを挿入するような Go のコードを書いていましたが、Atlas はこれに対応する機能がありません。そのため、今回は Atlas が生成したマイグレーションの SQL ファイルに書き足す形でデータの挿入を行うようにしました。Atlas が生成したファイルを書き換える際は、atlas migrate hash コマンドを実行して、Atlas が管理するハッシュファイル(atlas.sum)を更新してあげる必要があります。
スキーマの構造体の複製
新しいマイグレーション用のパッケージ schema を作ります。そこに migrate パッケージに記述されているスキーマのうち最新のものをコピーしてきます。ただし、コピーしてきた構造体は public になるよう記述し、V1 のような suffix はつけません。
差分が少なくなるように構造体を修正する
前の段階で現在のスキーマを表す構造体をコピーしたはずです。新しくコピーした構造体を使って atlas migrate diff を実行してマイグレーションファイルを生成しても、差分が存在しないのが理想的ですが、実際には型エイリアスを使っていた影響でインデックスの名前などが意図しないものになっている場合などがあり、差分が出てしまいました。これに対応するために、できるだけ構造体のフィールド名やタグを書き換えて差分が少なくなるようにし、どうしても必要な分だけ新たにマイグレーションファイルを生成しました。
https://github.com/traPtitech/trap-collection-server/pull/1216/changes/aba5d64da345d0a95bfddhttps://github.com/traPtitech/trap-collection-server/pull/1216/changes/07abdec303c9c0666e0
d4cef247e2cfecc97e4b9502d36f8e76ba2193e9
migrate パッケージの型エイリアスが指す先を schema パッケージにする
元の migrate パッケージにあった public な型エイリアスが新しい schema パッケージにコピーした構造体になるように書き換えます。この書き換えを行うことによって、今まで外部で migrate を参照していた部分が自動的に schema パッケージを使えるようになります。
ここまでで、アプリケーションコードを書き替えずにマイグレーション手法を gormigrate から Atlas に移行できました。
移行によって得られたもの
移行によって、テーブルに変更する際の処理が簡単になりました。
以前は以下のようなステップでした。
- 変更を加えたい構造体を suffix を変えてコピーする。
- 構造体に変更を加える。
- 外部から使われる型エイリアスが指す先を新しい構造体にする。(<- 忘れがち)
- マイグレーションのためのプログラムを書く。(<- ミスりがち)
Atlas の導入によって以下のようになりました。
- 構造体に変更を加える
- Atlas を使ってコードを生成する
とても簡単に、ミスもおきづらくなりました。マイグレーションをするのが難しいと新しい機能の追加への障壁が高くなってしまいます。マイグレーションが簡単になったので、どんどん新しい機能の開発を進めていきたいです。
明日は @tidus さんと @sumire0517 さんです。
