詩と創作・思索のひろば

ドキドキギュンギュンダイアリーです!!!

Fork me on GitHub

Go 1.10 Release Party in Tokyo で go test, go vet に入った変更の話をしました

Go 1.10 Release Party in Tokyo - connpass

「Go 1.10 ツール周辺の CL を読む」という話をしました。

Go 1.10 ツール周辺の CL を読む - Google スライド

Songmu さんに「モテメン、Goリリースパーティー出ない?」と言われて安請け合いしたはいいものの、とくに話すネタがないのでどうしようかなー、と src/go 以下の変更を読んでいたら普通に面白かったので、そのへんの話をまとめてみました。最初は go test -json の話をしようと考えていたんだけど、go vet 周辺の変更がよかったですね。

最後の方の 1 CL 1 枚のスライドは時間が余ったときのパディングで、発表のときはほぼスキップしました。以下、メインの go vet の話をざっくりまとめておきます。

CL 74356 に至る道

今回の主眼は go test 中にシームレスに go vet を行うようにする、という変更ですが、動機は以下のイシューに端的にまとめられています。

The key insight is that running 'go vet' is considered a best practice, but why is that something people should need to learn and think to do explicitly? If it's such a good practice, it should be integrated into something that happens already.

cmd/compile: gc inconsistent about reporting "unused variable" errors · Issue #8560 · golang/go · GitHub

go vet の実体は go tool vet$GOTOOLDIR/vet) ですが、これは go/* 系の標準パッケージによって構成されている一方で、Go ツールチェインのコンパイルには cmd/compile 以下の特製のソースが利用されている、かつこちらが正であるため、go/* 系、とくに go/types のふるまいを cmd/compile にあわせてやる必要があります(これまで go vet は vendoring や cgo にうまく対応できていないという問題がありました)。

CL 74750: go tool vet に JSON を渡す

そこで、cmd/compile のおこなった import の解決結果をそのまま vet に渡す、という方法が採られました。これまでは .go ソースコードのリストを引数に取っていましたが、vet.cfg という JSON ファイルを引数にあたえることで、vet の解析により詳細な指示を与えられるようになります。go vet -x すると分かるんですが、こんな感じ。

{
        "Compiler": "gc",
        "Dir": "/Users/motemen/dev/go/src/github.com/motemen/prchecklist",
        "GoFiles": [
                "/Users/motemen/dev/go/src/github.com/motemen/prchecklist/context.go",
                "/Users/motemen/dev/go/src/github.com/motemen/prchecklist/models.go",
                "/Users/motemen/dev/go/src/github.com/motemen/prchecklist/version.go",
                "/Users/motemen/dev/go/src/github.com/motemen/prchecklist/context_test.go",
                "/Users/motemen/dev/go/src/github.com/motemen/prchecklist/models_test.go"
        ],
        "ImportMap": {
                "context": "context",
                "fmt": "fmt",
                "github.com/pkg/errors": "github.com/motemen/prchecklist/vendor/github.com/pkg/errors",
                "golang.org/x/oauth2": "github.com/motemen/prchecklist/vendor/golang.org/x/oauth2",
                "net/http": "net/http",
                "net/url": "net/url",
                "testing": "testing"
        },
        "PackageFile": {
                "context": "/usr/local/Cellar/go/1.10/libexec/pkg/darwin_amd64/context.a",
                "fmt": "/usr/local/Cellar/go/1.10/libexec/pkg/darwin_amd64/fmt.a",
                "github.com/motemen/prchecklist/vendor/github.com/pkg/errors": "$WORK/b098/_pkg_.a",
                "github.com/motemen/prchecklist/vendor/golang.org/x/oauth2": "$WORK/b099/_pkg_.a",
                "net/http": "/usr/local/Cellar/go/1.10/libexec/pkg/darwin_amd64/net/http.a",
                "net/url": "/usr/local/Cellar/go/1.10/libexec/pkg/darwin_amd64/net/url.a",
                "testing": "/usr/local/Cellar/go/1.10/libexec/pkg/darwin_amd64/testing.a"
        },
        "ImportPath": "github.com/motemen/prchecklist",
        "SucceedOnTypecheckFailure": false
}
  • GoFiles は解析対象のファイル、
  • ImportMap は import 文に指定されたパッケージパスから実際に解決されたパスへのマップ(.../vendor/... なものが存在してるのが見て取れると思います)、
  • PackageFile は解決されたパッケージパスからビルドされたオブジェクトファイルのパスへのマップ

というふうになってます。go tool vet のほうではこの情報をもとに、ソースコードの型情報の解析の際、import されたパッケージの型情報を解決します。

ちなみに SucceedOnTypecheckFailure は go test 時に true となり、go/types による型チェックが失敗しても go vet を異常終了させないフラグです。go/types にはコーナーケースで型チェックが失敗する既知のバグがあり、これのワークアラウンドになります。go test は build → vet → test という順序で実行されるので vet が走るときにはすでに(cmd/compile による)ビルドが完遂しており、vet による型チェックの失敗はユーザにとって偽の情報となるから無視するわけです。

CL 74354: go/importer.For が lookup 対応

さて、パッケージパスからオブジェクトファイルへの完全なマッピングが得られただけでは go/types の型解析は完全にはなりません。なぜならそういったマッピングに(Go1.9 の段階では)対応していないからです。

go/types では型チェックをおこなう際、import されたパッケージの型情報を得るために go/importer というパッケージを使用します。go/importer

func For(compiler string, lookup Lookup) types.Importer
type Lookup func(path string) (io.ReadCloser, error)

という API を主な機能として提供していて、パッケージパスから型情報を返す types.Importer を生成します。

type Importer interface {
        Import(path string) (*Package, error)
}

lookup はパッケージパスから「ファイル的なもの」を返す関数で、これによってモジュールのオブジェクトの場所をカスタマイズできるのですが、この引数に長らく nil しか許されていなかったのが、このたびサポートされることとなりました。

vet の中ではこれを利用し、コマンドライン引数で受けたマッピングをもとに、パッケージのオブジェクトファイルの場所を cmd/compile 由来のものとしています。

おまけ: cmd/compile vs. go/types

以上の話では cmd/compilego/typesの上位版であるという認識が背景にあったのですが、じつは go/types のほうが型チェックに厳しいところがあって、例えば以下のようなソースコードは、go build はできるものの go/types のチェックを通りません。

https://play.golang.org/p/2BohDi9Mwr8

func f() {
    var x int // x declared but not used
    go func() { x = 42 }()
}

今回の変更で go test 時に vet が走るようになり、このような形のテストはみな失敗することになったわけですが、Go のテストコードはそれにあわせてこのような変数を _ に代入して、このエラーを回避するようになったのでした。おもしろいですね。

チャンネルを使って、決まった数のリソースをgoroutine間で共有するパターン

生成が重いリソース(や重い処理の実行権)を goroutine 間で共有し使いまわすようなパターンです。よく知られていて名前がついていそうだけど、ぐぐっても分からなかったので書いておく。

コネクションプールに近い感じで、最初にリソースを生成したあと、それらを大事に取り回します。リソースが空いてなかったら goroutine は待つことにする。sync.Pool は「プールにあったら使うけど、なかったら新しく作る」くらいの感じなので、ちょっとスタンスが違う。

チャンネルによる実装は簡単で、以下のエントリにも書いたセマフォを応用すればよい。

ざっくりと書いてみた例がこちら: https://play.golang.org/p/QWAXsA_89Y

チャンネルによるセマフォの実装は、「バッファありチャンネルに何か(struct{})を挿入できた goroutine が実行の権利を持つ」というものでしたが、今回は「バッファありチャンネルからリソースを取得できた goroutine がリソースの権利を持つ」というふうになっています。

最初にリソースのプール(チャンネル)を作り:

pool := make(chan *worker, 5)
for i := 0; i < 5; i++ {
    pool <- &worker{i}
}

各 gorountine では、リソースをプールから取得。リソースを使い終わったらプールに戻します。プールに戻すまではリソースを占有できていることが保証されています。

    case w := <-pool:
        w.work()
        pool <- w
    }

リソースを取得しようとするとき、空いているものがなければブロックするので、セマフォの上位版ということになりますね。

最近のGoプロジェクトのMakefile

最近は仕事でも新しくGoのプロジェクトをイチからはじめることが増えてきて、コピペ元が欲しくなるので、スナップショットとして残しておきます。とくに Go でウェブアプリケーションを書くような場合を想定していて、npm エコシステムにも乗っていきます。

大まかな方針としては、

  • self-contained である
  • グローバルな環境を汚染しない
  • コマンド一発で開発環境が再現できる

……というところを目指します。

motemen/prchecklist がこれを達成しているつもりなので、以下、これを例に見ていきます。

依存ライブラリは dep なり何かしらのツールと Go 標準の vendoring で管理すればよい一方、そのツール自体であったり、他の開発中に必要なツール(golint とか gobump とか)であったりのインストールをどうするかという話。

npm であれば devDependencies というやつでうまいことやってくれますが、現行の Go の依存管理ツールではデファクトがない、というか依存管理ツールのデファクトが(少なくとも自分の中では)まだ定まってないので、これを Makefile でやる必要がある。

かといって依存ツールを単純に go get してはグローバルな環境を汚染してしまうことになるので、npm を真似て Go 製ツールのバイナリを .bin/ 以下に置くことにします。以下のように GOBIN 環境変数を指定すれば、バイナリを作業ディレクトリ以下にインストールできます*1

.PHONY: setup-go
setup-go:
   GOBIN=$(abspath .bin) go get -v \
        gobin.cc/go-bindata \
        gobin.cc/reflex \
        gobin.cc/mockgen \
        github.com/motemen/go-generate-jsschema/cmd/gojsschemagen

gobin.cc を使えばすっきり。

最初に make setup-go しなくてもうまいこと動くように、依存関係をきちんと書いてやります。

.PHONY: test
test: lib/web/web_mock_test.go
    ...

lib/web/web_mock_test.go: lib/web/web.go .bin/mockgen
    .bin/mockgen -package web -source $< GitHubGateway > $@

.bin/%: Makefile
    @$(MAKE) setup-go
    @touch $@

テストがモックのソースコードに依存し、それがさらに .bin/mockgen に依存するように宣言しています。そして .bin/ 以下のファイルは先ほどの setup-go タスクで生成するよう指定します。.bin/% の依存先として Makefile を書いているのは、go get するパッケージの一覧が Makefile に書いてあるから。

ここに書いたのとまったく同じ方法で npm パッケージ向けの設定も書けますが、その場合は

node_modules/.bin/%: package.json

のように書けばいいわけです。

このようにして、gonpm または yarn さえ入っていればだいたい再現可能な開発環境が作れました(だいたいというのは、Go ツールに非互換な変更が入ったときに壊れる、という懸念があるよね、というところです)。

余談になるけれど、Go ソースコードの watch & build にこれまでよく entr を使っていて、これが大変便利なんですが、パッケージマネージャによるユニバーサルなインストール方法というのがなく、このエントリに書いたような再現可能な環境を作るために Go 製の reflex を使ってみました。

ls **/*.go | entr -r sh -c '...'

reflex -r '\.go\z' -s -- sh -c '...'

になる感じで、汎用的に使えるツールです。わりとよさそう。

*1:$GOPATH/pkg は汚染されますが。これは自分にとっては問題にならない

はてなで一緒に働きませんか?