go moduleのreplaceでハマったこと

3 min read読了の目安(約3400字

初めに

仕事でGoを使ってマイクロサービスシステムを作っていて、次のようにgo.modをプロジェクトごと分けるようにしています。
プロジェクト共通で使用するような機能、たとえばロガーなども同様に分けていますが、この際は通常にimportしているのではなくreplaceを使うようにしています。

.
├── logger
│   └── go.mod
├── utils
│   └── go.mod
├── service1
│   ├── go.mod
│   └── ...
├── service2
│   ├── go.mod
│   └── ...
└── ...

このようにreplaceを使って開発を進めていきましたが、ちょっとした問題に遭遇してしまいました。
この記事はその問題の解決方法の紹介と、もっと良いやり方ないかについて考察していきます。

問題

複数のプロジェクトでreplaceを使っていくと、気付かないうちにreplace先のプロジェクトもreplaceをしていたことがありました。
たとえばs3 => s2 => s1というように、s3s2replaceしていて、s2s1replaceしている、というケースです。
この場合、s3では次のようにビルドエラーになります。

[I] s3 ) go run main.go
go: github.com/skanehira/test/mono/s2@v0.0.0 requires
        github.com/skanehira/test/mono/s1@v0.0.0: missing go.sum entry; to add it:
        go mod download github.com/skanehira/test/mono/s1

s1がないよ、と怒られています。
replaceはメインモジュール(この場合はs3のgo.mod)のみ適用されるため、依存先(この場合s2)のreplaceは適用されず、ローカルにあるs1が見つからなかったためです。

この仕様はこちらに記載されています。

replace directives only apply in the main module's go.mod file and are ignored in other modules.

対策

このような構成になっている場合は、s3でもs1replaceを書いてあげることでエラーを回避できます。

// s3のgo.mod
replace github.com/skanehira/test/mono/s1 => ../s1
[I] s3 ) go run main.go
var1

go moduleのしくみ

go moduleはMVS(Minimal version selection)というアルゴリズムを使って、依存モジュールのバージョンを決定しています。
このMVSは、ビルドする上で最小バージョンを選択するようになっています。

以下、公式の例をもとに簡単に説明します。
Mainが依存しているA1.2B1.2はそれぞれC1.3C1.4を依存していますが、選択されるのはC1.4になります。
バージョンが高い方(C1.4)が選ばれているのは互換性のことを考慮しているからだと思われます。B1.2C1.3だと動かないけどB1.4は確実に動く、A1.2C1.3 or C1.4のどちらでも動くはず、という感じかと思います。

Goはセマンティック バージョニングを採用しているので、
ご存じがない方は一度読んでみると上記の説明につながると思います。

replaceの場合は、単純に指定されたモジュールを使うようになります。
以下、公式の例ではRを選択することになります。

考察

なぜメインモジュールのreplaceのみ適用可能なのか

go moduleはさきほど説明した通り、モジュールのバージョンを選択するアルゴリズムになっています。
しかしreplaceを使うとバージョンが無視されるため、replace先のreplaceも許容してしまうとバージョンを決定できないケースが出てきます。決定できる/できない場合をそれぞれ次のケースがあると思います。

  • 決定できるケース
      A => B => C
    
  • 決定できないケース
      A => B, C => D
    

決定できないケースはreplace先のBCが並列でDreplaceしていた場合、どちらのDを選択すればよいのか決定できない、という問題が置きます。本来ならバージョンを見て決定できるがreplaceはバージョンを無視するのでこのような問題が置きます。
逆に並列ではなく直列でreplaceしている場合は決定できます。

このように、以下の理由でメインモジュールのみreplaceを適用という仕様になっているのではないかと思われます。

  • 決定できないケースがある
  • 特定ケースのみreplace可能は混乱を招きやすい

replaceを使う場面

今回の問題を振り返り、そもそもreplace先がreplaceしているという構成自体が良くないのではないかと思いました。
ですので、replaceされるもしくは可能性があるモジュールはreplaceを使わないようにする構成を検討する、というのが根本的な対策かなと思っています。
具体的にどのような構成が良いかは答えがないので、この場では考察はしないんですが、普通に開発していたらreplace先がreplaceしていることはまず起きないと思います。

ただ、replaceを使わないと行けない場面もあるかと思います。ぱっと思い付くのは以下のケースです。

  1. 非公開モジュールを使う
  2. forkして改修したモジュールを使う

1はGitHubに載せているprivateなリポジトリを使う場合ですが、これはgitの設定を変えればある程度解消されると思います。
詳細はGitHubのprivate repositoryを含んだ場合のGo Modules管理を参照してください。
しかし、モジュールのキャッシュがすぐに更新されるわけではないので、開発が激しい場合はキャッシュを更新するしくみを取り入れる必要があるかと思います。
ちなみに、問題が起きたプロジェクトでreplaceを使ったのはキャッシュの更新と設定が面倒という理由が大きかったです。

2はプロジェクトで必要だけど、修正がなかなか公式に取り込まれない場合、使うことがあるかと思います。
この場合、だいたいライブラリで使われる側なのでreplaceしても今回のような問題が起きることは基本的にないと思います。

最後に

replace先がreplaceしているという変な構成にしてしまったことで、今回のような問題が起きましたがおかげさまでいろいろ学ぶことができました。
気軽にreplaceを使って良い場合と、考慮しないといけない場合はあるのでみなさんも使う際は意識してみると良いかもしれません。