ぽよメモ

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

go.modについての陥りやすい誤解

はじめに

 これはあくあたん工房アドベントカレンダー 2021 11日目の記事です。
 ポエムを書いていたら気分が暗くなったので、消して自分の過去のメモを記事にすることにしました。そんな解釈するやつおらへんやろwwと是非笑って読んでください。

 2023-09-19追記:Go 1.21からいくつか挙動に変更が入ったので加筆しています。これまでの内容には触らずそのままに、1.21以降における挙動を追記しているのでご注意ください。

go.modにおけるGoのバージョン指定

注意:この節ではGo 1.21未満における挙動について解説します。1.21以降では異なる挙動になります

go.modには go 1.17 のように、Goのバージョンを指定するディレクティブがあります。

  • 陥りやすい誤解:指定したバージョンでのみビルドする。
  • 実際の挙動:指定したバージョンまでの言語機能のみが利用できる。

https://golang.org/ref/mod#go-mod-file-go

the go directive still affects use of new language features

go 1.15 と書いた場合にでもGo 1.17でビルドすることは出来ます。ただしGo 1.17や1.16で入った新しい言語機能を使うことは出来ません。上のページにも下記の様な例が掲載されています。

For example, if a module has the directive go 1.12, its packages may not use numeric literals like 1_000_000, which were introduced in Go 1.13.

依存先のgoディレクティブの方が古いバージョンを指す場合

以下のGoを使ってビルドします。

❯ go version
go version go1.17.5 darwin/arm64

こんな感じでファイルを用意しました。

.
├── A
│   ├── go.mod
│   └── hello.go
├── B
│   ├── go.mod
│   └── hello.go
├── go.mod
└── main.go

ルート直下のgo.modでA・Bをそれぞれrequire && replaceしています

module main-module

go 1.17

require (
    A v0.0.0
    B v0.0.0
)

replace A => ./A
replace B => ./B

各モジュールA・BはHelloという関数を持っており、自身のモジュール名をしゃべります(↓はモジュールAの場合)。

package A

import "fmt"

func Hello() {
    fmt.Println("This is module A.")
}

ルート直下のmain.goでそれらの関数を呼びます。

package main

import (
    "A"
    "B"
)

func main() {
    A.Hello()
    B.Hello()
}

ただし、go.modで指定するgoディレクティブをそれぞれ以下の様に設定します。

  • モジュールA:go 1.17
  • モジュールB:go 1.12
  • ルート直下のgo.mod:go 1.17
--- A/go.mod 2021-12-11 09:35:59.000000000 +0900
+++ B/go.mod  2021-12-11 09:36:05.000000000 +0900
@@ -1,3 +1,3 @@
-module A
+module B

-go 1.17
+go 1.12

この状態でgo run main.goすると、正しくビルドされ実行できます。

❯ go run ./main.go
This is module A.
This is module B.

また、go 1.17を指定しているモジュールAやmainモジュールでは、Go 1.17までの言語機能を全て使うことが出来ます。ただしmodule B内でGo 1.12よりも新しい言語機能を使うことは出来ません。ここではリファレンスに掲載されていた例にならい、1_000_000 というリテラルを使ってみることにします。

package B

import "fmt"

func Hello() {
    fmt.Println("This is module B.")
    // Go 1.13以上でしか使えないリテラル
    fmt.Println(1_000_000)
}
❯ go run ./main.go
# B
B/hello.go:8:17: underscores in numeric literals requires go1.13 or later (-lang was set to go1.12; check go.mod)

依存先のgoディレクティブの方が新しいバージョンを指す場合

例えば以下の様な場合です。

  • モジュールA:go 1.17
  • モジュールB:go 1.12
  • ルート直下のgo.mod:go 1.12

普通にビルド・実行ができます。

❯ go run ./main.go
This is module A.
This is module B.

ここで、モジュールAでGo 1.12では使えない機能を使ってみます。

package A

import "fmt"

func Hello() {
    fmt.Println("This is module A.")
    fmt.Println(1_000_000)
}

これもまたビルド・実行することができます。

❯ go run ./main.go
This is module A.
1000000
This is module B.

ただし、main.goの中でGo 1.12よりも新しい機能を直接使うことは出来ません。

package main

import (
    "A"
    "B"
    "fmt"
)

func main() {
    A.Hello()
    B.Hello()
    // これを足す
    fmt.Println(1_000_000)
}
❯ go run ./main.go
# command-line-arguments
./main.go:13:17: underscores in numeric literals requires go1.13 or later (-lang was set to go1.12; check go.mod)

goのバージョンよりgoディレクティブが先行する場合

ここまでは全てGo 1.17.5でビルドしてきましたが、試しにGo 1.16くらいでビルドしてみます。

❯ go version
go version go1.16.12 darwin/arm64
  • モジュールA:go 1.17
  • モジュールB:go 1.12
  • ルート直下のgo.mod:go 1.17

を指定していてかつGo 1.17以降でのみ使えるような機能を含まない場合。

❯ go run ./main.go
This is module A.
This is module B.

このときGo 1.17でしか使えないような機能を使うとビルドに失敗します。例えばGo1.17でmathモジュールに追加された MaxInt を参照するとエラーになります。

❯ go run ./main.go
# command-line-arguments
./main.go:13:17: undefined: math.MaxInt
note: module requires Go 1.17

goディレクティブまとめ

  • あるモジュール内ではgoディレクティブで指定したバージョンまでの言語機能が使える。
  • 自身より先行するgoディレクティブを指定するモジュールを使える(1.21未満の場合)
    • ただし最初の制約により、自身の中では自身の指定したバージョンまでの機能しか使えない。
  • 自身より小さいバージョンを指定するモジュールも使える。
    • ただし最初の制約により、そのモジュールの中ではそのモジュールの指定したバージョンまでの機能しか使えない。
  • goバージョンよりgoディレクティブが先行していても、そのgoバージョンまでで使用できる機能だけで構成されていればビルドできる。

実際にはもっと細かい違いなどがあるはずなので、これ以上は公式のリファレンスを読みましょう。

https://go.dev/ref/mod#go-mod-file-go

1.21以降のgo.modにおけるGoのバージョン指定

Go 1.21のリリースノートで言及されていました。挙動が以下の様に変更されています。

  • 陥りやすい誤解:指定したバージョンでのみビルドする。
  • 実際の挙動:指定したバージョン以降でのみビルド可能

Go 1.21 Release Notes - The Go Programming Language

Go 1.21 now reads the go line in a go.work or go.mod file as a strict minimum requirement: go 1.21.0 means that the workspace or module cannot be used with Go 1.20 or with Go 1.21rc1.

これはGo 1.21以降のみに適用されるため、例えば go 1.21 と書かれた(ただしgo 1.21の新機能を含まない)goモジュールをgo 1.20.xでビルドすることは可能です。

require時のバージョンの指定

require ディレクティブでバージョンを指定できます。go get モジュールパス@バージョン などとすると指定のバージョンに固定できます。

  • 陥りやすい誤解:このモジュールはそのバージョン以外は許容しない。
  • 実際の挙動:そのバージョン以上、次のメジャーバージョン未満を許容する。

例えば require A v1.0.0 とすると、このAの取り得るバージョンの範囲はv1.0.0からv2.0.0未満となります。ただし勝手に変わるわけではなく、他の依存先が他のAのバージョンに依存しているわけでないならv1.0.0が利用されます。詳しくは次節で説明します。

この挙動は同じメジャーバージョンの間で完全な後方互換性が保たれることが前提となっています。自身でモジュールを作る時は、この点に十分配慮する必要があります。

Minimal version selection

Goがモジュールのバージョンを選択する際のアルゴリズムです。

  • 陥りやすい誤解:そのモジュールの一番小さいバージョンを使う*1
  • 実際の挙動:依存を全部考慮したときに要件を満たす最小バージョンを選ぶ

以下に公式の仕様があります。

https://golang.org/ref/mod#minimal-version-selection

つまり、あるモジュールAについてモジュールBには require A v1.1.0、モジュールCには require A v1.3.0 という制約があったとき、BとCを同時に利用しAのバージョンを明示しない場合はv1.3.0が利用されます。また、BとCを同時に利用しかつrequire A v1.1.0 などとより低いバージョンを明示しても、v1.3.0が利用されます。

バージョンを完全に固定したい場合は replace を利用することになります。例えばv1.1.0 に強制的に固定したい場合は replace A => A v1.1.0 とします。これは利用するモジュール全てに影響し、強制的にv1.1.0を使うことになります*2

v2などメジャーバージョンが変わる場合はインポートするモジュールパスが異なる、という制約があるため、同時にrequireに指定して使うことが出来るはずです*3

モジュールのバージョン

  • 陥りやすい誤解:セマンティックバージョニングに従うタグでしか指定できない
  • 実際の挙動:特定のコミットハッシュを指すことができる

go.modの仕様には疑似バージョン(Pseudo version)というものが記載されています。
https://go.dev/ref/mod#pseudo-versions

これは特定のコミットハッシュからセマンティックバージョンに対応したバージョン文字列に変換するための仕様です。例えば v0.0.0-20191109021931-daa7c04131f5 のようなものです。つまり

<既にあるバージョン+1>-<commitの日時>-<12桁のコミットハッシュ>

というバージョンが構成されます。既にあるバージョン+1とは、vX.Y.Z が既にあり指しているコミットハッシュがそれよりも新しい場合、vX.Y.Z+1 になるという意味です。ここでバージョンがまだ一つも付けられていない場合に v0.0.0 が採用されます。

golang.org/x/* などにはこのバージョニングを採用しているものが複数有ります*4。例えば golang/x/netリポジトリ(ミラーですが)を見るとタグが一つも付いていないことがわかります。

github.com

これを利用すると、vendoringはしたもののglideやdepに触れることなく今に至る古のGoプロダクトを、破壊的なことをせずにgo modulesに対応させることができます*5。ただしvendoringしたときのコミットないしバージョンがわかっていることが前提になります。

replaceの波及先

  • 陥りやすい誤解:replaceディレクティブは常に有効
  • 実際の挙動:replaceディレクティブは、main moduleに書かれたもののみが有効。

あるモジュールA内のmainパッケージをビルドするとします。ここで、モジュールAは別のモジュールBに依存しているとします。このとき、モジュールBのgo.modに記述されたreplaceは無視されます。

https://go.dev/ref/mod#go-mod-file-replace

replace directives only apply in the main module’s go.mod file and are ignored in other modules. See Minimal version selection for details.

依存先が別のパスにreplaceしている場合

依存先でのバージョンのreplaceは単に無視されてビルドされますが、モジュールパスを書き換えてしまっている場合にどうなるかやってみます。

.
├── A
│   ├── go.mod
│   └── hello.go
├── B
│   ├── go.mod
│   └── hello.go
├── go.mod
└── main.go

モジュールAはモジュールBに依存し、main.goはモジュールAに依存しています。ここで、AからBを使うためにreplaceを使ってローカルパスを指すようにします。

module A

go 1.17

require B v0.0.0

replace B => ../B

そしてルート直下のgo.modでこのAをrequireします。

module main-module

go 1.17

require A v0.0.0

replace A => ./A

A・Bは自身のモジュール名をしゃべる関数Helloを持っていますが、AのHello内でBのHelloも呼ぶことにします。

package A

import (
    "B"
    "fmt"
)

func Hello() {
    fmt.Println("This is module A")
    B.Hello()
}

このAのHello関数をmain.goで呼びます。

package main

import "A"

func main() {
    A.Hello()
}

このビルドは失敗し、非常にわかりにくいエラーが出ます。

❯ go run ./main.go
A/main.go:4:5: missing go.sum entry for module providing package B (imported by A); to add:
    go get A@v0.0.0

replaceできていないので、go buildではBを通常のモジュールとして認識し、チェックサムを確認しようとします(たぶん)。しかし、go.sumにBについての記述がないので、ちゃんとgo getできてないんじゃない?というエラーを吐いているという事だと思います(たぶん)。

ここでルート直下のgo.modでBに対するreplaceを書いてやると正しくビルドすることができます。

module main-module

go 1.17

require A v0.0.0

replace A => ./A
replace B => ./B
❯ go run ./main.go
This is module A
This is module B

go.sum

  • 陥りやすい誤解:package-lock.jsonのようなロックファイル
  • 実際の挙動:モジュールのあるバージョンのハッシュ値を記録したもの

https://golang.org/ref/mod#go-sum-files

これはロックファイルではありません。モジュールのハッシュを計算し、記憶しているだけです。 これにより、同じバージョン・同じモジュール名で改ざんされたモジュールがあったとき、改ざんされたことを検知できるようになっています(なので、リポジトリに含めておいた方が良い)。

まとめ

公式リファレンスを読みましょう。

golang.org

以上です。アドベントカレンダーの他の記事も、是非お楽しみください。


*1:だってminimumをselectするって書いてあるじゃん!

*2:ただし後述するように、これはmainモジュールのgo.modに書かれたreplaceのみが反映されるため、依存先のreplaceは機能しません

*3:たぶん

*4:タグが付いているものもありますgithub.com

*5:みんなもうgo modules使ってるからこんなことはしなくて大丈夫だよね!!