ORMライブラリを使うときの特殊なマイグレーション施策

皆さん、ORMライブラリは使っていますか?私は、最近Goでentを使ったアプリケーションを開発しています。

そこで、SQLマイグレーション方法について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にはオフラインモードのマイグレーションという機能があり、これを使用することでDDLio.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 INDEXCREATE 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に差分を書き込みます。

動作例

新しくテーブルを作成する

  1. schemaディレクトリに[テーブル名].sqlとしてSQLファイルを作成して追加したいDDLを記述
  2. go run main.go [マイグレーションの説明] を実行するとgo-migrateのup, downのファイルが作成されupに差分が入っている
  3. 追加でdownを記述する(ここは自動化できなかった)
  4. DBと接続してgo-migrate実行

既存のテーブルに変更を加える

  1. schema ディレクトリのSQLファイルを書き換える
  2. go run main.go [マイグレーションの説明]を実行
  3. 追加でdownを記述する
  4. DBと接続してgo-migrate実行

CREATE EVENTとか対応していないやつ

  1. migrate create -ext sql -dir ./db/[テーブル名] [マイグレーションの説明]
  2. up, down書く
  3. DBと接続してgo-migrate実行

どう?いいでしょ

forkして使ってくれてもいいのよ。もっといい方法とかあったらはてブとかでコメントください。

*1:https://entgo.io/ja/docs/versioned-migrations

*2:ファイル生成は外部公開されていなかったので愚直にコマンドを叩いてます。