本記事の背景と目的
go言語のWorkspace modeを導入した際に生成されるgo.work
ファイルは,リポジトリにコミットすべきでないとの指摘がある[1][2].この点についてProposalでも盛んに議論されており,公式リファレンスではgo.work
をリポジトリにコミットしないよう推奨されている.
本記事では,go.work
をリポジトリにコミットすると困りうるパターンを検証し,go.work
をコミットすべきでない理由を明らかにする.
Workspace modeとは
Go言語のWorkspace modeとは,Go 1.18以降で利用可能なマルチモジュール開発を支援する機能である.開発するモジュールから外部のモジュールを取り込むとき,通常はGitHub等で既に公開されているモジュールをgo install
などを使って取り込む必要がある.
一方,複数モジュールを同時開発し,それらが依存関係にある場合,公開前でローカル上にある開発中モジュールを使ってテストしたい場合がある.このとき,go.mod
のreplaceディレクティブでモジュールの向き先を一時的にローカルファイルに振り向けることができるが,go.mod
ファイルを汚してしまう懸念がある.これに対してWorkspace modeを使うことで,go.mod
を汚さずに同様のことが実現できる.このWorkspace modeを設定する際に生成されるのがgo.work
ファイルであるが,その設定方法はチュートリアルを参照されたい.
Workspace modeとmonorepo
複数のモジュールやサービスを単一のリポジトリで管理するmonorepoを採用する場合,Workspace modeを利用するケースが考えられる.類似の仕組みとして,Yarn Workspacesがある.workspaceを用いることで,monorepo上の異なるモジュール同士がシームレスに行き来できる.
また,Go言語のLanguageServerであるgoplsを動かし,エディタで補完や定義のジャンプ機能を使いたい場合,プロジェクトのルート配下にgo.mod
ファイルが必要になる.monorepo構成の場合は,各サブディレクトリ内にgo.mod
が配置されるため,goplsが機能せず困る.一方で,Workspace modeを設定し,ルート配下にgo.work
が存在すればgoplsは機能する.(Workspace mode以外にも,VS CodeのMulti-root Workspacesを使う方法もある.)
monorepoにおいてWorkspace modeはメリットがあり,go.work
をリポジトリにコミットして開発者間で共用したくなるが,そうすべきか否かはIssueや先述のProposalで議論を呼んでいる.
go.work
ファイルをコミットすべきでない理由
ここで,公式リファレンスで示されているgo.work
をコミットすべきでない理由を下記に引用する.
- A checked-in go.work file might override a developer’s own go.work file from a parent directory, causing confusion when their use directives don’t apply.
- A checked-in go.work file may cause a continuous integration (CI) system to select and thus test the wrong versions of a module’s dependencies. CI systems should generally not be allowed to use the go.work file so that they can test the behavior of the module as it would be used when required by other modules, where a go.work file within the module has no effect.
DeepLによる翻訳も掲載する.
- チェックインされた go.work ファイルは、親ディレクトリにある開発者自身の go.work ファイルを上書きするかもしれません。
- チェックインされた go.work ファイルは、 継続的インテグレーション (CI) システムで、 モジュールの依存関係を間違ったバージョンでテストしてしまうかもしれません。CI システムは一般的に、go.work ファイルを使うことを許可されるべきではなく、モジュール内の go.work ファイルが影響を及ぼさない、他のモジュールから要求されたときに使われるようなモジュールの挙動をテストすることができます。
本記事では特に2点目について,具体的にどういったケースで困るのかを検証する.
go.work
ファイルにおけるモジュールの依存関係への影響
検証内容と結果
例えば下記のようなmonorepo構成を想定する.monorepo-hoge
にはmodule-huga
とmodule-piyo
の2つのモジュールが含まれているが,これらに明確な依存関係はない.
monorepo-hoge/
├── .git
├── go.work # checked-in go.work file
├── module-huga/
│ ├── go.mod
│ ├── go.sum
│ └── main.go
└── module-piyo/
├── go.mod
├── go.sum
└── main.go
module-huga
は,インターネット上に公開されている或るモジュール(例えば github.com/samber/lo v1.46.0 )を取り込み,module-piyo
では同モジュールの別バージョン(github.com/samber/lo v1.47.0)を取り込むものとする.この場合に,それぞれのgo.mod
は下記のようになる.
module monorepo-hoge/module-huga
go 1.23.0
require github.com/samber/lo v1.46.0
require golang.org/x/text v0.16.0 // indirect
module monorepo-hoge/module-piyo
go 1.23.0
require github.com/samber/lo v1.47.0
require golang.org/x/text v0.16.0 // indirect
今回は検証用に,取り込んでいるモジュールのバージョンを出力するコードを各々のmain.go
に記述する.(参考元)
package main
import (
"fmt"
"log"
"runtime/debug"
"github.com/samber/lo"
)
func main() {
bi, ok := debug.ReadBuildInfo()
if !ok {
log.Printf("Failed to read build info")
return
}
for _, dep := range bi.Deps {
fmt.Printf("Dep: %+v\n", dep)
}
}
これらを実行すると,いずれも下記のような実行結果が得られる.module-huga
においては,github.com/samber/lo
の古いバージョン(v1.46.0)をgo.mod
で指定しているにもかかわらず,新しい方(v1.47.0)の実行結果が得られる.もちろん,go.work
ファイルを削除すれば,go.mod
で指定した通りのバージョンの実行結果が得られる.
Dep: &{Path:github.com/samber/lo Version:v1.47.0 Sum:h1:z7RynLwP5nbyRscyvcD043DWYoOcYRv3mV8lBeqOCLc= Replace:<nil>}
Dep: &{Path:golang.org/x/text Version:v0.16.0 Sum:h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= Replace:<nil>}
通常Go言語では,go.mod
に記載される別々のモジュール同士が,間接的に同一のモジュールに依存するがそのバージョンが異なる場合,より最新のバージョンのモジュールが取り込まれる.これについてはMajor Version Suffixの文脈で語られることがあるが,詳細はこちらのページで図解入り解説があるため参考にされたい.
Workspace modeにおいても同様の考え方があるようで,workspace内の各モジュールがgo.mod
で同一モジュールを参照し,そのバージョンが異なる場合に新しい方のバージョンが採用されること分かる.
go.work
をコミットすることで生じうる懸念点
上記のような状態でgo.work
をコミットして困るケースとして,リポジトリの一部だけcloneするsparse-checkoutを使う場合が考えられる.巨大なmonorepoの開発においてリポジトリ全体をcloneするのは非効率な場合があり,sparse-checkoutを使いたくなる.上記で検証したように,go.work
ファイルの有無によって依存モジュールのバージョンが変わってしまう状況で,リポジトリ全体をgo.work
を含めてcloneした場合と,sparse-checkoutを使ってgo.work
を含めずcloneした場合で,モジュールの実行結果が変わってくる可能性がある.従って,開発・テスト・ビルドの各フェーズでclone範囲を一貫させる必要性が生じる.
また,リポジトリの或るモジュールで先行してgo.mod
のバージョンアップ対応を行った場合,workspace内の別のモジュールでも意図せずバージョンが上がってしまう可能性がある.monorepoにおけるテストの差分実行を取り入れている場合は,暗黙的な依存モジュールのバージョンアップがテストされないまま放置される懸念もある.何より,go.mod
に記述されている依存モジュールのバージョンと,実際に使われるバージョンが異なる状況は混乱を生む.
go.work
ファイルのベターな運用方法
こちらの記事に述べられていることが基本であると考えられる.要約すると下記のようになる.
-
go.work
ファイルは原則コミットすべきでなく,.gitignore
に入れる - Workspace modeの利用は開発者で閉じたテスト用途にとどめ,開発が完了したら通常どおり
go.mod
ファイルを使って依存モジュールを取り込む - (どうしても
go.work
ファイルをリポジトリで共有したい場合や,そうでない場合も)意図しないgo.work
の混入に備えて,CI/CD環境やビルドに使う環境では,GOWORK=off
を設定する
【余談】モジュールの公開直後にgo.mod
で古いバージョンのモジュールが取得される場合
上記のベターな運用方法にもとづき,go.work
でローカル上のテストをした後,そのモジュールを公開しgo.mod
を使って本番用に依存モジュールを取り込む際に,go install
でモジュール取得エラーとなる,或いは古いバージョンが取得される場合がある.この場合,キャッシュが悪さをしている可能性があるので,go clean --modcache
コマンドを試すとよい.
まとめ
本記事では,Go言語の公式リファレンスに示されるgo.work
ファイルをリポジトリにコミットしない推奨事項をもとに,go.work
有無がgo.mod
にどう影響するかを検証し,go.work
をコミットすることで生じうる懸念点を挙げた.
複数モジュールを管理するmonorepo構成においてWorkspace modeは有効で,go.work
をリポジトリにコミットするユースケースも考えうるが,monorepo内の各go.mod
同士が作用し,実際に記載されるバージョンと異なるモジュールが取り込まれる可能性がある.そのため,go.work
は原則リポジトリへのコミットを避け,開発・テスト用途にとどめたい.
go.work
ファイルを共有することが有効であると判断した場合においても,Workspace mode有効状態のコードが本番環境まで流れないよう,CI/CD環境下ではGOWORK=off
を設定することが望ましい.