皆さん、ORMライブラリは使っていますか?私は、最近Goでentを使ったアプリケーションを開発しています。
そこで、SQLのマイグレーション方法についてSQLサーバーが特殊であるため簡単にできなかったため備忘録として残そうかと思います。
環境
- プログラミング言語: Go
- ORM: ent
- RDBMS: MySQL
- サーバー: Google Cloud Run
- DBサーバー: Google Cloud SQL
- DB管理リポジトリ: github.com/cateiru/sql
個人開発なので費用を抑えたい
DBサーバーって、実はめちゃくちゃ高いんですよ。VPSとか使うともう少し安く済みますが、私自身メンテしきれる自身が無いのであきらめてCloud SQLに丸投げしてしまっています。そのため、プロダクトごとにインスタンスを分けて〜ということはできるはずもなく1つのインスタンスにデータベースを分けてそれぞれでアプリケーションのDBを管理しています。
そのため、マイグレーション関連はすべて1つのリポジトリで管理したいという話題があり、実際にcateiru/sqlで管理しています。マイグレーションツールはgo-migrateを使用しています。
なので、entなどから本番用DBにプロキシ経由でマイグレーションを実行することはできないのでどうしようか…というものです。
entを使用したマイグレーションを諦める
entでは、go-migrateを使用したマイグレーションもサポート*1しています。 しかし、このリポジトリから本番DBには接続したくないのでこの機能は使えません。
そのため、一度全てのSQL文(DDL)を出力してそこから差分を取得するというようにします。
entからDDLを出力する
entでは、export ddl
みたいな機能は存在していないため空のテーブルに対してマイグレーションを実行して出力します。
entにはオフラインモードのマイグレーションという機能があり、これを使用することでDDLをio.Writer
で出力できます。それを使用して以下のようなスクリプトを作成します。
ctx := context.Background() db, err := ent.Open("mysql", "docker:docker@tcp(localhost:3306)/em?parseTime=True") if err != nil { return err } defer db.Close() f, err := os.Create("schema.sql") if err != nil { return err } defer f.Close() if err := db.WriteSchema(ctx, f); err != nil { return err } return nil
(dockerで、em
という空のテーブルを作成しています)
この状態で実行することでentからDDLを出力することができます。
DDLの差分を取る
なにかカラムを追加したときや変更したときDDLを記述する必要があります。しかし、毎回ALTER TABLE
を書くのはものすごくめんどくさいので今回、2つのSQL文から差分を取得できるschemalex/schemalexを使用して自動で差分を生成できるようにします。
schemalexは、なんでも差分を生成できると思っていたのですが、実はCREATE INDEX
やCREATE EVENT
は対応していなかったのでそれらは諦めて手動でDDLを作成する形にしています。
スクリプトは以下です。
// マイグレーション用のDSLを自動で生成するスクリプト package main import ( "bytes" "errors" "flag" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "github.com/schemalex/schemalex/diff" ) var TmpDirName string = ".before_scheme" var TargetDirName string = "schema" func main() { flag.Parse() migrateName := flag.Arg(0) if migrateName == "" { log.Fatal("migration name is failed\nusage:\n\tgo run . [migration name]\n\n") } tmpDir, err := filepath.Abs(TmpDirName) if err != nil { log.Fatal(err) } targetDir, err := filepath.Abs(TargetDirName) if err != nil { log.Fatal(err) } files, err := os.ReadDir(targetDir) if err != nil { log.Fatal(err) } for _, file := range files { if !file.IsDir() { path := filepath.Join(targetDir, file.Name()) err := Execute(path, tmpDir, migrateName) if err != nil { log.Fatal(err) } } } } // path: `./scheme`のファイルパス // tmpDir: スキーマの一時保存のフルパス func Execute(path string, tmpDir string, migrateName string) error { if _, err := os.Stat(path); err != nil { return errors.New("file not exists") } fileName := filepath.Base(path) tmpPath := filepath.Join(tmpDir, fileName) tmpBody, err := ReadTmpFile(tmpPath) if err != nil { return err } targetBin, err := os.ReadFile(path) if err != nil { return err } targetBody := string(targetBin) diffBody, err := Diff(tmpBody, targetBody) if err != nil { return err } // 差分がない場合はなにもしない if diffBody == "" { fmt.Printf("%s: is no diff\n", fileName) return nil } if err := SaveTmp(tmpPath, targetBody); err != nil { return err } name := filepath.Base(path[:len(path)-len(filepath.Ext(path))]) if err := CreateMigration(name, diffBody, migrateName); err != nil { return err } fmt.Printf("%s: Success export diff!\n", fileName) return nil } // tmpのファイルを読み込む // もしファイルが存在しない場合は作成する func ReadTmpFile(tmpPath string) (string, error) { tmpBody := "" // tmpにファイルが存在しない場合、作成する if _, err := os.Stat(tmpPath); err != nil { _, err := os.Create(tmpPath) if err != nil { return "", err } return tmpBody, nil } body, err := os.ReadFile(tmpPath) if err != nil { return "", err } tmpBody = string(body) return tmpBody, nil } func SaveTmp(tmpPath string, body string) error { return os.WriteFile(tmpPath, []byte(body), 0664) } func Diff(current string, target string) (string, error) { var buff bytes.Buffer if err := diff.Strings(&buff, current, target, diff.WithTransaction(false)); err != nil { return "", err } return buff.String(), nil } func CreateMigration(filename string, diffBody string, migrateName string) error { cmd := exec.Command("migrate", "create", "-ext", "sql", "-dir", fmt.Sprintf("./db/%s", filename), migrateName) var stderr bytes.Buffer cmd.Stderr = &stderr err := cmd.Run() if err != nil { return err } // len.0: up, len.1: down exportFiles := strings.Split(stderr.String(), "\n") return os.WriteFile(exportFiles[0], []byte(diffBody), 0664) }
このスクリプトは、schema
というディレクトリにSQLを作成、編集を行うとキャッシュが入っている.before_schema
のディレクトリのSQL文と比較して差分を出力、go-migrateのcreate
コマンドを使用*2してマイグレーション用のファイルを生成してXXXX-up.sql
に差分を書き込みます。
動作例
新しくテーブルを作成する
schema
ディレクトリに[テーブル名].sql
としてSQLファイルを作成して追加したいDDLを記述go run main.go [マイグレーションの説明]
を実行するとgo-migrateのup
,down
のファイルが作成されup
に差分が入っている- 追加で
down
を記述する(ここは自動化できなかった) - DBと接続してgo-migrate実行
既存のテーブルに変更を加える
CREATE EVENT
とか対応していないやつ
migrate create -ext sql -dir ./db/[テーブル名] [マイグレーションの説明]
up
,down
書く- DBと接続してgo-migrate実行
どう?いいでしょ
forkして使ってくれてもいいのよ。もっといい方法とかあったらはてブとかでコメントください。
*1:https://entgo.io/ja/docs/versioned-migrations
*2:ファイル生成は外部公開されていなかったので愚直にコマンドを叩いてます。