Go で静的解析して Go 1.13 から標準の xerrors とうまく付き合っていく

# はじめに

[Goの新しいerrors パッケージ xerrors\(Go 1\.13からは標準のerrorsパッケージに入る予定\) \- Qiita](https://qiita.com/sonatard/items/9c9faf79ac03c20f4ae1)


上記の @sonatard さんの記事にある通り、 Go 1.13 から [golang.org/x/xerrors](https://godoc.org/golang.org/x/xerrors) が標準の error パッケージとして入るそうです。
標準でエラーを追いやすい形の仕組みができるのは嬉しいですね。

この記事では xerrors と使用する上で発生しそうな問題と、
問題を防ぐために静的解析してチェックする lint を作成しましたので紹介します。

# pkg/errors との違いと感じている問題

## スタックフレームを明示的に積んでいく必要がある

今までは [github.com/pkg/errors](https://github.com/pkg/errors) を使用してエラー伝搬してスタックトレースを表示させるのが主流だったかと思います。
`pkg/errors` では `errors.New`, `errors.Wrap` によって、それが呼び出された際のスタックトレースが `error` として帰ってきていました。

しかし、 `xerrors` では明示的に wrap しない限り、 stack frame を積んでいきません。
故に以下のような問題が起こりそうです。

* Wrap し忘れてフレームが抜けてしまう
* Wrap の構文を間違えてフレームが抜けてしまう

後者に関しては @tenntenn さんの作成した [wrapperfmt](https://github.com/tenntenn/gosa/tree/master/passes/wraperrfmt) を使用する事によって防げそうです。

## error を比較する際に Is, As を使う必要がある

こちらに関しては今まで `pkg/errors` を使っていた際にもあった問題だと思います。
`errors.Cause` で root のエラーを取得して比較するというのが必要ですが、
実装者のミスによって、 そのまま `==`, `!=` で比較してしまいエラーが適切に捕捉できないということが起こりえます。

`xerrors` においても正しく error を捕捉するためには `xerrors.Is` もしくは `xerrors.As` を使用する必要があります。

### どう解決できるか？

* Wrap し忘れてフレームが抜けてしまう問題
* `error` を間違えて `==`, `!=` で比較してしまう問題

これらは以下のようにコーディングルールに落とし込めそうです。

* `error` を返却する際には `xerrors.Errorf` によって wrap する
* `error` を比較する際には `==`, `!=` を使用しない

もちろんこれらのコーディングルールはレビューの際に指摘することができれば防ぐ事ができます。
しかし、レビューするのもまた人間です。人間は間違いを起こします。

コーディングルールに落とし込めるのであれば静的解析によってチェックすることができそうです。
静的解析する lint を作成すれば、CI に組み込むこともできます。
幸いなことに Go では静的解析をする仕組みが標準で整っていますし、 [analysis](https://godoc.org/golang.org/x/tools/go/analysis) という静的解析をするためのフレームワークも公式が提供しています。

今回はこちらと、 @tenntenn さんの analysis のコードジェネレータである [skeleton](https://github.com/tenntenn/gosa/tree/master/skeleton) を使用して lint ツールを作成しました。

# 作成した lint ツール

[xerrchk](https://github.com/sachaos/xerrchk) というものを作成しました。

```
$ go get github.com/sachaos/xerrchk/cmd/xerrchk
```

これは複数のモジュール(analysis で言うところの pass)で構成されています。

* [sachaos/xerrchk/passes/wrapping](https://github.com/sachaos/xerrchk/passes/wrapping)
* [sachaos/xerrchk/passes/isas](https://github.com/sachaos/xerrchk/passes/isas)
* [tenntenn/gosa/passes/nilerr](https://github.com/tenntenn/gosa/passes/nilerr) (@tenntenn さん作)
* [tenntenn/gosa/passes/wraperrfmt](https://github.com/tenntenn/gosa/passes/wraperrfmt) (@tenntenn さん作)

## `wrapping` モジュール

`wrapping` では関数が `error` を返却する際に `xerrors.Errorf`, もしくは `xerrors.Opaque` によって wrap しているかをチェックします。
例えば以下のソースコードでは `wrap2` 関数において wrap できていません。

```go
package main

import (
	"fmt"

	"golang.org/x/xerrors"
)

var sentinelErr = xerrors.Errorf("sentinel error")

func main() {
	fmt.Printf("%+v\n", wrap1())
}

func wrap1() error {
	return xerrors.Errorf("error on wrap1: %w", wrap2())
}

func wrap2() error {
	return alwaysErr()
}

func alwaysErr() error {
	return xerrors.Errorf("error on alwaysErr: %w", sentinelErr)
}
```

こちらの結果は以下のようになります。
wrap2 のフレームはなく、どこを通ったのかが stack frame を見る限りではわかりません。
(この例の場合は自明ですが・・・)

```
error on wrap1:
    main.wrap1
        /Users/takumasa.sakao/dev/github.com/sachaos/go-sandbox/xerrchk/main.go:16
  - error on alwaysErr:
    main.alwaysErr
        /Users/takumasa.sakao/dev/github.com/sachaos/go-sandbox/xerrchk/main.go:24
  - sentinel error:
    main.init
        /Users/takumasa.sakao/dev/github.com/sachaos/go-sandbox/xerrchk/main.go:9
```

このコードに適応すると以下のように、 wrap されていないところを lint が指摘してくれます。

```sh
$ xerrchk -wrapping .
/path/to/src/main.go:20:18: wrap with xerrros.Errorf or xerrors.Opaque
```

### どの粒度で wrap するべきか？

全ての関数の返り値を wrap するというのが適切とは限りません。
深くネストされたところから返ってくる `error` はフレーム数が多く、スタックフレームは見にくくなりそうです。
そこでどの粒度で wrap するべきかというのは考えたほうが良さそうです。

個人的には外部から呼ばれる可能性のある public な export された関数では wrap を必須として private な関数に関しては wrap を必須としないというのがいい塩梅なのではないかと思っています。
`xerrchk` では `scope` というオプションがあり、 こちらでチェック範囲を制御することができます。

```sh
# public のみを対象とする
$ xerrchk -wrapping.scope=public

# 全ての関数を対象とする(default)
$ xerrchk -wrapping.scope=all
```

## `isas` モジュール

`isas` モジュールでは `==`, `!=` を使用して `error` を比較しているところを見つけます。
例えば以下のコードでは正しく `sentinelErr` を捕捉することができず、 `failed to handle error` と出力されます。

```go
package main

import (
	"fmt"

	"golang.org/x/xerrors"
)

var sentinelErr = xerrors.Errorf("sentinel error")

func main() {
	err := wrap()
	if err == sentinelErr {
		fmt.Printf("handle sentinel err")
	}

	fmt.Printf("failed to handle error")
}

func wrap() error {
	return xerrors.Errorf("error on wrap1: %w", alwaysErr())
}

func alwaysErr() error {
	return xerrors.Errorf("error on alwaysErr: %w", sentinelErr)
}
```

これを `isas` に適応すると `xerrors.Is` を使用するようにと注意をしてくれます。

```sh
$ xerrchk -isas .
/Users/takumasa.sakao/dev/github.com/sachaos/go-sandbox/xerrchk/main.go:13:5: do not compare error with "==" or "!="
```

# おわりに

`xerrors` の使用で気をつけなければならなさそうなところと、それを静的解析でチェックすることができる `xerrchk` を紹介させていただきました。
`xerrors` 便利なので安全に使っていきたいですね。

なお、 `xerrchk` は実際の大規模なソースコードに適応してテストしたわけではないので、考慮漏れ・不具合などなどあるかと思います。
もし使用して見つけた方は GitHub で issue 報告していただけるか PR を送っていただけるとありがたいです。

また `xerrors` は go 1.13 になれば標準に入る予定とのことなので、 その際にはこのような lint も公式でサポートされるといいですね。

# 参考文献

[analysis \- GoDoc](https://godoc.org/golang.org/x/tools/go/analysis)
[ssa \- GoDoc](https://godoc.org/golang.org/x/tools/go/ssa)

特に静的解析, analysis 周りの勉強は以下を活用させていただきました。
ありがとうございます。

[Goにおける静的解析のモジュール化について \- Mercari Engineering Blog](https://tech.mercari.com/entry/2018/12/16/150000)
[GoのためのGo](https://motemen.github.io/go-for-go-book/)