Go モジュールのバージョン管理

今回の長期休暇を利用して今まで公開したツールやパッケージ類をチューニングしているのだが, Go 1.11 以降から実装されているモジュール対応モード(module-aware mode)のバージョン管理の挙動が(ドキュメントを読んだだけでは)ピンとこなかったので,この際いろいろと試してみることにした。

試して壊して試して壊して… を繰り返した成果が今回の記事である1。 まとめは最後に書いておくのであしからず。

みんな大好き Hello World

まずは以下の簡単なパッケージを作ってみる。

hello/
├── go.mod
└── hello.go

go.mod ファイルの内容は以下の通り。 今回の記事では先頭行の module ディレクティブに注目する。 module ディレクティブはパッケージのモジュール・パスを定義するもので,このモジュールパスとバージョンのセットがモジュールの IDentity となる。

module github.com/spiegel-im-spiegel/hello

go 1.12

hello.go ファイルの内容は以下の通り。

package hello

import "fmt"

func Hello() {
	fmt.Println("Hello World")
}

このパッケージをリポジトリに push してバージョンタグ v1.0.0 を付ける。

パッケージを使う側のコードも書いておこう。

package main

import "github.com/spiegel-im-spiegel/hello"

func main() {
	hello.Hello()
}

これを実行すると以下のようになる。

$ go run main.go 
go: finding github.com/spiegel-im-spiegel/hello v1.0.0
go: downloading github.com/spiegel-im-spiegel/hello v1.0.0
go: extracting github.com/spiegel-im-spiegel/hello v1.0.0
Hello World

このとき,パッケージを使う側の go.mod は以下のようになっているはずである(モジュール名は適当)。

module work

go 1.12

require github.com/spiegel-im-spiegel/hello v1.0.0

前準備はこれで OK

パッケージのバージョンを v2 にアップグレードする

ではこの hello パッケージを少し弄ってみよう。 まずは安直に hello.go 関数を以下のように変更する。

package hello

import "fmt"

func Hello(name string) {
	fmt.Println("Hello", name, "by v2")
}

Hello() 関数の後方互換性が失われたのでメジャーバージョンを上げることにしよう。 このコードを push してバージョンタグ v2.0.0 を付ける。

この新しいパッケージで使う側のコードを修正してみる。

package main

import "github.com/spiegel-im-spiegel/hello"

func main() {
	hello.Hello("Golang")
}

go.mod ファイルも直さないとね。

module work

go 1.12

require github.com/spiegel-im-spiegel/hello v2.0.0

これを実行すると以下のようになる。

$ go run main.go 
go: finding github.com/spiegel-im-spiegel/hello v2.0.0
go: downloading github.com/spiegel-im-spiegel/hello v0.0.0-20190503134808-f31e6a72de0f
go: extracting github.com/spiegel-im-spiegel/hello v0.0.0-20190503134808-f31e6a72de0f
Hello Golang by v2

ありゃりゃ。 v2.0.0 のモジュールを見つけたまではよかったが,ダウンロード時にバージョンタグを認識していない?

ここで思い出したのが Semantic Versioning のルールである。

Semantic Import Versioning

ひょっとして v2 ディレクトリを切ったらいいのか? 試してみよう2

v2 ディレクトリによる分離

先ほどのコミットはなかったことにして, hello パッケージの構成を以下のように変える。

hello/
├── go.mod
├── hello.go
└── v2/
    └── hello.go

hello.gov1 のコードで v2/hello.gov2 のコードである。

このパッケージを使う側のコードも以下のように変える。

package main

import "github.com/spiegel-im-spiegel/hello/v2"

func main() {
	hello.Hello("Golang")
}

go.mod はこんな感じ?

module work

go 1.12

require github.com/spiegel-im-spiegel/hello/v2 v2.0.0

これで実行してみよう。

$ go run main.go 
go: finding github.com/spiegel-im-spiegel/hello/v2 v2.0.0
go: github.com/spiegel-im-spiegel/hello/v2@v2.0.0: go.mod has non-.../v2 module path "github.com/spiegel-im-spiegel/hello" (and .../v2/go.mod does not exist) at revision v2.0.0
go: error loading module requirements

ええつと? あぁ,そうか。 パッケージ側のv2/ ディレクトリにも go.mod ファイルがいるのか。

んじゃあ,以下の内容の v2/go.mod ファイルを追加して v2.0.1 タグを付ける。

module github.com/spiegel-im-spiegel/hello/v2

go 1.12

これでパッケージの構成は以下のようになった。

hello/
├── go.mod
├── hello.go
└── v2/
    ├── go.mod
    └── hello.go

では,このパッケージを使って先ほどのコードを動かしてみよう。

$ go run main.go 
go: finding github.com/spiegel-im-spiegel/hello/v2 v2.0.1
go: downloading github.com/spiegel-im-spiegel/hello/v2 v2.0.1
go: extracting github.com/spiegel-im-spiegel/hello/v2 v2.0.1
Hello Golang by v2

ようやく動いたよ… orz

インポートパスをリダイレクトしたかったのだが…

パッケージ側の構成はこれでいいとして,パッケージをインポートする側は

import "github.com/spiegel-im-spiegel/hello"

v2 のコードを動かしたいよね。 というわけで go.mod を以下のように書いてみる。

module work

go 1.12

require github.com/spiegel-im-spiegel/hello/v2 v2.0.1

replace github.com/spiegel-im-spiegel/hello v2.0.1 => github.com/spiegel-im-spiegel/hello/v2 v2.0.1

これで動かすとどうなるか。

$ go run main.go 
go: finding github.com/spiegel-im-spiegel/hello v2.0.1
go: finding github.com/spiegel-im-spiegel/hello/v2 v2.0.1
go: downloading github.com/spiegel-im-spiegel/hello/v2 v2.0.1
go: extracting github.com/spiegel-im-spiegel/hello/v2 v2.0.1
Hello Golang by v2

おっ,うまくいったっぽい? でも go.mod ファイルを見てみると

module work

go 1.12

require (
	github.com/spiegel-im-spiegel/hello v0.0.0-20190503144136-a8f02ef988d2 // indirect
	github.com/spiegel-im-spiegel/hello/v2 v2.0.1
)

replace github.com/spiegel-im-spiegel/hello v0.0.0-20190503144136-a8f02ef988d2 => github.com/spiegel-im-spiegel/hello/v2 v2.0.1

てな感じに書き換えられてしまった。 ふむむむむ?

どうもパッケージ内のディレクトリ名とバージョンタグを暗黙的に関連付けているようだ。 なので v2.x タグは hello/v2/ ディレクトリに関連付けられてしまう。

たとえば同じリビジョンに v1.0.1 タグを付ければ

という感じで hello/ ディレクトリにもバージョンタグが割り当てられる。 もっともそれで

module work

go 1.12

require (
	github.com/spiegel-im-spiegel/hello v1.0.1
	github.com/spiegel-im-spiegel/hello/v2 v2.0.1
)

replace github.com/spiegel-im-spiegel/hello v1.0.1 => github.com/spiegel-im-spiegel/hello/v2 v2.0.1

としたところで更なる混乱を招くだけだけどね。

“Malformed Module Path”

ならば,旧い v1 の方を別ディレクトリに移動すればいんじゃね? って思うよね。 私は思った。

で,パッケージ側を

hello/
├── go.mod
├── hello.go
└── v1/
    ├── go.mod
    └── hello.go

という構成にし,呼び出す側の go.mod ファイルを

module work

go 1.12

require github.com/spiegel-im-spiegel/hello v1.0.1

replace github.com/spiegel-im-spiegel/hello v1.0.1 => github.com/spiegel-im-spiegel/hello/v1 v1.0.1

とかやってみたんだけど

invalid module version github.com/spiegel-im-spiegel/hello/v1: malformed module path: github.com/spiegel-im-spiegel/hello/v1

とか言われたですよ。 いや “malformed module path” て orz

結局 モジュール対応モード下でメジャー・バージョンを上げたならモジュール・パスも変えるしかない ということらしい。

v2 ブランチを切って運用する

とはいえバージョンごとに物理的にディレクトリを切って運用するというのは今時ありえないダサさである。 そこで物理的にディレクトリを切るのではなくリポジトリ上でブランチを切って運用することを考える。

パッケージのディレクトリ構成は v1 と同じ。

hello/
├── go.mod
└── hello.go

これに対して v2 ブランチを切り, v2 ブランチ上で go.mod を以下のように変更する。

module github.com/spiegel-im-spiegel/hello2/v2

go 1.12

モジュールのパスと物理パスが異なっている が気にしないで先に進む。 hello.go

package hello

import "fmt"

func Hello(name string) {
	fmt.Println("Hello", name, "by v2")
}

として go.mod とともに v2 ブランチに commit & push し,バージョンタグ v2.0.0 を付与する。

パッケージを使用する側のコードは以下の通り。

package main

import "github.com/spiegel-im-spiegel/hello/v2"

func main() {
	hello2.Hello("Golang")
}

これを実行すると

$ go run main.go 
go: finding github.com/spiegel-im-spiegel/hello/v2 v2.0.0
go: downloading github.com/spiegel-im-spiegel/hello/v2 v2.0.0
go: extracting github.com/spiegel-im-spiegel/hello/v2 v2.0.0
Hello Golang by v2

という感じでうまく動いたようだ。 go.mod の内容も

module work

go 1.12

require github.com/spiegel-im-spiegel/hello/v2 v2.0.0 // indirect

となっていた。 よーし,うむうむ,よーし。

ブランチとモジュール・パスの関係は以下のような感じだろうか。

まとめると…

  1. v1 以降,メジャーバージョンを上げる度にモジュール・パスを変更して管理を分ける
    • v2.x なら path/to/module/v2 などとする。最後の v2 がポイント
    • パスの最後がバージョン番号(v2 など)になっていれば,暗黙的にバージョンタグが対応する
  2. モジュール・パスを変更するには go.mod ファイルの module ディレクティブを変更する
    • 物理的にディレクトリを切るのであれば go.mod ファイルも含める
    • バージョンごとにブランチを切って管理するのであれば,各ブランチの go.mod ファイルで指定するモジュール・パスに注意する
  3. パッケージを利用する側はリポジトリの物理パスとモジュール・パスが異なる場合があるため go.mod ファイルに記述されているモジュール・パスを確認する
  4. 同一パッケージの異なるメジャー・バージョンのモジュール・パスを replace で繋がないこと。更に分かりにくくなるか指定によってはエラーになる

といったところだろうか。

バージョンごとにパッケージのパスを分けるため gopkg.in といったサービスが使われることがあるが,リポジトリの物理パスとモジュール・パスが異なる場合は注意が必要である。 うまくパッケージをダウンロードできない場合は go.mod ファイル内に

replace gopkg.in/russross/blackfriday.v2 v2.0.1 => github.com/russross/blackfriday/v2 v2.0.1

といった記述が必要になるかもしれない(というかそれが元々の replace ディレクティブの機能)。

ブックマーク

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)


  1. 実際にはこの記事で書いた量の三倍くらいは試して壊して… を繰り返している。 ↩︎

  2. ちなみに v0 から v1 へのアップグレード時にはこのようなことは起きない。一般的に v0 系はベータ版と認識されていて後方互換性については煩くない。 Go 言語のモジュール対応モードでもチェックが入らないようだ。言い方を変えると v1 以降は(Semantic Versioning に従うなら)後方互換性についてちゃんと考えないといけないってこともであるのだが。バージョン設計と運用は意外と難しい? ↩︎