はじめに
どうも、スペリスで開発をしております、yasunagaです。
最近、仕事で使用しているHelmのドキュメントの中で、自分が翻訳したのが載りました。
拙い訳ですが、是非、見ていただけますと幸いです🙇♂️
さて、この記事では、普段仕事で使用しているデータベースのセットアップの方法について紹介したいと思います。
セットアップの方法を設計する際に一番心がけたのは、
セットアップの手順が増えないこと
です。
環境構築する際に、コマンドを叩く量が増えると、それだけつまづくリスクが増えるので、一つのコマンドで簡潔させたい理想からスタートして、構築を進めました。
対象読者
- GoなどでAPIを作成している方
- Gormなどを使って、データベースへのアクセスを行なっている方
- データベースのスキーマの設定に悩んでいる方
- マスタデータの投入方法について悩んでいる方
使用している技術
まずDBのセットアップ時に使用している技術を紹介します。
ここではバックエンド(特にデータベース)の部分に絞って解説いたします。
- Go + Gin
- air(ホットリロード)
- MySQL
- sql-migrate
- Gorm
- Docker
①マイグレーション
スペリスでは、APIサーバを立ち上げるとマイグレーションを実行するようにしています。
これは、環境構築時にできるだけコマンドを減らすという意味もありますが、デプロイ時に、他環境へのDBセットアップを容易にする意味合いもあります。(デプロイすると自動的にマイグレーションが実行されるようになるため)
当初こちらはGormの標準のmigrationを使用していました。
db.AutoMigrate(&User{})
db.AutoMigrate(&User{}, &Product{}, &Order{})
// Add table suffix when creating tables
db.Set("gorm:table_options", "ENGINE=InnoDB").AutoMigrate(&User{})
ただ、GormのAutoMigrateだと、後で追加したカラムがcreated_at, updated_atの後ろに追加されてしまって、あまり見た目がよくないなという問題がありました。
そこでsql-migrateを使用することになりました。
sql-migrate
sql-migrateは、sql文を作成してmigrationを行うことができる、Go製のマイグレーションツールです。
インストール方法
go get -tags godror -v github.com/rubenv/sql-migrate/...
そして、yaml形式のDBのコネクション先などを指定するyamlファイルを作成します。もちろんホストなどは環境変数で変えられるようにします。場所はどこでも良いと思いますが、私はconfig/dbconfig.ymlにしました。
dirはマイグレーションファイルを保存するディレクトリ、tableはmigrationの実行履歴用のテーブルの名前ですね。
development:
dialect: mysql
datasource: ${DATABASE_USERNAME}:${DATABASE_PASSWORD}@tcp(${DATABASE_HOST}:3306)/${DATABASE_NAME}?charset=utf8&parseTime=True&loc=Local
dir: db/migrations
table: migrations
ファイルの作成とup, down
そして、以下のコマンドを実行すると、migrationファイルが、ymlのdirで指定したフォルダに作成されます。nameの部分はマイグレーションファイルのファイル名になります。
sql-migrate new -config=./config/dbconfig.yml ${name}
実行すると以下のようなファイルが作成されます。
-- +migrate Up
-- +migrate Down
-- +migrate Upの下にマイグレーションup時のSQLを記述し、-- +migrate Downの下にdown時のSQLを記述する感じですね。例えばこんな感じ。
-- +migrate Up
CREATE TABLE IF NOT EXISTS `users` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`email` varchar(255),
`created_at` datetime(3) DEFAULT NULL,
`updated_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_email` (`email`(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- +migrate Down
DROP TABLE IF EXISTS `users`;
upとdownのコマンドは以下の通り。
# migrate up
sql-migrate up -config=./config/dbconfig.yml
# migrate down
sql-migrate down -config=./config/dbconfig.yml
Makefileの作成
いちいち-config=./config/dbconfig.ymlをつけてコマンド実行は面倒くさいし、覚えられないですよね。
ここは、Makefileで管理するのが得策です。
自社ではDockerを使用していますので、コンテナでコマンドを実行します。container_nameのところは適宜変えてください。
CONTAINER_NAME=container_name
migrate-new: ## create migration file ## make migrate-new name=hoge
docker exec -it $(CONTAINER_NAME) sql-migrate new -config=./config/dbconfig.yml $(name)
migrate-up: ## run migration
docker exec -it $(CONTAINER_NAME) sql-migrate up -config=./config/dbconfig.yml
migrate-down: ## revert migration
docker exec -it $(CONTAINER_NAME) sql-migrate down -config=./config/dbconfig.yml
これで、Makefileがある場所で、以下みたいな感じでコマンドを実行できるようになります。
make migrate-new name=create_users
make migrate-up
make migrate-down
API立ち上げの時にmigrationも実行する
API立ち上げする際に、アプリケーション側からsql-migrateを実行して、マイグレーションを実行しちゃいます。大分簡素化させておりますが、以下みたいな感じでアプリケーションからmigrationを実行しております。
package main
func main() {
dbInit()
defer dbClose()
// サーバ立ち上げ
serverInit()
}
func dbInit() {
// migration実行
migrations := &migrate.FileMigrationSource{Dir: "db/migrations"}
migrate.SetTable("migrations")
n, err := migrate.Exec(db, "mysql", migrations, migrate.Up)
if err != nil {
log.Fatalf("migration error: %v", err)
}
log.Printf("Applied %d migrations\n", n)
}
開発時には、airを使ってホットリロードをしているので、ホットリロードするとmigrationも実行されます。
②マスタデータ
次に紹介するのは、マスタデータの投入方法についてです。
私自身、Railsの経験が長く、昔はseed-fuというseederでマスタデータを投入していたのですが、Goのseederというと特に見当たりませんでした。
なので、自社では自分で簡単に作っております。
実現したかったこと
まず、私が実現したかったseederの仕様をざっくりと考えてみます。
- APIの立ち上げのタイミングでseederを実行し、マスタを投入したい
- seedのデータを更新すると、変更を検知して、そのマスタ(DBのデータ)も更新される
- 追加した場合も、追加したものを自動的にマスタに追加したい
- マスタの削除はしない(本番などで既にマスタと紐づいているデータがあるかもしれないため)削除したい場合は、手作業でバッチを作成するか、SQLでする。
これらを踏まえて、seederの作成に取り掛かりました。
seederの作成
幸い、GormにはSaveというメソッドがあり、それを使用すれば、既にあるデータは更新され、存在しないデータは追加される挙動になるため、そちらを使用いたしました。
seedファイル作成
リポジトリにdb/seedsというフォルダを作って、そこで、マスタデータを管理するようにしました。
package seeds
import (
"time"
"gorm.io/gorm"
)
// DBのスキーマと同じ構造体。普段は別のパッケージに定義していますが、説明のために簡素化してこちらに定義
type Option struct {
ID uint `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}
var options = []Option{
{ID: 1, Name: "オプション1"},
{ID: 2, Name: "オプション2"},
}
func seedOptions(db *gorm.DB) error {
for _, e := range options {
// CreatedAtを設定しないとエラーが出る
e.CreatedAt = time.Date(2021, time.November, 5, 0, 0, 0, 0, time.UTC)
if err := db.Save(&e).Error; err != nil {
return err
}
}
return nil
}
そして、同じところにseed.goを作って、以下みたいな感じで、データを作成・更新するところを作成しました。
package seeds
func Seed(db *gorm.DB) error {
if err := seedOptions(db); err != nil {
return err
}
}
それを、dbInit()のところで実行して、seedを実行しました。
// seed実行(初期データ)
if err := seeds.Seed(gormDB); err != nil {
log.Fatalf("seed error: %v", err)
}
これでスライスを例えば、以下のように変更した場合に、ID: 2のオプションの名前がupdateされる仕組みになっています。追加した場合も同様に作成されます。
var options = []Option{
{ID: 1, Name: "オプション1"},
{ID: 2, Name: "オプション3"}, // -> オプション2から3に変更
{ID: 3, Name: "オプション4"}, // -> 追加
}
ここでは、IDを必ず指定するようにします。IDを指定しないと、seedが実行されるたびに新しくデータが追加されます。
投入方法の問題点
さて、最初はこのseederもうまくいっていましたが、問題点がありました。それは、
ホットリロードする度に実行されるので、コンパイルが遅くなる
ことです。ソースコードを更新するたびにホットリロードが走るので、開発の生産性に影響していました。毎回マスタデータをSaveしにいっているので、データの分だけDBのIOが発生していまい、それが遅くなっている原因の一つでした。
解決
昔にFlutterを触った時に、sqliteを使ってアプリを作ったのですが、その時の経験を応用してみました。
具体的には、seedsというデータベースのテーブルを作成し、そこでマスタデータのバージョン管理をするという方法です。
以下のようなテーブルをsql-migrateで作成します。table_nameはマスタのテーブル名、versionはバージョン番号で、1からの連番で付けます。
CREATE TABLE IF NOT EXISTS `seeds` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`table_name` varchar(255) NOT NULL,
`version` int NOT NULL DEFAULT 1,
`created_at` datetime(3) DEFAULT NULL,
`updated_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_table_name` (`table_name`(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
レコードは例えば以下のようになります。table_nameはマスタの格納先テーブル名、バージョンはマスタのバージョンです。
id | table_name | version |
---|---|---|
1 | options | 1 |
2 | skills | 2 |
2 | languages | 1 |
そして、seed.goに新しくメソッドを追加。
package seeds
type Seed struct {
ID uint
TableName string
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
func Seed(db *gorm.DB) error {
if err := provision(db, 1, "options", seedOptions); err != nil {
return err
}
}
func provision(db *gorm.DB, version int, tableName string, seedFunc func(db *gorm.DB) error) error {
var seed Seed
result := db.Where("table_name = ?", tableName).Find(&seed)
if result.RowsAffected == 0 {
newSeed := entity.Seed{TableName: tableName, Version: version}
if err := db.Create(&newSeed).Error; err != nil {
return err
}
if err := seedFunc(db); err != nil {
return err
}
} else {
if version != seed.Version {
seed.Version = version
if err := seedFunc(db); err != nil {
return err
}
if err := db.Save(&seed).Error; err != nil {
return err
}
}
}
return nil
}
やっていることは、
- tableNameに渡したテーブル名のseedsのレコードを見に行く
- そのtableNameがレコードが無かったら、seedsのレコードを作成して、seedFuncを実行(seedFuncはマスタデータ作成処理)
- そのtableNameがレコードが存在し、かつバージョンがレコードのバージョンと、provisionに渡されたバージョンが違っていたら、seedFuncを実行をし、seedsのレコードを更新
しています。
マスタを更新して反映する場合は、provisionに渡しているバージョン番号を1プラスすれば、更新される仕組みです。
バージョンがそのままであれば、マスタの更新は行わないため、seedsテーブルのSELECTのみで済みます。なので、ホットリロードする度にSaveが走るのを防げるわけです。これにより、開発者間でのマスタデータの差異や各環境間でのデータの差異も吸収できるようになりました。
おわりに
ここまでDBのセットアップについて色々解説してきました。まとめると、
- GormのAutoMigrateだとCreatedAtなどのカラムが前に来てしまうので、sql-migrateを使用
- 各環境へのDBのスキーマ変更などを容易にするため、API立ち上げ時にmigrationも実行
- seedの仕組みは自分で実装
- seederはseedsというバージョン管理のテーブルを作成し、開発時、毎回Saveをしないように制御
まだまだ発展途上ですが、どなたかの参考になれば幸いです。