ぽよメモ

レガシーシステム考古学専攻

go.workはmonorepoの夢を見るか

TL; DR

  • multi moduleなmonorepoだからgo.workを置こう、は間違い
    • 依存関係の管理を一緒にしてしまって問題ない物だけが一つのWorkspaceに共存できる
  • monorepoでも普通のリポジトリでも、go.workは一時的に外部依存をローカルにあるモジュールで上書きするときに使う
    • 一時的な上書きは開発者のローカル環境の事情が混入するため go.work はコミットすべきでない
  • goplsを動かしたいだけならVSCodeMulti-root Workspaceを使え

はじめに

これはあくあたん工房アドベントカレンダー 2022 およびGoアドベントカレンダーその3 5日目の記事です。他の日の記事も面白いので、是非読んでみてください。

Go 1.18では Workspace モードが導入され、 go.work というファイルで制御するということは比較的広く知られていると思います。最近monorepoで go.work が使えないか( go.workリポジトリにコミットして良いか)という相談を受けることが何度かあったためこの記事を書くことにしました。現時点での自分の理解と、調べた限りの情報を元にしています。間違っていたら指摘を下さい。

Workspace modeが解決したい課題

まず、Workspace modeの元となるプロポーザルが以下にあります。

go.googlesource.com

このプロポーザルには解決したい課題が書かれています。Backgroudのところです。

Users often want to make changes across multiple modules: for instance, to introduce a new interface in a package in one module along with a usage of that interface in another module. Normally, the go command recognizes a single “main” module the user can edit. Other modules are read-only and are loaded from the module cache. The replace directive is the exception: it allows users to replace the resolved version of a module with a working version on disk. But working with the replace directive can often be awkward: each module developer might have working versions at different locations on disk, so having the directive in a file that needs to be distributed with the module isn't a good fit for all use cases.

DeepL翻訳にかけた物も置いておきます。

例えば、あるモジュールのパッケージに新しいインターフェイスを導入し、別のモジュールでそのインターフェイスの使い方を変更したいなど、複数のモジュールにまたがって変更を加えたい場合がよくあります。通常、go コマンドは、ユーザが編集可能な単一の「メイン」モジュールを認識します。他のモジュールは読み込み専用で、モジュールキャッシュから読み込まれます。replace指示は例外で、解決されたモジュールのバージョンをディスク上の作業バージョンと置き換えることができます。しかし、replace ディレクティブの扱いはしばしば厄介です。各モジュールの開発者はディスク上の異なる場所に作業バージョンを持っているかもしれません。

workspace modeが導入されるまで、あるモジュールに変更を加えリリースするまでの間、それに依存する他のモジュールでは変更内容を試せないという問題の回避方法がreplaceするかリリースしてしまうかの二択しかありませんでした。
実験段階の物などはできればリリースしたくありませんし、そもそもローカルで気軽に試したいと考えるのは当然です。replaceはそのための有効な回避方法でしたが、プロポーザルに書かれているとおり各開発者のローカル環境の事情がgo.modに混入してしまうことが大きな課題でした。replaceで置き換えたのを忘れてcommitしてしまった経験のある方も多いのではないでしょうか。

Backgroudにはまだ続きがあり、こちらはgoplsに関する話題です。

gopls offers users a convenient way to make changes across modules without needing to manipulate replacements. When multiple modules are opened in a gopls workspace, it synthesizes a single go.mod file, called a supermodule that pulls in each of the modules being worked on. The supermodule results in a single build list allowing the tooling to surface changes made in a dependency module to a dependent module. But this means that gopls is building with a different set of versions than an invocation of the go command from the command line, potentially producing different results. Users would have a better experience if they could create a configuration that could be used by gopls as well as their direct invocations of cmd/go and other tools. See the Multi-project gopls workspaces document and proposal issues #37720 and #32394.

DeepL翻訳にかけたものが以下です。

gopls は、モジュール間の置換を操作することなく変更を行う便利な方法をユーザーに提供します。複数のモジュールが gopls のワークスペースで開かれると、作業中の各モジュールを取り込んだ supermodule と呼ばれる単一の go.mod ファイルが合成されます。スーパーモジュールの結果、単一のビルドリストができ、依存モジュールで行われた変更を依存モジュールに反映させるツールができます。しかし、これはgoplsがコマンドラインからのgoコマンドの呼び出しとは異なるバージョンのセットでビルドしていることを意味し、潜在的に異なる結果を生成します。cmd/go や他のツールを直接呼び出すのと同様に、gopls でも使用できるような設定を作成できれば、ユーザーはより良い経験を得ることができるでしょう。Multi-project gopls workspaces ドキュメントおよび提案課題 #37720 と #32394 を参照してください。

goplsではGo 1.18のWorkspace modeよりも前の時点から、multi moduleなprojectにおいても動作するように experimentalWorkpaceModule というフラグがあり、実験的にサポートしていました*1
しかしプロポーザルに書かれているとおり、これは本来のgoコマンドの挙動と異なる可能性がありました。具体的にはモジュールAがあるモジュールBのv1.0に依存し、別のモジュールCがモジュールBのv1.1に依存しているとします。このモジュールA、Cが存在するworkspaceにおいてgoplsはMinimal Version Selection(以下MVS)に従いモジュールBについてはv1.1を選択するという挙動になるはずです。しかしモジュールAはgo buildする際にはv1.0を使うので、v1.0では存在しなかった機能をgoplsが補完し、ユーザが取り込んでいるとビルドが壊れてしまいます。Go本体がworkspaceの機能を入れることで、これらの挙動を統一したいというわけです。

まとめると

  • replaceを使った依存の上書きは開発者のローカル環境の事情が混入してしまうおそれがある
  • 現状複数go.modがあるケースにおいてgoplsとgoコマンドの挙動に差異があり、ユーザを混乱させる恐れがある

これらの課題を解決するためにWorkspace modeが考案されています。

Workspace modeのユースケース

複数リポジトリにまたがる開発をする

具体的なストーリーをあげると↓のようなケースが考えられます。

  • 自分が開発しているモジュールAに入れた変更をリリースする前に、それを使う予定の他のモジュールBから試したい
  • フォークして修正したモジュールAを、PRを出す前に自身のモジュールBと組み合わせて試験したい
  • ……etc

以下の様なディレクトリ構成で開発をすることが考えられます。go.workはリポジトリの外側にあり、当然コミットすることはできません。最終的にmodule-a、module-bの完成版がリリースされ、それらのgo.modで正しく解決される必要があります。

.
├── go.work
├── module-a
│   ├── .git
│   └── go.mod
└── module-b
    ├── .git
    └── go.mod

go.workをリポジトリ内に置くこともできます。go.work から other-module を参照することができますが、このother-moduleの存在はローカル環境依存のものであるため go.work はコミットすべきではありません。

.
├── your-module
│   ├── .git
│   ├── go.mod
│   └── go.work
└── other-module
    ├── .git
    └── go.mod

experimentalWorkspaceModuleを置き換える

複数のgo.modを伴う開発において、goplsを experimentalWorkspaceModule: true で使っていたユーザはこの機能で置き換えができます。ただし、注意点が二つありgoplsが動くからという理由でサブディレクトリのgo.modを全て列挙した go.work を置くべきはなく、go.workリポジトリにコミットするべきでもありません。

  1. go buildの挙動が変わる
    • これまで各go.modのみで評価していた依存関係がworkspace全体を考慮するようになるため、MVSの仕様により選択される依存関係のバージョンが変わる可能性がある
  2. go.workをコミットしてしまうと外部依存を開発者のローカル環境のモジュールで置き換えるという本来の使い方がやりにくくなる
    • go.modにreplaceを書きたくないからgo.workというコミットしないファイルにその情報を逃がしたのに、go.workをコミットしてしまうと同じ問題が再び起き得る

1はこれまでgoplsの中では既にそうなっていましたが、Workspace modeにより実際に生成されるバイナリにまで影響が及ぶためです。そしてこの仕様が許容できるなら、全体で一つのgo.modを共有するやり方で問題ないことになります。
2は先述した「go.workリポジトリ内に置き、リポジトリ外のother-moduleを参照する」という場合と実質的に同じ事です。そのため go.work をコミットするということは開発者のローカル環境の事情を混入させるおそれがあります。

monorepoとWorkspace mode

single moduleの場合

これはそのリポジトリにただ一つのgo.modをおいて全体でそれを共有する方式です。この場合、プロジェクトのルート直下にgo.modがないとgoplsが動作しない問題があります。ただしこれはmonorepoでなくとも起き得るため、特にmonorepoに特異な問題ではありません。

そして自明ですが、これはWorkspace modeで解消したかった課題ではありません。副次的に go.workリポジトリルートに置くことでgoplsが動作する可能性がありますが、本来の意図とは異なることに注意が必要です。各開発者が外部依存の上書きを可能にするため、go.workはコミットせずignoreしておくのが無難です。

multi moduleな場合

一つのリポジトリ内に複数のgo.modが存在する方式です。この場合、各go.modごとに依存関係が管理されています。

まずreplaceの問題について考えてみます。元々のプロポーザルでは、replaceを使うことで開発者のローカル環境に依存した変更が入ってしまうことが問題としてあげられていました。しかし、multi moduleなmonorepoの中だけで言えば各モジュールの位置は開発者間で差異はなく、replaceを使っても問題ないと考えられます*2。monorepoの中から外部の依存関係を参照しており、それを一時的にreplaceするという使い方においてWorkspaceは有用です。これはmonorepoでないリポジトリでの開発と考え方は同じで、go.work には開発者のローカル環境の事情が紛れ込んでしまうためコミットするべきではありません。

次にgoplsの問題について考えてみます。multi moduleなmonorepoを選択したことにはおそらく様々な事情があるはずです。

  • 元々複数のリポジトリだったものをmonorepoに集約する過程で生じた
  • モジュールごとに個別に依存関係のバージョンを管理する必要があった
  • 複数の開発が並行することでgo.modやgo.sumが頻繁にコンフリクトするのを避けたかった
  • ……etc

特に個別で依存関係の管理ができなければならない、という要件は見過ごされがちです。この仕様がある場合、何も考えず全てのモジュールを列挙した go.work ファイルを置くと意図せず依存関係を変更してしまう可能性があります。よって、そもそも現在既に依存関係にあるもの同士しか go.work に書かれるべきではありません。

goplsのためのワークアラウンド

「個別に依存関係を管理しなければならない」という複数go.modを持つプロジェクトでは、これまで書いた通り go.work を置いて解決する方法がとれません。それでもgoplsを動かしたい場合に採用できる方法を2つ紹介します*3

go.modのあるディレクトリにcdしてそこでエディタを開く

最も単純なワークアラウンドです。ただし開きたいモジュールの数だけエディタを開く必要がある他、それより上位のディレクトリにユーティリティ(Makefileなど)がある場合、それを使うのが面倒になるというデメリットが存在します。

エディタのMulti-root Workspaceを利用する

これは以前からサブディレクトリに go.mod がある場合のワークアラウンドとして知られています。
VSCodeにはWorkspace機能があり、一つのウィンドウ内で複数のプロジェクトを開けるようになっています((この設定は *.code-workspace というファイルに保存することができます。開発者のローカル環境の事情を汲んだファイルであることから共有する類いのファイルではありません。))。これを使うとあるプロジェクトの複数のサブディレクトリを、あたかも別プロジェクトのようにworkspaceに追加してそこをルートとして扱うことができます。これによりgoplsが動作するようになります。この方法はgoplsが公式に紹介しています。

https://github.com/golang/tools/blob/master/gopls/doc/workspace.md#multiple-workspace-folders

その他のエディタではプラグインがサポートしている場合があるので、以下のドキュメントを参照してください。

まとめ

  • go.workに書かれたモジュールは依存関係を共有してしまうため、既に依存関係にあるモジュール以外を書くべきではない
  • monorepoであるかないかに関わらず、go.work は一時的な外部依存の上書きに使用する際に有用である
    • 一時的な上書きは開発者のローカル環境の事情が混入するため go.work はコミットすべきでない
  • プロジェクトのルートにgo.modがなく、goplsを動かしたいケースではエディタの機能が現状では有用である

go.work の導入に悩んでいる方の参考になれば幸いです。


*1:このフラグは先日のgopls v0.10.0からdeprecatedになり、Go本体のWorkspace modeに統一される流れになりました。

github.com

*2:厳密に言えばモジュールの位置を変更したり削除するなどによってreplaceの指すパスが無効になってしまうことはありますが、それはworkspace modeでも解消できません。

*3:なお筆者はGoLandをもう2年以上使用していないため、JetBrainsにおける事情は分かりません