go get の動作メモ (その2)

go get した時に出てくるバージョンについて調べたことのメモ書き

TL;DR

  • モジュール有効下の go get で取得できるバージョンは、さまざまな条件によって異なる場合がある
    • 特にproxyを利用したときと、利用しない時で取得できるバージョンが異なる場合がある
    • それらを詳しく調べた
  • proxy の /@v/list のバージョン選定アルゴリズムを推測した

随記

本調査の問題意識は「モジュール有効下で単にgo getした時に出てくるバージョンはどう決まっているのか」である。

発端は mattn/jvgrep (以下jvgrep) で普通に go get github.com/mattn/jvgrep したときに出てくるバージョンが v5.8.1+incompatible だったことから。 なおこの時点で v5.8.5 がリリースされていた。

go 自体のバージョンは調査時点は 1.14.4 であった。

モジュール有効下時の go get (以下単に go get とする) は、実行時の環境の差異によってその振る舞いを微妙に変える。

例えば go.mod が特定できないディレクトリ、すなわちルートに至るまでいかなる親にも go.mod が存在しない場合、 @upgrade, @latest, @patch の仮想バージョン指定は意味をなさず、すべて同じ動作になる。これらの動作の詳細は説明しない。

これらの動作は現在インストール済みのモジュールのバージョンに依存するのだが go get はそのソースを go.mod に依存しているため、なければ全部同じで「最新のモジュールを取り出す」にならざるをえない。

以下では go.mod が存在しない場合を想定している。

今回フォーカスしているのは go get が「最新のモジュール」のバージョンをどのように決定しているかである。

知っての通り go get は通常ならば proxy.golang.org を介してモジュールを取得する。

proxy の説明は go help goproxy で参照できるが、 いま知っておくべきなのはバージョン一覧を取得する https://proxy.golang.org/{モジュール名}/@v/list (以下 /@v/list) と最新バージョンを取得する https://proxy.golang.org/{モジュール名}/@latest (以下 /@latest) だけである。 なおモジュール名には具体的には github.com/mattn/jvgrep みたいな文字列が入ることになる。

ここで1つ疑問が増えるわけだが jvgrep の /@latestv4.9.0+incompatible になっている。 go get で取れるのは v5.8.1+incompatible なのでこの差異がどこから来るのか。

go のソースコードを読んで、時に書き換えて調べてみた。

まず v5.8.1+incompatible のほうだが、これは /@v/list から semver で最新版を決定していた。 /@latest は一切かかわっていない。

次に proxy を使わずに直接 go get した場合は v4.9.0+incompatible が出てきた。 なお proxy を使わない場合の取得コマンドは次の通り。

$ GOPROXY=direct GO111MODULE=on go get github.com/mattn/jvgrep

以降 go がバージョンを取り扱うとき3種類のバージョンが存在することを意識する必要がある。

1つは VCS に付けられるタグで v1.2.3 のように semver に従ってるやつのこと。 go はコレを他のバージョンのソースにしているが、そのまま使わないことも多い。 むしろこのことが混乱を招いていると思える。 これをA系といおう。

2つめはメジャーバージョンで v0, v1, v2, … といった感じ。 これはタグによるバージョンから決まる。 また v0 と v1 は同じモジュール名として認識されるよう特別扱いされている。 これをB系としよう。

なお v2 以降のメジャーバージョンは v2 and Beyond に示されるように、 モジュール名を /v2 付きに変える必要がある。 なおこのあたりを調べているときに気が付いたのが v8 (有名なJavaScriptエンジン)のようなレポジトリ名は go では正しく扱えないことが判明した。 参考: https://github.com/golang/go/issues/28435#issuecomment-440324231

3つめは v5.8.1+incompatible や v0.0.0-{commitid}-{datetime} のような go が管理目的で便宜的に生成したバージョン名。 go.mod などの中でよく目にする形式である。 これはC系とする。

最新バージョンの決定の話に戻ろう。

最新バージョンの決定は VCS 毎にアルゴリズムが異なってしまう可能性があるのだが、 今回は github なので git に限定する。

この場合は複雑なのだが、基本的にはタグの一覧 (refs/tags/*) に依存する。 このタグは当然A系。

A系の内、B系に変換して v0 および v1 に属する最新バージョン、そこに go.mod が存在した場合は最新バージョンの選定に使われるタグは v0 および v1 のものに限定される。 (codrepo.go#L208-L226)

C系バージョンにおいて +incompatible が付く条件は、A系のうち v0 および v1 に属さず go.mod がないやつ。 つまり go.mod がないならば v2.0.0 (A) 以降は常に +incompatible だ。

v0 および v1 に go.mod が追加されたら全ての +incompatible (C)よりも v1 の compatible (無印) が優先される。

具体的にいうと、仮に jvgrep に v1.10.0 として正しい go.mod を置いたならば GOPROXY=direct GO111MODULE=on go get github.com/mattn/jvgrep で出てくるのは v4.9.0+incompatible ではなく v1.10.0 になるだろう。

このあたりはモジュールとしての利用において不用意なメジャーアップグレードを避ける目的で納得度が高い。 しかしツールとしての利用においてはやや不満がある。

ここまでで最新バージョンを決定できない場合 +incompatible の中から選定される。 この 選定アルゴリズム は一見ややこしい。 だが言葉にすると意外と簡単、「go.mod が存在するメジャーバージョンより前のバージョンの最新」だ。

jvgrep で具体例をみてみよう。 jvgrep は v5.8.2 (A)から go.mod を含むようになった。 そのため v5 (B) よりも古い中で一番新しいものが最新バージョンとなる。 一覧をみると それが v4.9.0+incompatible (C) だということがわかる。

おっと忘れていたがソースコードで示すとココで昇順にソートされたバージョン一覧を取得して、 コッチでその末尾を取るため最新のバージョンになる。 この周辺ではバージョン選定における束縛を実装しているので、興味がある人は読んでみると良い。

長くなったが GOPROXY=direct GO111MODULE=on go get github.com/mattn/jvgrep で出てくるのは v4.9.0+incompatible ということだ。

推測ではあるが先にあげたこの疑問の答えはコレだろう。proxy の /@latest はこのアルゴリズムによって決定されているのだろう。

ここで1つ疑問が増えるわけだが jvgrep の /@latest は v4.9.0+incompatible になっている。 go get で取れるのは v5.8.1+incompatible なのでこの差異がどこから来るのか。

一方で /@v/list のアルゴリズムはわからない。 残念ながら proxy.golang.org のソースコードは非公開であるため推測する以外の方法がない。 該当部分だけパッケージとして切り出されている可能性はある。 知っていたら教えて欲しい。

ただおそらく v0 および v1 に属するやつ全部、それ以外でも go.mod のないやつは全部、ということは推測に難くない。

つまり GOPROXY=direct GO111MODULE=on go get のバージョン候補の選定と、proxy の /@v/list の選定アルゴリズムは異なってる。 これも混乱の一要因であるといえよう。

なお go.mod ありの v2 (A) 以降は、モジュール名を変えるしかないのでこれは諦めてほしい。 その際にレポジトリ内にフォルダを掘るか go.mod の module を修正するにとどめるかは各自の選択となっている。

ややこしく長い話になってしまいましたが、ここまで真面目に読んでいただいた方、ご苦労さま&ありがとうございます。