⚠️

Goのエラーについて

2024/12/16に公開

Go のエラーに関しては、他の言語と少し考え方が異なっているので好き嫌いがある気がします。この記事では Go のエラーをどのように扱えば良いのか、何がベストなのか、何が NG なのかといったことについて述べたいと思います。

ご意見やフィードバックをいただけますと嬉しいです!

Go のエラーの特徴

Go の error

  • インターフェースであり、Error() string というメソッドさえあればどの型でもエラーとして扱うことができます。つまり、制限なしに使用できる第一級オブジェクト (first-class citizen) です。

  • 他の言語でいう Exception ではないため、特別扱いにされていません。

Go の error は Java や Javascript の世界では Exception に当てはまるのですが、Exception は普通のコントロールフローとは別に throwstrycatch で管理する必要があります。つまり、ハッピーパス(path のほうですよw)のコントロールフローとアンハッピーパスを別々に考える必要があると思います。

Go の世界ではエラーも単なる値のため、別々に考える必要はありません。そして、Go のエラーは単なる値だからこそ、魔法のようなものはありません。スタックトレースも発生元のソースポジションも何もありません。

さて、このようなエラーをどう扱えばよいのか考えてみましょう。

フォーマット

エラーは多くの場合、ただの string であるため、どのような情報を加えるのか、メッセージはどのように書くか、型は何にするかなどは開発者の自由です。自由だからこそある程度ルールを決めておかないと実装方法や内容が煩雑になりがちです。

なお、Go のスタイルガイドに記載されている唯一のルールは以下の通りです。

Error strings should not be capitalized (unless beginning with proper nouns or acronyms) or end with punctuation, since they are usually printed following other context. That is, use fmt.Errorf("something bad") not fmt.Errorf("Something bad"), so that log.Printf("Reading %s: %v", filename, err) formats without a spurious capital letter mid-message.

エラー文字列は、(固有名詞や頭字語で始まらない限り)大文字にしたり、句読点で終わったりしてはならない。つまり、log.Printf("Reading %s: %v", filename, err) が、メッセージの途中で余計な大文字を入れずにフォーマットできるよう、fmt.Errorf("Something bad") ではなく fmt.Errorf("something bad") を使用する。

https://go.dev/wiki/CodeReviewComments#error-strings

ラッピング

if err != nil {
    return err
}

この3行は Go の世界ではもはやミームになっています。Go で書かれたプロジェクトのコードをランダムに覗いてみると"大半のコードはこのチェックだ"と感じるでしょう。

ただ、このコードには問題があると思います。

エラーをそのまま返すとエラーが実際に発生したところと、最終的に出力される箇所がどんどん離れてしまいます。更に、同じ発生元が複数の出力先でプリントされる可能性もあるため、非常に追いにくくなります。

Go では以下のように、元のエラーにコンテクスト(文脈・背景)を加えることができます。%w はエラー専用のプレースホルダーです。

if err != nil {
    return fmt.Errorf("something went wrong: %w", err)
}

この簡単な追加で

  • エラーメッセージを自然な文章として読むことができます
  • エラーのアンハッピーパスがピンポイントでわかるようになります

アサーション

Java の世界ではエラー別に catch を書くことができます。もちろんエラーによって対策が違ってくるので、"どの"エラー、もしくは"どういう"エラーが発生したのかを判別する必要があります。
Go では errors.Is で"どの"エラーが発生したのか、errors.As で "どういう"エラーが発生したのかをチェックすることができます。

使い方は以下の通りです。

if err != nil {
    if errors.Is(err, fs.ErrNotExist) {
        fmt.Println("file does not exist")
    } else {
        fmt.Println(err)
    }
}
if err != nil {
    var pathError *fs.PathError
    if errors.As(err, &pathError) {
        fmt.Println("Failed at path:", pathError.Path)
    } else {
        fmt.Println(err)
    }
}

上記のコードは、err%w でラップされている場合にも問題なくアサートすることができます。従って、Go 1.13 より前のバージョンでよく使われていた err.(type) は非推奨となっているため、やめましょう

エラーのコンテクスト

すでに述べたように、return err はやめたほうが良いと思います。すべてのエラーに fmt.Errorf("〇〇: %w", err) でコンテクストを加えるべきだと考えています。

コンテクストの書き方にルールは無く、自由ではあるのですが、その中にも良い書き方とあまり良くない書き方があるように感じています。例えば、以下のようなコードをよく見かけます。

fmt.Errorf("error getting user from database: %w", err)

一見、なにも問題ないように思えるかもしれません。起こったことをそのまま書いたものです。しかし、返却されるのはエラーなので、メッセージの中にわざわざ error という言葉を使う必要はないでしょう。むしろ、エラーログを読むときには、ノイズになるのではないでしょうか。

また、動詞を現在分詞形(~ing)にする必要性もないと思います。単に get にした方が短く、わかりやすいでしょう。以下に2つの例を用意してみました。読みやすさを比べてみてください。

error creating reservation: failed to find user: could not connect to auth service: i/o timeout
create reservation: find user: connect to auth service: i/o timeout

上の例は、やや冗長に感じます。一方、下の例は、コンパクトで理解しやすいのではないでしょうか。
エラーをラップする際のメッセージは、どのようなコンテクストでエラーが発生したのかをわかりやすく記録するためのものなので、不要な言葉を極力減らし、必要な情報をすぐに読み取れるようにしましょう!

補足

エラーに自動的にスタックトレースをつけるパッケージもあります。使い方は以下のようです。

return errors.WithStack(err)

ただ、あくまで個人的な経験から言えば、自動生成されたスタックトレースはノイズとなるような余計な情報が多く、必要な情報にたどり着くのに時間がかかってしまうように感じています。

https://github.com/cockroachdb/errors

パフォーマンスインパクトも大きいので、よくベンチマークしたうえで取り込んだほうが良いでしょう。

ユーザー向けエラーとエラーコード

(あくまでも個人のプロモーションです☺️)

Go では、上記のようにエラーをラップする際のお悩みポイントとして、ユーザー向けのエラーメッセージの内容が挙げられるのではないでしょうか。エラーメッセージをそのまま返却してしまうと、ユーザーにとって理解不能な情報が含まれるだけでなく、内部実装方法が外部に漏れる可能性があるため、セキュリティー的にもリスクがあります。

そのため個人的には、ユーザー向けのエラーと内部エラーを分けるべきだと思っています。内部エラーはすでに述べたように扱い、ユーザー向けのエラーは更にラップすることでエラーコードも加えることができます。

ラップする方法はいくつかありますが、こちらのブログの方法が今のところ最も使いやすいと思っています。作者のパッケージでいくつか気になる点があったので、Finatext に入社する前に自分なりにフォークしてカスタマイズしました

https://blog.carlana.net/post/2020/working-with-errors-as/

https://github.com/sollniss/resperr

以下に使用方法のサンプルを示します。

if err != nil {
    // エラーにメッセージとステータスコードを追加
    return resperr.WithCodeAndMessage(err, http.StatusBadRequest, "リクエストが不正です。")
}
func writeError(w http.ResponseWriter, err error) {
	// レスポンスを返却、デフォルトが 500 エラー
	w.WriteHeader(resperr.StatusCode(err))
	w.Write([]byte(`{"error":"` + resperr.UserMessage(err) + `"}`))
}

このパッケージの特徴は取り込みやすさです。resperr.With〇〇error を受けて、error を返しているので、網羅的にすべてのエラーに対応する必要はなく、関数のシグネチャ変更などのコード調整も不要です。単に「このエラーのときにはこのエラーメッセージにしたい」と考えれば良いだけです。

終わりに

以上が Go のエラーについての所感でした。ここまで読んでいただきありがとうございました。ご意見やご感想などございましたら、是非、コメントをいただけると嬉しいです!

Finatext Tech Blog

Discussion