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

<h1><code>go:build</code> で Go バージョン別指定</h1>

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

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

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

[「golang "go:build" ビルド時 特定のGoバージョンでのみ利用 コンパイル」でググって](https://www.google.com/search?q=golang+%22go%3Abuild%22+%E3%83%93%E3%83%AB%E3%83%89%E6%99%82+%E7%89%B9%E5%AE%9A%E3%81%AEGo%E3%83%90%E3%83%BC%E3%82%B8%E3%83%A7%E3%83%B3%E3%81%A7%E3%81%AE%E3%81%BF%E5%88%A9%E7%94%A8+%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%AB)も、特化した情報が見つからなかったので、自分のググラビリティとして。

- 参考文献: <ruby>[Build constraints](https://pkg.go.dev/cmd/go#hdr-Build_constraints)<rt>ビルドの制限項目</rt></ruby> | go | cmd @ GoDoc

## TL; DR <sub><sup><sub><sup>（今北産業）</sup></sub></sup></sub>

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

- [オンラインで動作をみる](https://go.dev/play/p/kP57b3XQpJD) @ Go Playground（バージョンを切り替えて実行してみてください）

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

## TS; DR <sub><sup><sub><sup>（「この Go 素人が」とか、「この下位性なし」とか言われないように頑張ったコマケーこと）</sup></sub></sup></sub>

### マスター、ソースをくれ

<details><summary>こちらが<ruby>秘伝<rt>hidden</rt></ruby>のソースになります。</summary><div>

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

```go:main.go
package main

import "fmt"

func main() {
	fmt.Println(Greetings())
}
```
```go: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"
}
```
```go: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"
}
```
```go: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:go.mod
module github.com/KEINOS/sample

go 1.16
```

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

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

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

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

```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
}
```

次に <ruby>Dockerfile<rt>環境定義ファイル</rt></ruby> です。Go のバージョンは外部からでも受け取れるようにして、起動したらテストを実行するだけのシンプルな内容にします。

外部から受け取る変数は `VARIANT` で、ソースコードのマウント先は `/workspace` です。ちなみに `VARIANT` とは「バリエーションの選択肢」という意味ですが、変数名は何でもかまいません。

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

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

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

```shell-session:Go1.16環境でのテスト実行例
$ # 環境の構築
$ docker compose build
...
$ # go1_16 のサービス実行（環境の立ち上げ）と終了ステータス表示
$ docker compose run go1_16; echo $?
ok      github.com/KEINOS/sample/sample 0.018s
0
```

上記の<strong><font color="red">注意点</font>として、`docker compose up` ではなく `docker compose run` で環境を立ち上げていること</strong>です。

なぜなら `docker compose up` で起動した場合、テストに失敗しても終了ステータスは `0`（正常終了）のままになるからです。

これは `up` の場合は「サービス（環境）自体が起動し、正常に実行された」という判断に変わるため、「スクリプトが起動できない」という問題でない限り、実行結果の終了ステータスは関係なくなります。逆に `run` の場合は、終了ステータスが反映されます。

```shell-session:ディレクトリ構成
$ 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

---

</div></details>

### 所感

Golang をチンタラ勉強する中で、とある Go バージョンで、とある関数を実装したものの、気付いたら「次の Go バージョンで標準実装されてもうた」といったことがまれによく出てくるようになりました。

関数名が被らなければ、そのまま上位バージョンでも使い続ければいいのですが、使えるなら標準実装されているものを使いたいものです。

しかし、新規で標準実装されたものを使うと、今度は以前の Go バージョンでは使えなくなるという、ジレンマに悩みます。

そこで、ラッパー関数を作って本体ではラッパーを使い、「Go バージョン X では俺様実装のラッパーを使い、Go バージョン Y では Go 様実装のラッパーを使う」といったことがしたかったのです。

Golang は日進月歩でバージョンが上がる、まだまだ枯れていないフレッシュな言語です。

古い Go バージョンに固執するよりは、`go.mod` で新しいバージョンに切ってしまった方がいいのかもしれません。しかし「古いバージョンでも使えるんだもん」という、「もったいない」精神がうずいたので調べてみました。

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