LoginSignup
0

posted at

updated at

Organization

【Golang】ビルド/コンパイル時に利用するファイルを、特定の Go バージョンに限定する【下位互換の持たせ方の一案】

go:build で Go バージョン別指定

Go 言語(以下 Golang)で、とある関数を実装したら次の Go バージョンで実装されてしまった。
自分のパッケージは下位互換を持たせたいので、特定の Go バージョン以上の場合に特定のソースを含めないようにしたい。

イメージとして //go:generate 〜 で任意のスクリプトを実行できるような感じで、go build 時に分岐させたいのです。

つまり、Golang でビルドを実行する際に、Golang のバージョンによってコンパイル時に含める・利用するファイルを限定したいのです。具体的には //go:build 〜 で特定の OS や cgo の有無で利用するファイルを分岐させるように、コンパイル時に利用している Golang バージョン別に分岐させたいのです。

「golang "go:build" ビルド時 特定のGoバージョンでのみ利用 コンパイル」でググっても、特化した情報が見つからなかったので、自分のググラビリティとして。

TL; DR (今北産業)

  1. Go 1.18 以降でのみ「含める」ように制限する。
    v1.18以降限定
    //go:build go1.18
    // +build go1.18
    
  2. Go 1.18 以降は「含めない」ように制限する。
    v1.17まで限定
    //go:build !go1.18
    // +build !go1.18    
    
  3. 上記を組み合わせると、Go 1.16 でのみ「含める」ように制限する。
    v1.16のみに限定
    //go:build go1.16 && !go1.17
    // +build go1.16,!go1.17
    

go:build+build は同じものです。+build が古い記法で、go:build が新しい記法です。Go 1.18 までは下位互換のため、両方の記載が必要です。

TS; DR (「この Go 素人が」とか、「この下位性なし」とか言われないように頑張ったコマケーこと)

マスター、ソースをくれ

こちらが秘伝hiddenのソースになります。

一見同じものを使っているように見えますが、時と共にバージョンごとに継ぎ足していった、年季のあるソースです。サッパリとしつつも、汎用性の高いソースに仕上げています。ソース内に同じ関数の二度漬けは厳禁でお願いします。

main.go
package main

import "fmt"

func main() {
	fmt.Println(Greetings())
}
v1_16.go
//go:build go1.16 && !go1.17
// +build go1.16,!go1.17

package main

func Greetings() string {
	return "I am a Go 1.16"
}
v1_17.go
//go:build go1.17 && !go1.18
// +build go1.17,!go1.18

package main

func Greetings() string {
	return "I am a Go 1.17"
}
v1_18.go
//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 するとエラーがでます。

go.mod
module github.com/KEINOS/sample

go 1.16

しかし、これだけでは Go のバージョンごとのテストが大変です。

そこで、サクッと使い捨ての環境を作るのに便利な Docker を使って、ローカルでも GitHub Actions などの CI/CD でも利用できるテストの実行環境を用意します。

具体的には、汎用の Dockerfile環境定義ファイル を作って、docker-compose コマンドで異なる Go のバージョンごとにテストを実行できるようにします。

まずは、テスト・ファイルから。とりあえず、どの Go バージョンでも共通する出力を含んでいたらテストはパス通過とします。テスト内容は、必要にあわせて適宜工夫します。

main_test.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 とは「バリエーションの選択肢」という意味ですが、変数名は何でもかまいません。

Dockerfile
# 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 バージョンを指定して実行できるように、実行定義ファイルdocker-compose.yamlを作成します。

下記 YAML の services にある go1_〜 の各々の項目が環境と実行内容です。利用する Dockerfile、変数に渡す値、ボリュームのマウント先などを定義しています。

docker-compose.yml
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 変数を通してバリエーションを変えられるようにしている点がポイントです。

Go1.16環境でのテスト実行例
$ # 環境の構築
$ 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 で新しいバージョンに切ってしまった方がいいのかもしれません。しかし「古いバージョンでも使えるんだもん」という、「もったいない」精神がうずいたので調べてみました。

そして、下位互換を維持するために、また無駄なメンテナンスが発生するんだろうなと思ったのでした。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
0