🦈

Goの共通モジュール管理の方法をまとめてみた(Go1.18で導入されたWorkspaceも)

2022/04/05に公開

概要

Amplifyを使ってサービスを構成する方針で、バックエンドにLambda関数をいくつか用意する必要がありました。
その際に、Amplify-cliを使って構築してみたのですが、

  • GoでLambda Layerを生成するコマンドがなかった
  • Lambda関数のみで生成するとgo.modが分かれて、lambda関数単位のプロジェクトが生成されてしまった

これらのことからGoで共通モジュールをいい感じに管理する方法ないかなと探っていたところ、Go1.18で導入されたWorkspaceが機能的にマッチしていたのでまとめてみました。
そのついでに色々まとめてみた記事です。

手っ取り早くWorkspaceのやリ方だけをみたい方はこちらだけ参照してください

Goで共通モジュールを取り扱う方法

go1.18以前にGoで共通モジュールを使う方法は2つ(知ってる限り)

  • Go1.8(1.18ではない)から導入されたPlugin
  • Gitでversion管理されたもの
    • この補助として1.18で導入されたWorkspaceは機能する

Plugin

ディレクトリは最終的にこんな感じになります
これに従って作成を進めます

multimodule
├── go.mod
├── main.go
└── pluginmodules
    ├── go.mod
    ├── plugin.go
    └── plugin.so

ディレクトリとgo mod生成

~/ $ mkdir multimodule
~/ $ cd multimodule
~/multimodule/ $ go mod init multimodule
~/multimodule/ $ mkdir pluginmodules
~/multimodule/ $ cd pluginmodules
~/multimodule/pluginmodules/ $ go mod init pluginmodules

~/multimodule/pluginmodules/plugin.goファイルを用意します

package main

import (
	"fmt"
)

func Run(from string) {
	fmt.Printf("これはプラグインです。%sから呼ばれました", from)
}

~/multimodule/pluginmodules/plugin.goファイルをビルドします
すると、plugin.soファイルが生成されます

~/multimodule/pluginmodules $ go build -buildmode=plugin -o plugin.so

ビルドしたPluginファイルを~/mutimodule/main.goの方で使用します

package main

import (
	"fmt"
	"os"
	"plugin"
)

func main() {
	var (
		pl      *plugin.Plugin
		err     error
		runFunc plugin.Symbol
	)

	if pl, err = plugin.Open("./pluginmodules/plugin.so"); err != nil {
		fmt.Println("プラグインの読み込みに失敗しました")
		os.Exit(1)
	}
	if runFunc, err = pl.Lookup("Run"); err != nil {
		fmt.Printf("プラグインの実行に失敗しました: %s", err)
		os.Exit(1)
	}

	runFunc.(func(string))("Mainファイル")
}

実行

~/multimodule/ $ go run main.go
# 実行結果
これはプラグインです。Mainファイルから呼ばれました

Pluginを使う場面

これで共通モジュールを作成できました。
使う場合は、さまざまなプロジェクトからplugin.Open("./modules/plugin.so")でOpenしましょう
個人的に共通モジュールの管理でPluginを使うくらいならGitで管理する方法を選ぶと思いますが、CLIからGoのファイルを指定して実行させたい場合に有効でしょうか、というかそういうのを想定しているからPluginって名前なんでしょうね

例えば、テスト用のデータを生成したい場合に、用意しておいたGoのSeederファイルをCLIで指定してデータを流し込みたい場合

  1. xxx_seeder.goファイルを作ってRunメソッドのみ定義する
  2. soファイルにBuildしておく
  3. seedrunnner.goのようなものを用意して、flagパッケージを使って-fileオプションを受け付けるようにする
  4. seedrunner.go -file xxx_seederという名前を渡す
  5. plugin.Open(fmt.Sprintf("./seeder/%s.so", filename))で開く
  6. runFunc, err := pl.Lookup("Run")でRunメソッドを読み込む
  7. runFunc.(func())()でSeederを実行する

って感じでしょうか。
Seederの事前ビルドが必要なので、Makefileなどで一手間加えておくと良きだと思います。

Gitで管理する方法

Goはgitで管理されているモジュールをgo getで取得することが可能です
この方法がメジャーだと思います。

ディレクトリは最終的にこんな感じになります
これに従って作成を進めます

test
├── gitmodules
│   ├── go.mod
│   └── plugin.go
├── go.mod
└── main.go

まずはgithubでrepositoryを作成(これは省きます)
次にディレクトリとgo mod生成

~/ $ mkdir multimodule
~/ $ cd multimodule
~/multimodule/ $ go mod init multimodule
~/multimodule/ $ mkdir gitmodules
~/multimodule/ $ cd gitmodules
~/multimodule/gitmodules/ $ go mod init github.com/y-saiki1/gitmodules # 自分で作ったgithubのリポジトリ名にしてください

次に共通モジュールとして~/multimodule/gitmodules/plugin.go作成

package gitmodules

import "fmt"

func Run(from string) {
	fmt.Println("これはgit管理されたモジュールです。%sから呼ばれました", from)
}

用意したファイルをリポジトリにpush

~/multimodule/gitmodules/ $ git init
~/multimodule/gitmodules/ $ git remote add origin git@github.com:y-saiki1/gitmodules.git # 自分で作ったリポジトリ名にしてください
~/multimodule/gitmodules/ $ git add .
~/multimodule/gitmodules/ $ git commit -m 'first commit'
~/multimodule/gitmodules/ $ git push origin master

githubにpush後、~/multimoduleディレクトリで以下を実行して、pushしたGoプロジェクトをgo modに追加する

~/multimodule/ $ go get github.com/y-saiki1/gitmodules # 自分で生成したGithubリポジトリを参照してください

go getが終わったら、共通モジュールを使用するため~/multimodule/main.goファイルで共通モジュールを使用する

package main

import (
	// 以下追加(ここは自分のリポジトリに読み替えてください)
	"github.com/y-saiki1/gitmodules"
)

func main() {
	gitmodules.Run("Mainファイル")
}

実行

~/multimodule/ $ go run main.go
# 実行結果
これはgit管理されたモジュールです。Mainファイルから呼ばれました

PRIVATEリポジトリを使っている方は事前にgithub上にトークンを設定してからやらないと共通モジュールをgo getできないので注意です。
こちらの方の記事が参考になります
https://songmu.jp/riji/entry/2019-07-29-go-private-modules.html

Gitで共通モジュールを管理して使う場面

中くらいの規模感があるサービスであれば、モノリシックに作っていた環境から徐々にサービスを分割していくことがあると思いますので、その際に似たような処理が必要になる場面があるかと思います。
その時に同じ処理を作る、というのは渋いなぁといった時などに有効です。
例えばRDBへのReadアクセスのみ別サーバーから提供したい場合、Queryだけ叩ければいいのでスキーマは同一のものを使いたい、とかそんな感じ時に使用します。

新しく導入されたWorkspace機能

今までのGo共通モジュール管理の課題

管理方法として、PluginとGitの管理の2つがありましたが、多くの場面ではGitの管理方法が使われているかと思います。
その際に問題となるのは、共通モジュールを修正後、修正したものを一旦ローカルで組み込んでテストすることができない、に尽きると思います。
どういうことかというと、go.modでは依存パッケージの管理をしていますが、その対象パッケージをリリースタグベースでしか管理できないところです。

Git管理のセクションで取り扱ったgo.modを確認するとわかりますが、ハッシュ値が用いられています

module multi_modules

go 1.17

require github.com/y-saiki1/gitmodules v0.0.0-20220401161211-c7093e33520b

このv0.0.0-20220401161211-c7093e33520bを見るとわかるように、go getで取得するパッケージはバージョンを指定しないとデフォルトブランチの最新の状態のものを取得します。
多くの場合、リリースタグを切って管理されているかと思いますので、バージョン管理している場合はここがgithub.com/y-saiki1/gitmodules v1.0.1のようになります。

これの何が問題なのかというと

  1. Aプロジェクトで必要になった機能を、共通モジュール側に用意する。この際、変更前の共通モジュールはv1.0.0とします。
  2. 共通モジュールを修正したので、確認のためのテストを共通モジュール側に用意してテスト
  3. その後、共通モジュールのリポジトリにコミット
  4. コミットをレビューしてもらい、マージされる
  5. マージ後、リリースタグをv1.0.1で切る
  6. Aプロジェクト側で共通モジュールのバージョンをv1.0.1に上げる

という手順が必要になります。
素直な意見として、長い!長すぎる!レビュー待ちが発生してしまうのはこれは良くない、そう考えるのが普通です。
せめてローカルでもサクッと共有したいじゃないですか。でなければ開発効率が落ちてしまいます。
かと言って自分で勝手にマージしてリリースするわけにもいかないので、どうすればいいのか、それはreplaceディレクティブを使うことで解決します。(まだWorkspaceは出てきません。)
簡単に説明すると、これです。

replace github.com/y-saiki1/gitmodules v0.0.0-20220401161211-c7093e33520b => ../gitmodules

参照している共通モジュールをローカルのディレクトリに直結することができます。
これでレビュー待ちやむやみなリリースタグを切ることは無くなりました。
が、さらに問題が発生します。
go.modを編集しているため、go.modそのものの変更がコミットに紛れ込んでしまう可能性と、どの状態の共通モジュールを参照して作った機能なのかわからなくなるといった問題です。
チームで開発していると、Aプロジェクトのソースコードと共通モジュールを新しくいじったAさんの手元では動いているが、Bさんが新しく作った機能のほうでは動かない…なんてことが起こり得ます。
実際Aさんがローカルでの動作確認だけして、共通モジュールのコミット忘れ・レビュー待ちで止まっている状態で、Aプロジェクトの方のコミットはマージされてしまったということがあり得ます(これはちゃんとテストが稼働していればAプロジェクトはCI上のテストで共通モジュールの古いバージョンを参照するため、テストが落ちる=マージされないので防げますが)
またreplaceディレクティブを使うことで、その人のローカル環境のディレクトリ構造に依存しますので、もしディレクトリ構成が違えば、共通モジュールのパス先も変わりますので、このreplaceディレクティブはコミットすべきではないと思います。
そうなるとコミットにgo.modの変更が適切に行われているかレビューでしっかりみなければいけないので、大変です。

このことから、共通モジュールはreplaceディレクティブで手軽に管理できる一方、go.modの変更を手動で監視する必要があるので、課題となっていました。

Workspaceについて

Workspaceはgitで管理されている共通モジュールのimportをローカルのディレクトリに差し替えることが可能になります。
この機能はreplaceディレクティブと解決できるポイントは同じですが、go.modに何か手を加える必要がない点で違います。
go1.18のworkspaceの使い方についてはこちら
https://pkg.go.dev/cmd/go@master#hdr-Workspace_maintenance

事前用意

チュートリアルがあるのでしっかりみたい方はこちら
https://go.dev/doc/tutorial/workspaces

まず諸々の前準備をします。
Dockerでgo1.18の環境を整えるために、ディレクトリと必要なファイルを準備

bash
~/ $ mkdir workspace
~/ $ cd workspace
~/workspace $ 
~/workspace $ touch docker-compose.yml
~/workspace $ touch Dockerfile
~/workspace $ mkdir go-service # サービスコードを置くところ
~/workspace $ mkdir gopkg # 共通モジュール配置先
docker-compose.yml
version: '3.6'

services:
  workspace:
    build: 
      context: .
      dockerfile: ./Dockerfile
    volumes:
      - ./:/go/src
    tty: true
Dockerfile
FROM golang:1.18.0-alpine3.15
WORKDIR /go/src
RUN apk update && \
    apk add bash git
bash
~/workspace $ tree
.
├── Dockerfile
├── docker-compose.yml
├── gopkg
└── go-service
~/workspace $ docker compose up -d

Goのmodをそれぞれのディレクトリで設定していきます

bash
~/workspace $ docker compose exec workspace bash
# ↓ここからコンテナ内
/go/src $ cd go-service
# ↓公開するソースコードでなければ適当なmod名でおkです
/go/src/go-service $ go mod init go-service
/go/src/go-service $ cd ../
/go/src $ cd gopkg
# ↓公開するソースコードの方は以下の形式で。githubリポジトリはまだ作成しなくてもいいので、リポジトリ名と同じにしておいてください
/go/src/gopkg $ go mod init github.com/y-saiki1/gopkg

modの設定がおわったら、~/workspace/gopkg/run.goに適当にコードを書く

~/workspace/gopkg/run.go
package gopkg

import "fmt"

func Run() {
	fmt.Println("Hello From MY Go PKG")
}

次にgithubにリポジトリを作成し(省略)、gopkgディレクトリの中身をpushします。

bash
~/workspace/gopkg $ git init
# ↓自分で用意したリポジトリ名に替えてください
~/workspace/gopkg $ git remote add origin git@github.com:y-saiki1/gopkg.git
~/workspace/gopkg $ git add .
~/workspace/gopkg $ git commit -m 'first commit'
~/workspace/gopkg $ git push origin master

依存関係をmodに記載しておくために、go-service直下でgo get github.com/y-saiki1/gopkgします。

bash
~/workspace/ $ docker compose exec workspace bash
# ここからコンテナ内
/go/src/ $ cd go-service
/go/src/ $ go get github.com/y-saiki1/gopkg

ここまで用意できたら、次はgo-serviceからgopkgをworkspaceモードで使う方法を試します
まず、workspaceモードを有効にします。
workspace直下に複数のgoプロジェクトがあるので、workspace直下でworkspaceモードを有効にします。
コンテナ入るの面倒なので、docker composeで実行

bash
~/workspace/ $ docker compose exec workspace go work init ./go-service ./gopkg

するとworkspace直下にgo.workファイルが生成されます

go.work
go 1.18

use (
	./go-service1
	./gopkg
)

あとは簡単です、go-serviceのmain.goファイルでgopkgを使ってRunメソッドを呼び出しましょう

~/workspace/go-service/main.go
package main

import (
	// 以下追加(ここは自分のリポジトリに読み替えてください)
	"github.com/y-saiki1/gopkg"
)

func main() {
	gopkg.Run()
}

コンテナ入るの面倒なので、docker composeで実行

~/workspace/ $ docker compose exec workspace go run go-service
Hello From MY Go PKG

呼び出しに成功しました。
ただこれだと、go getで共通モジュールを取得したんだから、workspaceモードが動いているかしっかり確認できていません。
なので、次にgopkgのソースコードを修正します。

わかりやすくこんな感じで

~/workspace/gopkg/run.go
package gopkg

import "fmt"

func Run() {
	fmt.Println("共通モジュールからHello")
}
bash
~/workspace/ $ docker compose exec workspace go run go-service
共通モジュールからHello

リリースタグやreplaceディレクティブを使うことなく修正が反映できていますね。
workspace直下でgo runしていますが、go-service直下でmain.goファイルを呼び出しても同じ結果が出力されると思います

~/workspace/ $ docker compose exec workspace bash
# ここからコンテナ内
/go/src $ cd go-service
/go/src/go-service $ go run main.go
共通モジュールからHello

これでコミットの際にわざわざgo.modの変更を監視する必要性がなくなりました。

最後に

workspaceモードいいなぁと思ってAmplify-cliで使う想定してまとめてましたが、今のプロジェクトにも簡単に反映できそうなのでコミュニティの皆さんに感謝です。
あと、チュートリアルの最後の方に乗っていたんですが、これを使ったからといって共通モジュールのバージョン管理は引き続き必要です。
ローカルでサクッと動作確認ができるし、共通モジュールを自分のブランチにコミットして、チームメンバーがそのブランチをPullしてくれば動作できますが、ステージングや本番では共通モジュールをgo.modでバージョン管理すると思いますので、動作確認後しっかりリリースタグ切ったり、go get -uでバージョンを上げることを忘れないようにしましょう。

以上、参考になれば幸いです。

Discussion