Plan 9とGo言語のブログ

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

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

2022年8月、Go 1.18対応版にアップデートしました

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

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

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

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

バージョンの取得

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

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

type BuildInfo struct {
    GoVersion string         // Version of Go that produced this binary.
    Path      string         // The main package path
    Main      Module         // The module containing the main package
    Deps      []*Module      // Module dependencies
    Settings  []BuildSetting // Other information about the build.
}

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

type BuildSetting struct {
    // Key and Value describe the build setting.
    // Key must not contain an equals sign, space, tab, or newline.
    // Value must not contain newlines ('\n').
    Key, Value string
}

BuildSettingは、ビルドしたときのフラグやリポジトリの情報を持った値です。Go 1.18時点では以下のような値を保持しています。

// Key=Valueで列挙
-compiler=gc
CGO_ENABLED=1
CGO_CFLAGS=
CGO_CPPFLAGS=
CGO_CXXFLAGS=
CGO_LDFLAGS=
GOARCH=amd64
GOOS=linux
GOAMD64=v1
vcs=git
vcs.revision=ddf71bc3f52c7c221cea82bd26547d0346715f14
vcs.time=2020-12-17T05:50:03Z
vcs.modified=true

従って、コミットハッシュもBuildInfoから参照することが可能です。

どのように動くのか

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

普通にgo installした場合

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

% go install 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 install github.com/lufia/go-version-example@v0.0.1
go install 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 install 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 install github.com/lufia/go-version-example

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

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

% go install 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以下のソースコードを眺めると良いでしょう。

他の実行ファイルに含まれるバージョンを調べたい場合

Go 1.18より、debug/buildinfoパッケージを使うと、go version -mと同様に指定したファイルのバージョンを調べることができるようになりました。それぞれの値は上と同じなのでコードだけ紹介します。

package main

import (
    "debug/buildinfo"
    "flag"
    "fmt"
    "log"
)

func main() {
    log.SetFlags(0)
    flag.Parse()

    for _, arg := range flag.Args() {
        info, err := buildinfo.ReadFile(arg)
        if err != nil {
            log.Fatalln("can't get BuildInfo:", err)
        }
        fmt.Printf("Main = %s (%s)\n", info.Main.Version, info.Main.Sum)
        for _, m := range info.Deps {
            fmt.Printf("Deps[%s] = %s (%s)\n", m.Path, m.Version, m.Sum)
        }
        fmt.Println("Settings:")
        for _, s := range info.Settings {
            fmt.Printf("\t%s=%s\n", s.Key, s.Value)
        }
    }
}

まとめ

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

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