Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

go getだけでコマンドのバージョンを埋め込む

久しぶりのGoネタです。Go 5 Advent Calendar 2020の18日目が空いていたので書きました。

Goで実装されたコマンドでは、ビルドした時点のバージョンを埋め込むため以下のようなMakefileを用意することがあると思います。

.PHONY: build
build:
  go build -ldflags '-X main.Version=$(VERSION)'

しかしこの方法では、go getなどMakefileを経由せずビルドしたバイナリには適切なバージョンが埋め込まれない問題があります。個人的な意見では、可能な限りgo getでインストールできる状態を維持した方が良いと思っていますが、バージョンを埋め込むためには他に方法がないので仕方がないと理解していました。しかしGo 1.15現在、runtime/debug.ReadBuildInfoを使うと、Goモジュールで管理される場合においてはビルド時のバージョンを取れるようになっていて、これを使えば自前でバージョンを埋め込む必要がなくなるので、go getだけで適切なバージョンを表示させることができます。

ところで、Go1.16からの go get と go install についてで書かれているように、Go 1.16からgo getの挙動が変わってしまいます。この記事を書いている時点ではまだGo 1.15なのでgo get表記を使っていますが、おそらく記事中のgo getgo installに読み替えると、1.16以降もそのまま使えるのではないかと思います。

バージョンの取得

バージョンを取得したい箇所で、以下のように書きましょう。

import (
    "runtime/debug"
)

func Version() string {
    info, ok := debug.ReadBuildInfo()
    if !ok {
        // Goモジュールが無効など
        return "(devel)"
    }
    return info.Main.Version
}

これで、v1.0.0などのような、Goモジュールが認識しているバージョン文字列を取得できます。簡単ですね。

runtime/debug.BuildInfo構造体

Go 1.15時点の定義は以下のようになっていて、mainパッケージのバージョン以外にも、コマンドが依存しているモジュールのimportパスやバージョンも取得できます。ただし、runtime/debug.BuildInfoに含まれるモジュールはコマンドをビルドするために必要なものだけです。例えばテストでのみ参照するモジュールは含まれません。

type BuildInfo struct {
    Path string    // The main package path
    Main Module    // The module containing the main package
    Deps []*Module // Module dependencies
}

type Module struct {
    Path    string  // module path
    Version string  // module version
    Sum     string  // checksum
    Replace *Module // replaced by this module
}

どのように動くのか

この方法を導入した場合に、バージョン表記はどうなるのかをいくつかのパターンで見ていきましょう。

普通にgo getした場合

コマンドのimportパスをそのままgo getした場合ですね。この場合は、Goモジュールが認識する最新のバージョンが使われます。

% go get github.com/lufia/go-version-example
go: downloading github.com/lufia/go-version-example v0.0.2
go: github.com/lufia/go-version-example upgrade => v0.0.2
go: downloading github.com/pkg/errors v0.9.1

% go-version-example 
Main = v0.0.2
Deps[github.com/pkg/errors] = v0.9.1

古いバージョンを指定した場合

この場合も、正しくバージョンを取得できます。

% go get github.com/lufia/go-version-example@v0.0.1
go get github.com/lufia/go-version-example@v0.0.1
go: downloading github.com/lufia/go-version-example v0.0.1

% go-version-example 
Main = v0.0.1

ブランチ名などを明記した場合

正規のバージョンよりmainブランチが進んでいる場合に、@mainを明記してgo getした場合はバージョンの後ろに時刻とハッシュ値が付与されます。

% go get github.com/lufia/go-version-example@main
go: downloading github.com/lufia/go-version-example v0.0.2-0.20201217055003-ddf71bc3f52c
go: github.com/lufia/go-version-example main => v0.0.2-0.20201217055003-ddf71bc3f52c

% go-version-example 
Main = v0.0.2-0.20201217055003-ddf71bc3f52c
Deps[github.com/pkg/errors] = v0.9.1

カレントディレクトリがmainモジュールと同じ場合

少し分かりづらいのですが、カレントディレクトリがGoモジュール管理下にあり、そのimportパスとgo getするimportパスが同一の場合。例えば、

// go.mod
module github.com/lufia/go-version-example

を持つリポジトリの中で

% go get github.com/lufia/go-version-example

としてコマンドをビルドした場合、このときは手元でどのようなタグ付けがされていたとしても、mainのバージョンは常に(devel)という文字列が取得されます。Makefileと併用しようとすると手元にはソースコードが一式あるはずなので、この挙動は少し厄介ですね*1

% cd $GOPATH/src/github.com/lufia/go-version-example

% go get github.com/lufia/go-version-example

% go-version-example 
Main = (devel)
Deps[github.com/pkg/errors] = v0.9.1

どうやっているのか

2020-12-18 16:30: 対応フォーマットの話はgo version -mの話だったので書き換えました。

runtime/debug.ReadBuildInfoは何を読んでいるのか、ですが、ビルド時にruntime.modinfoへモジュール情報が埋め込まれていて、これをruntime/debug.BuildInfoへパースしているようですね。雰囲気で読んだ感じだと、埋め込んでいるのはgo/src/cmd/go/internal/work/exec.gobuild()辺りかな。

runtime.modinfoとだいたい同じ値がgo version -mで調べることができます。こっちはGo 1.13以降で対応されました。

% go version -m ~/bin/ivy
/Users/lufia/bin/ivy: go1.14.6
    path    robpike.io/ivy
    mod robpike.io/ivy  v0.0.0-20191204195242-5feaa23cbcf3 h1:tff9UcwX5PKD7Z+Q7O9EIILnQVm5uRY0/xP90+oXtog=

go versionが読む情報は、例えばELFフォーマットのバイナリでは.go.buildinfoという名前のセクションに64KBのデータとして存在します。Mach-Oの場合はセクション名が__go_buildinfoであったりなど若干の差はありますが、どれも同じデータが埋め込まれています。これらの詳細は、go/src/cmd/go/internal/version以下のソースコードを眺めると良いでしょう。

まとめ

runtime/debug.ReadBuildInfoでコマンドに埋め込まれたバージョンを取得できることを紹介しました。多くの場合は、これを使うとgo getだけでも適切なバージョンが取得できると思います。個人的にはgo getでコマンドを導入することが多くあるので、Makefileに依存しないこの方法が広まってくれると嬉しいのですが、Makefileでクロスコンパイルしている場合は容易に(devel)表記となってしまうところは、少し注意が必要かもしれませんね。

*1:一時的にcdでカレントディレクトリを変更すれば回避は可能ですが