🎃

[Go] 複数モジュールから参照するテーブルのモデルを共通化

2022/07/13に公開

複数のアプリケーションから呼ば得る Go のパッケージを別 module として切り出すことで、コードの多重管理を防ぐことができました。コードの共通化のアプローチとしては汎用的だと思ったので紹介します。

背景

  • バックエンドには Hasura GraphQL Engine を採用している関係で、マイグレーション管理は Hasura で行っている
  • Hasura のロジック拡張に Remote Schemas を採用 (Go + 99designs/gqlgen)
    • Hasura の構成ファイルと Remote Schemas は同じリポジトリで管理
    • Remote Schemas では ORM に GORM を使用
    • テーブルのモデルの生成には smallnest/gen を使用
      • DB のスキーマからいい感じに構造体を生成してくれます
# 実際のディレクトリ構成とは若干異なりますが、わかりやすさのために簡略化しています
.
├── external   # Hasura の Remote Schemas になっている GraphQL サーバ
│   ├── models # DB モデル。gen で生成
│   │   ├── templates
│   │   └── gen
│   │       ├── hoge_table1.go
│   │       ├── hoge_table2.go
│   │       └── hoge_table3.go
│   # 途中省略
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── hasura # Hasura の構成ファイル。Go のプロジェクトではない

「Hasura / Remote Schemas って何」という方向けに解説記事を貼っておきます。わからなくても本記事の趣旨はつかめると思います。

https://qiita.com/maaz118/items/9e198ea91ad8fc624491

https://zenn.dev/msorz/articles/47b47acedb3c5e

やりたいこと

DB に対しての読み込み・書き込みを伴うバッチ処理を作りたかったです。バッチでは既存の BE と同様、ORM には GORM、モデルの生成には gen を使いたいなと思いました。
モデル、テスト DB のセットアップ処理など、背景 で紹介した GraphQL サーバと一言一句同じコードが生まれてしまうなとは思いつつ、プロジェクトのルートから別でディレクトリを切ってみました。

.
├── batch-job  # 追加。バッチ処理のソースコード
│   ├── models # DB モデル。gen で生成
│   │   ├── templates
│   │   └── gen
│   │       ├── hoge_table1.go
│   │       ├── hoge_table2.go
│   │       └── hoge_table3.go
│   ├── services
│   # 途中省略
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── external   # Hasura の Remote Schemas になっている GraphQL サーバ
│   ├── models # DB モデル。gen で生成
│   │   ├── templates
│   │   └── gen
│   │       ├── hoge_table1.go
│   │       ├── hoge_table2.go
│   │       └── hoge_table3.go
│   ├── graph
│   # 途中省略
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── hasura # Hasura の構成ファイル。Go のプロジェクトではない

batch-job というディレクトリを切り、そこに新たな module をつくりました。既存の external とは完全に独立した module としています。
ご覧の通り externalbatch-job ともに models ディレクトリが切られ、同じファイルが入っています。コードの多重管理が生まれてしまっています。external の models も batch-job の models も変更頻度・変更理由が全く同じなので、それぞれ別 module で管理することにメリットはありません。どうにかして、この models を GraphQL サーバからも、バッチ処理からも呼べるように共通化したいです。

Go のマルチモジュール

コードの共通化するために、Go のマルチモジュール機能を使うことにしました。共通化したいコードを shared という module に切り出しました。DB のモデルの他に、テストの共通処理もついでに切り出しました。

.
├── docker-compose.yml
├── batch-job  # バッチ処理のソースコード
│   ├── services
│   # 途中省略
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── external   # Hasura の Remote Schemas になっている GraphQL サーバ
│   ├── graph
│   # 途中省略
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── shared     # 追加。external/batch-job 両方から呼ばれる処理をここへ
│   ├── models # DB モデル。gen で生成
│   │   ├── templates
│   │   └── gen
│   │       ├── hoge_table1.go
│   │       ├── hoge_table2.go
│   │       └── hoge_table3.go
│   ├── go.mod
│   ├── go.sum
│   └── test   # テスト用の汎用処理 (DBセットアップ・factoryなど)
├── hasura     # Hasura の構成ファイル。Go のプロジェクトではない

マルチモジュールを使うのにいくつかアプローチがあったのですが、今回は replace ディレクティブを使うやり方にしました。

https://go.dev/doc/modules/gomod-ref#replace

Replaces the content of a module at a specific version (or all versions) with another module version or with a local directory. Go tools will use the replacement path when resolving the dependency.
特定のバージョン (またはすべてのバージョン) のモジュールの内容を、別のモジュールのバージョンまたはローカルディレクトリに置き換えます。Go ツールは、依存関係を解決するときに置換されたパスを使用します。

replace は go.mod に指定して使います。 => の後ろで指定されたパスに存在しているモジュールを、replace のあとに書いたパス名で import できます。

サンプルコード

go.mod
module github.com/example/huga

go 1.15

// module-path: そのモジュール内で使いたいモジュールのパス名。任意のパス名をつけることができる
// actual-path: 実際にそのモジュールが存在する (go.mod が存在する) パス
// replace "module-path" => "actual-path"

replace github.com/example/hoge => ../hoge
main.go (huga module)
package main

import "github.com/example/hoge"

func main() {
  hoge.GetHoge()
}

今回やりたいのは、shared という module を batch-job と external の両方から呼べるようにすることでした。replace を使って、batch-job、external の go.mod で shared のルートパスを任意のパスに置換します。

go.mod (external)
module github.com/kmtym1998/server-external

go 1.15

replace github.com/kmtym1998/server-shared => ../shared
go.mod (batch-job)
module github.com/kmtym1998/server-batch-job

go 1.18

replace github.com/kmtym1998/server-shared => ../shared

これでそれぞれの module から shared が使えるようになりました。shared module のパッケージを使う場合は、replace のあとに書いたパス名を用いて import します。

hoge.go
package hoge

import (
  "github.com/kmtym1998/server-shared/models"

  "gorm.io/gorm"
)

func insertHoge(db *gorm.DB, param *models.Hoge) error {
  return db.Create(param).Error
}

これで shared にある DB モデルが参照できるようになりました 🎉🎉🎉🎉

補足

workspace モードを使わなかったのはなぜ?

go1.18 からは workspace モードを使うことができます。workspace モードを使うともう少しスマートな感じでマルチモジュールの管理ができます。記事が長くなるので詳細は割愛します。
今回 workspace モードを使わなかったのは、external をビルドする docker イメージの環境で go1.15 を使っていたためです。Go は基本的に下方互換を保ったままバージョンが上がっています。external で使う Go のバージョンを 1.18 に上げることを検討してよかったかもしれません。

shared を別リポジトリで管理して、ライブラリ化すればよかったのでは?

shared に含まれる DB モデルは、DB スキーマの変更に追随させる必要があるため、モデルの変更タイミングはスキーマの更新に依存しています。今回取り上げたプロジェクトではマイグレーション管理をしているのが Hasura なので、そこと切り離すのが難しく、同一リポジトリで管理するやり方をとりました。

GitHubで編集を提案

Discussion