go:build
で Go バージョン別指定
Go 言語(以下 Golang)で、とある関数を実装したら次の Go バージョンで実装されてしまった。
自分のパッケージは下位互換を持たせたいので、特定の Go バージョン以上の場合に特定のソースを含めないようにしたい。
イメージとして //go:generate 〜
で任意のスクリプトを実行できるような感じで、go build
時に分岐させたいのです。
つまり、Golang でビルドを実行する際に、Golang のバージョンによってコンパイル時に含める・利用するファイルを限定したいのです。具体的には //go:build 〜
で特定の OS や cgo
の有無で利用するファイルを分岐させるように、コンパイル時に利用している Golang バージョン別に分岐させたいのです。
「golang "go:build" ビルド時 特定のGoバージョンでのみ利用 コンパイル」でググっても、特化した情報が見つからなかったので、自分のググラビリティとして。
- 参考文献: Build constraints | go | cmd @ GoDoc
TL; DR (今北産業)
- Go 1.18 以降でのみ「含める」ように制限する。
v1.18以降限定
//go:build go1.18 // +build go1.18
- Go 1.18 以降は「含めない」ように制限する。
v1.17まで限定
//go:build !go1.18 // +build !go1.18
- 上記を組み合わせると、Go 1.16 でのみ「含める」ように制限する。
v1.16のみに限定
//go:build go1.16 && !go1.17 // +build go1.16,!go1.17
- オンラインで動作をみる @ Go Playground(バージョンを切り替えて実行してみてください)
go:build
と +build
は同じものです。+build
が古い記法で、go:build
が新しい記法です。Go 1.18 までは下位互換のため、両方の記載が必要です。
TS; DR (「この Go 素人が」とか、「この下位性なし」とか言われないように頑張ったコマケーこと)
マスター、ソースをくれ
こちらが秘伝のソースになります。
一見同じものを使っているように見えますが、時と共に継ぎ足していった、年季のあるソースです。サッパリとしつつも、汎用性の高いソースに仕上げています。ソース内に同じ関数の二度漬けは厳禁でお願いします。
package main
import "fmt"
func main() {
fmt.Println(Greetings())
}
//go:build go1.16 && !go1.17
// +build go1.16,!go1.17
package main
func Greetings() string {
return "I am a Go 1.16"
}
//go:build go1.17 && !go1.18
// +build go1.17,!go1.18
package main
func Greetings() string {
return "I am a Go 1.17"
}
//go:build go1.18
// +build go1.18
package main
func Greetings() string {
return "I am a Go 1.18"
}
これらを同じディレクトリに設置します。ちなみに go.mod
の内容は以下の通りです。ポイントは、サポートする最低の Go バージョン(ここでは Go 1.16)を指定していることです。Go 1.15 環境で go mod tidy
するとエラーがでます。
module github.com/KEINOS/sample
go 1.16
しかし、これだけでは Go のバージョンごとのテストが大変です。
そこで、サクッと使い捨ての環境を作るのに便利な Docker を使って、ローカルでも GitHub Actions などの CI/CD でも利用できるテストの実行環境を用意します。
具体的には、汎用の Dockerfile を作って、docker-compose
コマンドで異なる Go のバージョンごとにテストを実行できるようにします。
まずは、テスト・ファイルから。とりあえず、どの Go バージョンでも共通する出力を含んでいたらテストはパスとします。テスト内容は、必要にあわせて適宜工夫します。
package main
import (
"fmt"
"strings"
)
func ExampleGreetings() {
expect := "I am a Go"
actual := Greetings()
if strings.Contains(actual, expect) {
fmt.Println("OK")
}
// Output: OK
}
次に Dockerfile です。Go のバージョンは外部からでも受け取れるようにして、起動したらテストを実行するだけのシンプルな内容にします。
外部から受け取る変数は VARIANT
で、ソースコードのマウント先は /workspace
です。ちなみに VARIANT
とは「バリエーションの選択肢」という意味ですが、変数名は何でもかまいません。
# VARIANT のデフォルト値。これが外部から渡されます。(デフォルトで `FROM golang:latest` と同等)
ARG VARIANT=alpine
# -----------------------------------------------------------------------------
# Main Stage
# -----------------------------------------------------------------------------
FROM golang:${VARIANT}
# 作業ディレクトリと、ソースのマウント先
WORKDIR /workspace
# Docker イメージのビルド時に、現在の `go.mod` のモジュールだけダウンロードさせておき、テスト時に差分
# をダウンロードさせることで、アクセス負荷を減らしています。
COPY ./go.mod /workspace/go.mod
RUN go mod download
# `go.mod` のチェックと、テストの実行
ENTRYPOINT go mod tidy && go test ./...
最後に docker-compose
コマンドで異なる Go バージョンを指定して実行できるように、実行定義ファイルを作成します。
下記 YAML の services
にある go1_〜
の各々の項目が環境と実行内容です。利用する Dockerfile、変数に渡す値、ボリュームのマウント先などを定義しています。
version: "3.9"
services:
go1_16:
build:
context: .
dockerfile: ./Dockerfile
args:
VARIANT: 1.16-alpine
volumes:
- .:/workspace
go1_17:
build:
context: .
dockerfile: ./Dockerfile
args:
VARIANT: 1.17-alpine
volumes:
- .:/workspace
go1_18:
build:
context: .
dockerfile: ./Dockerfile
args:
VARIANT: 1.18-alpine
volumes:
- .:/workspace
以上で、さまざまな Go バージョンでテストする環境の準備が整いました。あとは、docker-compose
コマンドで各サービス(環境)を起動し、テストを実行するだけです。
この設定の場合、例えば docker-compose run go1_16
と実行すると、services
にある go1_16
の内容で環境が起動され、Dockerfile
内の ENTRYPOINT
が実行されます。
この時使われる Docker イメージは golang:1.16-alpine
になります。Go のバージョンを、VARIANT
変数を通してバリエーションを変えられるようにしている点がポイントです。
$ # 環境の構築
$ docker compose build
...
$ # go1_16 のサービス実行(環境の立ち上げ)と終了ステータス表示
$ docker compose run go1_16; echo $?
ok github.com/KEINOS/sample/sample 0.018s
0
上記の注意点として、docker compose up
ではなく docker compose run
で環境を立ち上げていることです。
なぜなら docker compose up
で起動した場合、テストに失敗しても終了ステータスは 0
(正常終了)のままになるからです。
これは up
の場合は「サービス(環境)自体が起動し、正常に実行された」という判断に変わるため、「スクリプトが起動できない」という問題でない限り、実行結果の終了ステータスは関係なくなります。逆に run
の場合は、終了ステータスが反映されます。
$ tree
.
├── Dockerfile
├── docker-compose.yml
├── go.mod
└── sample
├── main.go
├── main_test.go
├── v1_16.go
├── v1_17.go
└── v1_18.go
- 検証環境: macOS Catalina (OSX 10.15.7), Docker version 20.10.13, build a224086
所感
Golang をチンタラ勉強する中で、とある Go バージョンで、とある関数を実装したものの、気付いたら「次の Go バージョンで標準実装されてもうた」といったことがまれによく出てくるようになりました。
関数名が被らなければ、そのまま上位バージョンでも使い続ければいいのですが、使えるなら標準実装されているものを使いたいものです。
しかし、新規で標準実装されたものを使うと、今度は以前の Go バージョンでは使えなくなるという、ジレンマに悩みます。
そこで、ラッパー関数を作って本体ではラッパーを使い、「Go バージョン X では俺様実装のラッパーを使い、Go バージョン Y では Go 様実装のラッパーを使う」といったことがしたかったのです。
Golang は日進月歩でバージョンが上がる、まだまだ枯れていないフレッシュな言語です。
古い Go バージョンに固執するよりは、go.mod
で新しいバージョンに切ってしまった方がいいのかもしれません。しかし「古いバージョンでも使えるんだもん」という、「もったいない」精神がうずいたので調べてみました。
そして、下位互換を維持するために、また無駄なメンテナンスが発生するんだろうなと思ったのでした。