Carpe Diem

備忘録

go modulesでコマンドラインツールのバージョン管理をする

概要

goはModulesリポジトリのライブラリのバージョン管理を行えます。
ただコマンドラインツールに関してはgo getしてgo.modに追加されても、goファイルで扱っているわけではないのでgo mod tidyすると消えてしまいます。

しかしながら「この機能は最新のmasterにしかない。かと言ってgo get -u@masterで常に最新にしてしまうと互換性が壊れるかもしれない。だからバージョン管理できないと困る」というシチュエーションはよくあります。

その対策としては現状tools.goにblank importするのがベストプラクティスとなっています。

環境

  • go 1.13.6

現状の問題

入ったはずのパッケージがgo mod tidyで消える

$ mkdir hello
$ cd hello
$ go mod init example.com/hello

としてから、stringerを使いたいと考えインストールしてみます。

$ go install golang.org/x/tools/cmd/stringer

するとgo.modにそのライブラリ及びインストールしたバージョンが追加されます。

$ cat go.mod
module example.com/hello

go 1.13

require golang.org/x/tools v0.0.0-20200110213125-a7a6caa82ab2 // indirect

しかしこれは直接goファイルで扱っているわけではないので、go mod tidyすると消えてしましいます。

$ go mod tidy
$ cat go.mod
module example.com/hello

go 1.13

blank importでgo.modに残す

そこでtools.goといった適当なファイルを用意し、_importを使います。

// +build tools

package tools

import (
        _ "golang.org/x/tools/cmd/stringer"
)

こうするとgo mod tidyしてもgo.modに残り続けます。

$ go install golang.org/x/tools/cmd/stringer
$ go mod tidy
$ cat go.mod
module example.com/hello

go 1.13

require golang.org/x/tools v0.0.0-20200110213125-a7a6caa82ab2

// +build toolsを付けるのはなぜか?

単純に

package tools

import (
        _ "golang.org/x/tools/cmd/stringer"
)

としてしまうと、通常のビルド時やgo get時に

tools.go:6:2: import "golang.org/x/tools/cmd/stringer" is a program, not an importable package

と怒られてしまいます。

なので通常のビルドに含まれないように付けます。

インストールする時は?

go installを使う

go getはライブラリが更新されていると勝手にバージョンが上がるので基本的にgo installを使います。
go installであればgo.modに書かれたバージョンをインストールしてくれるのでコマンドラインツールのバージョン固定が可能です。

複数のCLIをインストールしたい

import (
        _ "github.com/golang/mock/mockgen"
        _ "golang.org/x/tools/cmd/stringer"
)

こんな感じで複数のコマンドラインツールがある場合は

$ cat tools.go | awk -F'"' '/_/ {print $2}' | xargs -tI {} go install {}

ちょっと泥臭いですがこのようにシェルコマンドで対応します。
Makefileに書くときは$2のところを$$2にするよう注意してください。

install-go-tools:
        cat tools.go | awk -F'"' '/_/ {print $$2}' | xargs -tI {} go install {}

リポジトリ毎にバイナリを管理したい

以上でインストールするコマンドラインツールのバージョンを固定できるようになりましたが、他のところで別のバージョンをインストールして上書きしてしまった場合、もう一度インストールする必要が出てきます。

そういった手間を省くために環境変数GOBINでインストールする場所を指定し、そこにPATHを通して実行するバイナリの場所も指定します。

例えば先程のMakefileを↓のように変更します。

BIN := $(abspath .bin) 
install-go-tools:
        cat tools.go | awk -F'"' '/_/ {print $$2}' | GOBIN=$(BIN) xargs -tI {} go install {}
mockgen:
        PATH=$(BIN):$(PATH) mockgen hoge.go

ポイント

ポイントとしては以下です。

インストール時にGOBINを指定

インストールしたいところにGOBINを指定します。
この例ではリポジトリルート/.binにしています。

xargs -tI {} go install {}
↓
BIN := $(abspath .bin) 
GOBIN=$(BIN) xargs -tI {} go install {}

実行時は一時的にPATHに追加

GOBINはインストール場所なだけで、そのコマンドラインツールを実行する時にセットしても

# グローバルは最新
$ mockgen -version
v1.6.0

# リポジトリ用に古いバージョンを入れてみる
$ GOBIN=./.bin go install github.com/golang/mock/mockgen@v1.5.0

# GOBINをつけて実行しても使うのはグローバル
$ GOBIN=./.bin mockgen -version
v1.6.0

このようにインストールしたバイナリを使ってくれないです。
go generateのようなコマンドを使っても同様です。

PATHの先頭に置く

Goを使う人のほとんどが

export PATH=$PATH:$GOPATH/bin

してると思いますが、このように後ろにつけてしまう(PATH=$(PATH):$(BIN))と$GOPATH/binが優先されてしまい$BINが使われません。なので

PATH=$(BIN):$(PATH)

としましょう。

abspathで相対パスでなく絶対パス

シェルではBIN := $(abspath .bin)としてます。
これはPATH相対パスを設定すると実行場所が変わった時に実行できなくなるためです。

なのでMakefileでなくターミナルで直接使う場合はGOPATH=$(PWD)/.binといった感じにしてください。

その他

1.16からgo installでバージョン固定できるよ

go install now accepts arguments with version suffixes (for example, go install example.com/cmd@v1.0.0). This causes go install to build and install packages in module-aware mode, ignoring the go.mod file in the current directory or any parent directory, if there is one. This is useful for installing executables without affecting the dependencies of the main module.

ref: https://go.dev/doc/go1.16#go-command

とあるようにバージョン固定ができるようになりました。これによりtools.goにこだわらなくても、バイナリバージョンを固定することができます。
しかしながらgo.modをイジらないということはdependabotのようなバージョン更新の自動化botも検知が難しくなるので、やはりgo.modを利用するやり方の方がバージョン管理を1つに集約できる&周辺ツールのエコシステムに乗っかれて便利かと思います。

1.17からgo runでバージョン固定できるよ

go run now accepts arguments with version suffixes (for example, go run example.com/cmd@v1.0.0). This causes go run to build and run packages in module-aware mode, ignoring the go.mod file in the current directory or any parent directory, if there is one. This is useful for running executables without installing them or without changing dependencies of the current module.

ref: https://go.dev/doc/go1.17#go-command

さらに1.17からはgo runでもバージョン固定ができるようになりました。
これにより例えばgo generateでも以下のようにバージョンを指定して実行することができるので、tools.goが無くても固定が可能になります。

//go:generate go run golang.org/x/tools/cmd/stringer@latest

しかしながらこの使い方は

  • バージョンが各ファイルに分散されてしまうこと
  • 1.16のgo installでも話したエコシステムの恩恵を受けられなくなること

といった課題があります。

一方CIなどで特定のgoツールを利用したい、といったユースケースではとても適していると思います。

まとめ

go modulesでコマンドラインツールのバージョン固定方法を紹介しました。

Wikiにも載っているくらいなので現状このやり方が推奨されているかと思います。

ソース