エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

今どきの Go の書き方まとめ (2020 年末版)

こんにちは、m3 エンジニアリンググループ CTO 矢崎(id:Saiya)です。

過去に Go 言語の仕様を一通り見た経験があったのですが、久しぶりに Go のコードを最近読み書きした際に、ここ数年の Go 言語やエコシステムの進化による変化もあり、発見やハマりが多々ありました。

f:id:Saiya:20201202203554p:plain
Go 言語公式のロゴもスピード感ありますね。

同じような迷い・回り道をしてしまう方ももしかしたらおられるのではないかと思いますゆえ、 エムスリー Advent Calendar 2020 6 日目の記事として、筆者が実際に「最初から知っていれば時間を無駄にしなかったのに...!」と感じた知見をざっくばらんにシェアいたします。

本記事がどなたかの一助になりますと幸いです。 なお本記事の内容は筆者個人の理解・自身で直接読み書きしたユースケースの範囲での知見であり、全ての Go 利用事例に当てはまらない点も含みうります。

エラー処理まとめ (go >= 1.13, 2019 年 9 月リリース)

Go におけるエラー処理は悩みやすい部分でありかつ歴史的事情に惑わされやすいですが、現在の Go で筆者が落ち着いたのは以下の書き方です:

  • エラーを関数の中で生成して返したい場合は xerrors.New("....") で返す
  • エラーのラップは xerrors.Errorf("Configration file problem: %+w", err) のようにすると良い
    • 特別扱いのフォーマット文字列 %w, %+w がラップする意味を持っている (なお残念ながら複数指定は不可)
    • またフォーマット指定子の前に : %w のようにコロンを付けることでエラーの詳細がフォーマット後文字列に含まれるようにも出来ます
  • エラーの一意性の判定や unwrap は errors.Is, errors.As で行う (詳細後述)

エラーへのスタックトレースの付与とエラーの一意性

言語標準の errors は(少なくとも 1.15 現時点では)スタックトレースを含まず、一方で golang.org/x/xerrors を使う場合はスタックトレース情報が付与されます。

ただし、エラーの同一性を判定したい用途では xerrors でなく errors で生成したオブジェクトをグローバル変数に格納する方が自然です。 グローバルに共有される error オブジェクトにスタックトレースを付与する意味はないため、言語標準の errors.New("...message...") が妥当でしょう。 その上で errors.New で定義したエラーを関数から返す際に xerrors でラップすることで、エラーの同一性の判定の実現とスタックトレースの付与を両立できます。

エラーがラップされている場合でもエラーの同一性を判定するためには == での比較や .(型名) による型変換ではなく、1.13 以降の言語標準機能である errors.Is を用います。

// エラーの同一性判定をしたい用途の場合のコード例 ("sentinel error" 方式)
var ErrSomething = errors.New("mysystem.error-something")

func Hoge() error {
  return xerrors.Errorf("%w", ErrSomething) // スタックトレースをつけて返す
}

func TestHoge(t *testing.T) {
  // Hoge() の結果が ErrSomething (を warp した)エラーであることを確認
  assert.True(t, errors.Is(  // assert ライブラリについては後述
    Hoge(),
    ErrSomething,
  ))
}

さらにエラーに属性を付与したい場合は error interface を実装する struct を定義して、 errors.As で取り出すこともできます:

type MyError struct {
  someInformation string
}
func (e *MyError) Error() string {  // error interface の実装
  return "..."
}

func Hoge() error {
  return xerrors.Errorf("%w", &MyError{
    someInformation: "something",
  })
}

func Huga() {
  err := Hoge()
  var myError *MyError
  if errors.As(err, &myError) {
    // err が MyError または MyError を wrap したエラーであればここに到達する
    // ここでは MyError の関数を呼び出したりすることもできる
  }
}

上記いずれのケースでも、エラーの一意性の判定は == 等ではなく errors.As, errors.Is で行う点は重要です、さもないとエラーのラップが行われた際に判定に失敗してしまいます。

なお、前者の errors.New 結果を var に格納する方式(sentinel error)と独自の error 実装 interface を返す後者の方式の使い分けはケースバイケースですが、ライブラリなどの公開インタフェースとしてエラーを返す場合には拡張性に富む後者の方式の方が好ましいでしょう (参考資料)。

一方で、自身のプログラムの内部でエラーを一意に判定したいだけの用途では前者(sentinel error)でも十分なことも多かったです。

なお、一意性が重要でなくエラーを単に返したい場合は xerrors.New ないし xerrors.Errorf で十分です:

func Hoge() error {
  // ここで errors.New を使ってしまうとスタックトレース情報がつかず不便。
  return xerrors.New("Something wrong blah blah blah ...")
}

標準型で引っかかりやすいポイント

Go の言語標準の型の使い方は時代での変化は少なかったですが、書きなれていないと下記の点ではハマりやすかったです:

  • map は slice と違って破壊的な追加操作が可能だが、nil の map に対する更新は SEGV
    • 一方で slice の更新イディオム slice = append(slice, 追加要素) は nil slice に対しても正常に動く
  • errortype error interface { ... } であり自由に実装でき mutable にもやれば出来てしまう型である
    • Universe block で定義される type の中では唯一の mutable になりえる型
    • エラーハンドリング系のライブラリなどを使う際に意識しておいたほうが良い
  • chan はサイズ無指定の場合に buffer サイズが無限ではなく 0 (受信待ちがいないと送信がブロック)なのでデッドロックに要注意
    • 例えばシグナル(SIGINT など)を受け取るチャンネルはサイズを 1 以上にしないとシグナルハンドラをブロックしてしまう問題がよくある間違いとして各種 linter で挙げられていたりもします

並列処理の goroutine のエラー集約には golang.org/x/sync/errgroup が便利

複数 goroutine を並行実行していずれかがエラーになったら残りをキャンセル、という処理は golang.org/x/sync/errgroup で Context に対応しつつシンプルに実現できます。

筆者はうっかり自作してから気が付きました。

複数ある型変換の構文の区別

Go 言語の型変換の式は 2 種類あり迷いますが以下の仕様です:

  • expression.(T) は expression の結果の 実行時 の型が T に 代入可能である 場合に成功 *1
  • T(expression) は expression の コンパイル時 の型が T に 変換可能である 場合にコンパイル可能。

特に、type T U (defined type, 旧称 named type) の TU は互いに代入不能(変換は可能)なので、T と U の変換は T(expression) 構文が必須です。

一方で interface{} を実行時の型情報を元にキャストしたい場合は expression.(T) が必要です。 例えば interface{} 型の式が実行時に error 型(を実装したオブジェクト)を返すと分かっているならば expression.(error) となります。

なお、Defined type は type HogeHogeID string のようにドメイン固有の型を定義することで string 同士の取り違えを防ぐといった用途で有用なため筆者は大変お世話になりました。

goroutine は preemptive multitask (go >= 1.14)

1.14 (2020 年 2 月リリース)で go 処理系に真の preemptive multi task が実装されたそうです。 それまでは関数呼び出しを伴わないループが限られた machine thread を無限に占有してしまいプロセス全体が詰まってしまうという課題がありました。

1.14 までの間に多くのコードがエコシステム全体に作られてきており、1.14 からの変化を知らないと戸惑うこともあります。

Context の扱い

現代の go の実用プログラミングでは必ず使うであろう context.Context ですが、利用するフレームワークによっては独自の拡張された Context 型の利用が実質強制される場合があります。例えば gin.Context が該当。

gin.Context といった Context 実装を context.WithValue(Context, interface{}) Context 等を使ってラップしてしまうと context.Context になってしまい gin.Context を要求するコードに渡すことは当然できません。

一方で、gin.Contextcontext.Context 型を実装はしているが string 以外の属性(Value)に非対応であるため gin.Context が Context の機能の上位互換であるわけでもありません。 ( 型の定義としては interface を実装しているが、実装がリスコフの置換原則を満たしていない... )

このように利用するフレームワーク・ライブラリによっては Context の利用方法に制限が生じることがあるのですが、先にフレームワークに依存しないドメイン層等からコードを書き始めて後からフレームワーク依存の制限が発覚すると悲しいため、コードを書き始める前にフレームワークなどと整合する Context の扱いを決めておくことが望ましいです。

テストコードを書く際にモレがちな点

標準の testing パッケージや go test コマンドの使い方自体はシンプルなのでテストコードを書き始める障壁が低いのが Go 言語の良い点ですが、テストコードの書き方にはいくつか留意点もあります:

  • error の内容は errors.Is 相当でアサーションしないと後で辛いです
    • error をラップした途端に fail 続出になります
    • 比較的新しい仕組みなので、アサーション用ライブラリが error.Is 相当の判定を実装していないという罠もありがちです
  • JSON などの (un)marshal を伴うコードは面倒でも手厚くテスト書いたほうが無難です
    • struct の tag の書き間違いがあってもエラーにならないことが多いエコシステムの傾向のため
  • テストコードで defer から呼び出しているコードのエラーチェック(後述)も忘れずに書いたほうが良いです
    • close 系の処理がバグってても気づけないため
  • nil value の扱いでバグりやすいので、テストを手抜き気味にする場合でも初期値の境界ケースはちゃんとテストしたほうが安全です
    • 特に先述の nil map に対する書き込み SEGV

アサーションライブラリ

今回筆者はアサーションライブラリ選定にそこまで力を掛けていないのですが、とりあえず利用してみました github.com/stretchr/testify/assert は error.Is/As が対応していない点を除けば十分に便利でした。 error.Is によるエラー判定関数を自作するのは容易なので、各自で対応すれば十分でしょう。

なお、素の testing や自作のアサーション関数のみで戦うよりはアサーションライブラリを活用したほうが生産性が圧倒的に改善します。

例えば上記ライブラリでは必要最小限かつ十分なスタックトレースが出るおかげでテストコードを関数に分割した際のデバッグがしやすい点で特に生産性が大きく変わりました。 アサーション関数を自作する際も、内部でアサーションライブラリの関数に処理を移譲することで同様の恩恵が受けられます。

テストコードにおけるエラーのチェック

アサーションライブラリを用いることで if !assert.NoError(t, テスト対象()) { return } などと書くことができ、テスト対象の関数がエラーを返していないことのチェックを簡潔に記述できます (あるいは NoError(*testing.T, error) を自作しても良いでしょう)。

これによってテストコード中の defer のエラーチェックも defer func() { assert.NoError(t, obj.Shutdown(ctx)) }() などと一行で書ける点が特に便利で、defer で呼び出している関数の正常終了のチェックを漏れなく書くことも楽になります。

private メンバのテスト

Go におけるテストコードは package 対象パッケージ_test に記述することが一般的ですが、異常系テストなどの都合で private メンバをテストする場合には対象コードと同じ package にテストコードを記述することになります。

ただし、package を 対象パッケージ_test に分ける慣習に反する結果のデメリットとして、テストコードを本番コードから参照できてしまう構造になります。 そこで、テスト対象コードと同じ package にテストコードを記述する場合は、テストコードのあらゆる関数(テストケース以外の関数を含む)の引数に常に _ *testing.T を受け取るようにしておくと安心です。

なおファイル名が _test.go で終わる場合にはビルドの対象にならないという仕様を活用することも可能です。 テスト用の共有ユーティリティをファイルに切り出した場合などにミスをしない意味で引数に受け取る方式が筆者は好みですが、テストケースを含まないファイルでもファイル名に必ず _test.go をつける対策もアリかと思います。

テスト実行とカバレッジ採取

CI などでテストを実行する際に、筆者は最終的に以下の Makefile 例のような書き方に行き着きました:

test:
# 規模・実行時間がよほどのレベルでない限り -race (Race detector) も有効にしておいたほうがベターです
    go test -race -timeout 30m -coverprofile=coverage.txt -covermode=atomic ./...
    go tool cover -html=coverage.txt -o coverage.html

# 本記事のコード例の全てにおいて、はてなブログの都合でインデントがスペースになってしまっているのでコピー時はタブ文字に修正してください

標準機能でカバレッジの可視化ができるのは Go の良い点です、言語機能や処理系の内部構造のアップデートに起因してカバレッジ計測が壊れる可能性が低そうで安心感がありますね。

Meta Linter のススメ

Go 言語の lint は言語標準の go lint 以外にも実は大量にあり、しかも複数を併用することが普通です。 他のプログラム言語と異なり、個別の linter はピンポイントの用途に特化しており併用を前提としているエコシステムの傾向があります。

そこで、複数 linter をまとめて実行・エラーレポーティングできる meta-linter である golangci-lint (alecthomas/gometalinter の後継) を使う方が保守性・利便性に優れます。 名前に ci とありますが、CI に限らずローカルマシン等でも問題なく容易に利用できますので、lint 関係を一本化できます。

ただし golangci-lint は go module としてのインストールが非推奨 なのでインストール方法は 公式ドキュメント を参照しつつ対応する必要あります。

Makefile での実行例:

lint:
    golangci-lint run ./...
# なお、GitHub Actions の場合は専用の action が公式に用意されているため、その場合は Makefile 等で実行する必要はないです

golangci-lint 設定ファイル .golangci.yml 例:

# see: https://golangci-lint.run/usage/configuration/
# 内容をどうするかは各自のお好みで。以下はあくまで例。

issues:
  exclude-use-default: false  # デフォルトでは幾つかのルールが無効であり go lint 標準からもずれる

linters:
  enable:  # デフォルトで有効な linter は限られているため以下で有効化
    # - godox  # TODO, FIXME などのコメントの検知, リリース前に有効にする
    - goimports  # フォーマッタ(go fmt 相当も包含) https://godoc.org/golang.org/x/tools/cmd/goimports
    - gosec  # セキュリティ系 https://github.com/securego/gosec
    - noctx  # net/http 系での Context 渡し忘れ https://github.com/sonatard/noctx
    - nolintlint  # nolint コメント自体の書き方 https://github.com/golangci/golangci-lint/tree/master/pkg/golinters/nolintlint
    - prealloc  # slice のキャパシティ指定 https://github.com/alexkohler/prealloc
    - misspell  # typo https://github.com/client9/misspell

go.mod の不要な記述の自動検知

go mod tidy で差分が出る(= 不要な module 依存がある)かどうかのチェックは golangci-lint では現状できないですが以下の追加で実現できます:

lint:
    cp go.mod go.mod.bak
    go mod tidy
    diff go.mod go.mod.bak
    rm go.mod.bak

不要な記述の削除は上記を見ての通り単に go mod tidy を実行するだけで実現可能です。

ビルド時の工夫

Go はビルド結果のバイナリのポータビリティに大変優れている*2言語でありバイナリを共有しやすいですが、ビルド過程で対象アーキテクチャを明示的に指定したり、ビルド情報を埋め込んでおいてログに出す実装を入れたりは予めしておいたほうが何かと有用です:

Makefile と変数埋め込み先の main.go のコード例:

version_id = $(shell git rev-list -1 HEAD)
ldflags = "-X main.buildVersion=$(version_id) -X main.buildAt=$(shell date +'%s')"

my-binary:
    GOOS=linux GOARCH=amd64 go build -o $@ -ldflags $(ldflags) main.go
var buildVersion string
var buildAt string // UNIX epoch (e.g. 1605633588)

CREDITS の生成

バイナリ頒布する際などには CREDITS ファイルはちゃんと付けましょう、go それ自体のライセンス表記も必要です。

github.com/Songmu/gocredits/cmd/gocredits が CREDITS の自動生成には大変有用でした:

# Makefile で gocredits を使い生成する例
go_module_files = ./go.mod ./go.sum

$(release_dir)/CREDITS: $(go_module_files)
    rm -f $@
    gocredits . > $@

CI 用のツールの go.mod での扱い

先述の gocredits などのツールを普通に go get すると go.mod に記述が追加されますが、go.mod ファイルを自動で整理するコマンド go mod tidy で記述が削除されてしまいます。 なぜならばいずれのソースコードからも参照されていないためです。 しかしながらも go mod tidy は不要な module 依存の掃除に有用なので使わない理由がないです。

そこで、ライブラリとして公開するようなケースを除いては、Go の公式ドキュメント FAQ にある通り tools.go といったダミーのファイルからツールとして使う module を import する手法が有用です。 これによって go mod tidy の削除対象ではなくなります。 go get 時にはダウンロード対象になりますが、tools.go ファイルを main から直接・間接に参照しない限りビルド結果に含まれることはありません。

(VS Code などの gopls LSP 利用時) サブディレクトリに go.mod がある構成への対応

Monorepo において以下のようにレポジトリのサブディレクトリ複数に go.mod を作りたくなることがあります:

/my-project
  /server
    go.mod
    go.sum
    main.go
  /client
    go.mod
    go.sum
    main.go

現時点で上記の my-project を VS Code で普通に開くと補完やコードジャンプが機能せず困るのですが、VS Code の場合は workspace ないしエディタ全体の settings に "gopls.experimentalWorkspaceModule": true を直接書くと機能するようになります (参考: golang/go/issues/32394, golang/vscode-go/issues/197)。

gopls (golang.org/x/tools/gopls) は Go の公式 language server 実装です。

おそらく gopls (LSP) を使う他のエディタでも類似の指定が必要になるのではないかと思われます。

We are hiring!

エムスリーではテクノロジーを活用して日本の医療を進化させる取り組みを、今回触れました Go 言語に限らず日夜続けております!

対面での接触は難しさのある情勢が続いておりますが、インターネットのテクノロジーを活用したエンジニア採用を活発に行なっておりますので是非以下よりコンタクトください:

jobs.m3.com

*1:実行時の型情報を参照するため expression が interface 型である必要も必然的にありますが、この点はコンパイル時に気づくことができるため常に覚えておく必要はないでしょう

*2:libc にすらあまり依存しない