LayerX エンジニアブログ

LayerX の エンジニアブログです。

Go言語のORMであるGORMをv1からv2へのマイグレーションした話 #LayerXテックアドカレ

こんにちは。バクラク請求書チームでエンジニアをしている赤羽です。 昨年の12月のLayerXにJOINしたので、今月でちょうど1年経ちました。

この記事は  LayerXテックアドカレ2023  25日目の記事です。 前回はsh_komine が「相互理解の重要性と、促進するためのワークショップのご紹介」を書いてくれました。次回は@yoheiよりポストされる予定なのでご期待ください。

GORMとは

GORMはGo言語の人気のあるORM(Object Relational Mapping)ライブラリです。 データベース(テーブル)とGoの構造体のマッピングを簡単に行うことができます。シンプルな構文、多様なデータベースサポート、マイグレーション、トランザクション管理など、豊富な機能が提供されています。 柔軟なクエリビルダーも提供され、開発者に使いやすいORMライブラリとなっています GORMについては公式で日本語の充実したドキュメントもありますし、様々な記事で解説されておりますので詳細については他記事を参照してください。 gorm.io zenn.dev

GORMはバクラクでも利用しております。2021年にv2とメジャーバージョンアップが行われましたが、バクラク請求書では長くv1のままとなっていました。 本記事では、バクラク請求書で行ったv2へバージョンアップについてお話ししたいと思います。

バージョンアップ前のアプリケーション構造

初めに、バージョンアップ前のアプリケーション構造を見てみましょう。 主にRepository、Serviceから出来ており、および各インスタンスまたDBインスタンスをコンストラクタインジェクションによりDIし利用しています。 以下がサンプルになります。わかりやすく、バクラク請求書のアプリケーションを簡素化したものになります。

func main() {
    sqlDB, err := sql.Open("mysql", "root:password@/dev_payer")
    if err != nil {
        panic(err)
    }
    db, err := gorm.Open("mysql", sqlDB)
    if err != nil {
        panic(err)
    }
    repo := NewRepository()
    service := NewService(db, repo)
    res, err := service.Get(context.Background())
    if err != nil {
        panic(err)
    }
    fmt.Println(res)
}

type Service struct {
    v1   *gorm.DB
    repo *Repository
}

func NewService(db *gorm.DB, repo *Repository) *Service {
    return &Service{
        v1:   db,
        repo: repo,
    }
}

func (s *Service) Get(ctx context.Context) (string, error) {
    return s.repo.Find(ctx, s.v1)
}

type Repository struct{}

func NewRepository() *Repository {
    return &Repository{}
}

func (r *Repository) Find(ctx context.Context, db *gorm.DB) (string, error) {
    // DBアクセス処理。ここでは省略 
    return "hello", nil
}

mainでRepository,Service、DBのインスタンスを生成しそれぞれDIしています。 RepositoryでGORMを用いてデータアクセス処理を実装しています。 DB(GORM)のインスタンスはServiceからRepositoryの関数の引数として渡され、それを用いてDBにアクセスしています。

アプローチ その1:一気にバージョンアップ

最初に試みたのは、v1からv2への一気にアップデートするアプローチでした。 v2もある程度I/Fの互換性はあるようでしたので、一気にv2に置き換えてしまうアプローチを考えました。 多少の非互換はあるものの、コンパイルエラーと格闘しながら一旦バージョンアップは完了しました。 しかし、動作確認の段階で問題が発生しました。調査すると、GORMにより生成されるクエリがv2では異なることが判明しました。 ビジネスロジック部分のユニットテストはあるものの、Repository層のユニットテストが整備されていませんでした。 そのため、実際のバクラク請求書のアプリーション規模から考えると不安が残ることから断念しました。

アプローチ その2:テーブルごとのバージョンアップ

次に試みたのは、テーブルごとに少しずつv2にアップデートするアプローチです。 この方法では、v1とv2のDBインスタンスの両方を保持する構造体と、それを扱うinterfaceを定義し扱うことにしました。 そして、テーブルごとに順次アップデートしていきました。

type DB interface {
    V1() *gormv1.DB
    V2() *gormv2.DB
}

type db struct {
    v1 *gormv1.DB
    v2 *gormv2.DB
}

type Service struct {
    db   DB
    repo *Repository
}

func NewService(db DB, repo *Repository) *Service {
    return &Service{
        db:   db,
        repo: repo,
    }
}

func (s *Service) Get(ctx context.Context) (string, error) {
    return s.repo.Find(ctx, s.db.V2()) // このRepositoryだけv2に移行する 
}

Repositoryの関数を実行する際に、対応するバージョンのインスタンスをDBから取得し渡すようにしています。 また、Repository単位で、ユニットテストを書きながらv2にバージョンアップしていきました。 一旦順調にバージョンアップは進んで行きましたが、また問題が発生しました。 単一テーブルへのCRUDであれば問題ありませんでしたが、トランザクションが複数のテーブルにまたがる場合に難しさが現れました。 例えば、以下のようなコードです。 トランザクションはGORMの機能を用いています。 v2のGORMトランザクションはv1のトランザクションとして利用することはできません。 こういう場合はまとめてv2にバージョンアップが必要になります。 実際のコードは様々なテーブルでトランザクション処理しているため、トランザクションがまたがっているRepsitoryをバージョンアップしようとほぼ一気にバージョンアップすることとなり断念しました。

func (s *Service) Update(ctx context.Context) error {
    err := s.db.V2().Transaction(func(tx *gormv2.DB) error {
        s.repo1.Save(ctx, tx, foo)
        s.repo2.Save(ctx, v1tx, bar) // v1のtxを必要とするのでダメ!
        return nil
    })
    return err
}

アプローチ その3:トランザクションの共有

最終的に成功に至ったのは、GORM v1とv2でトランザクションを共有するアプローチでした。 GORMはORMとして機能しますが、最終的には sql.DBsql.Tx 経由で実行されています。工夫することでv1とv2でトランザクションを共有することが可能ではないかと考えました。 v1のGORMインスタンス生成時にコンストラクタにsql.DBを渡しています。実はここはintefaceになっており、sql.Txも渡せます。 また、内部的にもsql.Txが渡されることも考慮されていそうでした。 なので、v2のGORMでトランザクションを実行しsql.Txを取り出し、それを使ってv1のGORMインスタンスを生成してあげれば良さそうです。 以下のようなコードになります。

type DB interface {
    V1() *gormv1.DB
    V2() *gormv2.DB
    Transaction(f func(tx DB) error) error
}

func (d *db) Transaction(f func(tx DB) error) error {
    return d.v2.Transaction(func(txv2 *gormv2.DB) error { // v2でトランザクションを発行
        sqlTx, ok := any(txv2.Statement.ConnPool).(*sql.Tx) // v2からsql.Txを取り出す
        if !ok {
            return fmt.Errorf("failed to get sql.Tx")
        }
        txv1, err := gormv1.Open("mysql", sqlTx) // sql.Txからv1のGORMを生成
        if err != nil {
            return err
        }

        tx := &db{
            v1: txv1,
            v2: txv2,
        }
        return f(tx) // txをセットしたDB(interface)を渡し関数を実行
    })
}

Service層ではDB interfaceのまま扱い、 Repository関数へはそのまま渡すようにしました。 GORMインスタンスを取り出して扱うのはRepository層内に限定しました。 それにより、Service層では特にGORMのバージョンを意識する必要もなくなりました。 このアプローチにより、順調にRepository単位でv2にバージョンアップを進めることができました。

func (s *Service) Update(ctx context.Context) error {
    err := s.db.Transaction(func(tx DB) error {
        s.repo1.Save(ctx, tx, foo)
        s.repo2.Save(ctx, tx, bar)
        return nil
    })
    return err
}

おわりに

GORM v1からv2へのマイグレーションは、当初予測できない問題に直面することもありましたが、最終的には解決のアプローチを見つけ、マイグレーションすることができました。 Repository層のユニットテストの整備(もちろん他もですが)が、今後のアップデートや拡張に対して安心感をもたらし、バクラク請求書のシステムの安定性を確保することでしょう。 今回の対応でRepository層のユニットテストを整備したことで、今後のMySQL8(現在は5.7系利用)への移行やリードレプリカ対応などの安全なアップデートも進められるようになりました。

ハタラクをバクラクにするプロダクトを一緒に開発していきたい人を大募集中です!もしご興味ありましたら、ぜひカジュアル面談からお話させてください! LayerX Casual Night というお酒を飲みながらカジュアル面談よりカジュアルにゆる~く話せるイベントも開催しておりますので、ぜひご参加ください! jobs.layerx.co.jp

採用情報はこちら↓ jobs.layerx.co.jp