🦜

Goエラーハンドリング戦略

2021/03/04に公開

Goのエラーハンドリングが採ったスタイル

  • 多値返し
  • 直積(関数の返値とエラーを両方返す)
  • try-finallyをdeferという機構でカバー
  • panicはプロセスを落とすためのもの

Goはこの戦略でエラーハンドリングを行うとしましたので、「多値はなぜタプルじゃないんだ?」、「直和(返値orエラー)で十分じゃ?」「panic-recoverでtry-catchできそう?」などいう様な他の処理系の風習を持ち込むことは意味がありません。そしてそれらの提案の多くはすでに検討されリジェクトされてきた経緯があります。

「try組み込み関数」プロポーザルなんかも検討されマージ直前くらいまで進んだこともありますが、「Goのエラーハンドリング」にとって一長一短がありました。その欠点課題は解決できずに最終的にリジェクトされました。

「多値返し」は実にCPUフレンドリーな機構で、C言語の関数呼び出し規約にちょっと改良をいれただけの「返値をレジスタまたはスタックに積み上げるだけ」というシンプルな仕組みです。タプルや直和のオブジェクトを返すとなるとメモリフットプリントが拡大しますし、パック・アンパックコストも増えてしまいます(最適化でオーバーヘッドを殺すということはできるかもしれませんが)。

また、直積が必要な異常系というものがレアケースですが存在します(エラーを返しつつ、処理結果も返し、結果にに応じた対応が必要な異常系)。これと同じ異常系を直和で処理している事例を調べたことがありますが、結構トリッキーなハンドリング手順が必要でした。

Goのエラーハンドリングの考え方

  • 関数呼び出しの返値は処理結果とエラーを多値で返します。
  • 処理結果を返す必要がない場合はエラーのみを返します。
  • 実行時に失敗する可能性のない処理ではエラーを返さないという場合もあります。
  • エラーがnilなら正常処理完了を示し、処理結果には有効な値が格納されます。
  • エラーがnilではないなら異常(例外的な状況)の発生を示します。
  • エラー発生時、処理結果は必ず「defer等で解放処理が不要」なものです。(ほとんどはゼロ値でポインタならnil)
  • レアですが「後でdeferで解放処理が不要」かつ意味のある値を返すケースがあります。
  • エラーハンドリングの主体はまず呼び出し側の責務です。
  • 上流に委譲、ログに出力、リトライ、続行を諦めpanicまたはexit、
  • 限られた条件でエラーを無視するという選択肢もあります。
  • 委譲されたエラーは呼び出し側がどうハンドリングするのかを考えること。
  • エラーはユーザーへのメッセージでもあります。
  • 意味不明なメッセージにならない様に努めるのはエラーハンドリングを記述する人です。
  • 適切に書かれたエラーメッセージからトラブルの概要を掴む際、スタックトレースが役に立つ場面は多くはありません(役に立つ時、それは適切なエラーメッセージの構築が不十分)。
  • なので、いまのところGoの標準でエラー値にスタックトレースを含む様にはなっていません。
  • 処理が完走するにしても中断されるにしても確保した一時リソースの開放処理を忘れない様にする必要がありますが、これはdefer機構でまかないます。
  • アーリーリターンというスタイルで書くのが推奨されています。
  • ハンドリングする時、適切なスタックの深さというものがあります。
  • ハンドリングのスタック深さが上流すぎる時、エラーの型分岐が必要になってしまいがち。

エラーハンドリングの4種

  • 上流に委譲
  • ログに出力
  • リトライ
  • 続行を諦めpanicまたはexit

下ふたつは適用されるケースが上2つに比べると少ないと思います。
ほとんどは「上流に委譲」で、特定のスタックレベルで「ログに出力」をするのがおすすめです。
「リトライ」が有効な場面は失敗頻度がそれなりにある状況です。
例えば「無線での通信相手」など「相手がいつでもスタンバイで失敗せずに通信できること」を期待できない様な場合です。「ログに出力」と「続行を諦めpanicまたはexit」については後述にて解説します。

アーリーリターンスタイルとは

以下の様に...主たる処理...を実行するための前提条件を事前に確認します。
満たさない様な場合、returnして上流にエラーを委譲します。

func hoge() error {
	if err :=  f1(); err != nil {
		return fmt.Errorf("f1 fail: %w", err)
	}
	if err :=  f2(); err != nil {
		return fmt.Errorf("f2 fail: %w", err)
	}
	...主たる処理...
}

サードパーティのロガーやエラーハンドラは極力使わない

  • 基本標準のみで済ますこと。
  • 特に再利用が見込まれる汎用のコードやライブラリでは絶対にダメ。
  • Gopherの多くはエラーやロガーが標準外で組まれたライブラリは採用しづらいと考えます。
  • トップ(アプリ)レイヤの実装者がどんなログを出力したいのかを決める。
  • トップレイヤの実装者がその上でサードパーティロガーを採用することを決めるのはあり。
  • エラーハンドラのサードパーティライブラリは個人的には採用しないほうが良いと考えます。
  • なぜならGoは「チュートリアルを終えたメンバーが読み書きできる」というのも良さの一つなのです。

ログに出力

  • 一番シンプルなのはmain関数やgoroutineのトップレベル関数に決める
  • 上記の深さで戻り値のerrがnilではない場合にログに出力すると良い。
  • 後続の処理が続行不可能なエラーならlog.Fatalなどプロセスが終了するログ関数を呼べば良い。
  • 必須の設定ファイルがないような場合もlog.Fatalなどでプロセスを終了したほうが良い。
  • それより深いレイヤではfmt.Errorf("... failed: %w", err)でラップして上流へエラーを伝搬させます。
  • Debugログはできるだけ関連処理に近しいところで出力しましょう。

コールスタックツリー

  • main()
    • taskA()
    • taskB()
    • ...

main()をログ出力担当に割り当てた場合、それより深いレベルの関数で失敗する可能性のある処理は必ずerrorを返して上流にエラーハンドリング処理を委譲すること。

goroutineでのエラー

  • goroutineをデーモンのように有限個を起動する場合、goroutineトップレベル関数でログ出力するのはあり。
  • 不定個数のgoroutineの場合は https://godoc.org/golang.org/x/sync/errgroup を使いgoroutine終了待ちのレイヤまでエラー情報を持ち帰る方法が便利。
  • 上記errgroupの実装は非常にシンプルでGoの並行処理記述の基礎レベルで書かれているので一読を推奨。

意図的にエラーを無視する

以下のような記述をみたことはあるでしょうか?

defer fp.Close()

Close()メソッドの多くはerrorを返すはずなんですが無視しています。このような記述は実は標準ライブラリにもよく見られますが、エラーを無視してるのは良くないんじゃないかと思うかもしれません。

標準ライブラリのClose()メソッドの役割は観測している限り、以下のようなものです。

  • キャッシュ等の後始末があるなら行うがエラーがあればエラーを返す
  • 握っているOSリソースを解放し、エラーはnilで返す

この役割のうち前者を持つ場合、あらかじめそれを解消するメソッドが用意されていることが多いです。例えば*os.Fileの場合、Sync()メソッドをあらかじめ呼ぶことで解消済みにすることができます。そうするとClose()はハンドリングの必要なエラーを返すことはなくなります。
(Close済みを再度Closeしようとしたというようなエラーは起こりえますが、後述の書き方ではその状況は回避できます)

なのでファイルへの書き込みを行う処理は以下のように書くことがお勧めです。こうしてfp.Close()の戻り値エラーは意図的に無視するのがGoの標準スタイルです。

fp, err := os.Create("sample.log")
if err!=nil {
	// エラーハンドリング
}
defer fp.Close()
// fpに対する書き出し
if err := fp.Sync(); err != nil {
	// エラーハンドリング
}

また、仮にClose()がその他の理由によるトラブルがあるとして、それはかなり致命的な状況かと思います。そういうもののほとんどはpanicすべき状況です。つまりエラーハンドリングしてもしょうがない状況(多重クローズも含む)を除くと、Closeでエラーが返されることはありません。

逆にいうとユーザー定義型にClose()メソッドを生やす時も*os.FileのClose()メソッドに期待される仕様であることを期待されがちです。それを順守しておくのがお勧めです。

クラウドネイティブなエラー出力方法

  • 標準エラーつまりlog.Print系で出力するだけ。
  • クラウドネイティブな考え方だとこれがベスト。
  • ファイルに残す、ファイルローテ、クラウドロガーに投げ込む、何らかのフックからイベント発生など。
  • 標準エラーの内容をキャプチャしてこういった処理を行うのは親プロセスの役割。
  • systemdやcontainerd、AWS-Lambda、Cloud-Runなど。
  • ログレベルを分ける場合は多くてもDebug(開発者向け)、Info(対応不要)、Error(要対応)の3種くらい。これより増やしても利用者に対する説明が冗長になるだけ。
  • シンプルなパターンとしてはfmt.Print(標準出力)がInfoログ、log.Print(標準エラー)がErrorログ扱いにすることも検討しましょう。
  • ログレベルを分けるならアプリごとにloggerパッケージを作るのを検討してください。
  • ログレベル別にlog.Loggerインスタンスを作ります。
  • どうしてもトップレベルでない場所でのログ出力や上位レイヤにログ出力方法を決めてもらう必要があるなら*log.Loggerインスタンスを差し込める作りにしましょう(参考:http.ServerのErrorLogフィールドのように)
  • もちろん、Debugレベルはビルドタグで出力先をioutil.Discardにすげ替えることでリリースビルドを作ることができます。(メソッドが空のlog.LoggerダミーインスタンスでもOK)

サンプル

https://play.golang.org/p/q0ciD0zGEb5

package main

import (
	"play.ground/logger"
)

func main() {
	logger.Debug.Print("Hello!")
	logger.Info.Print("World!")
}
-- go.mod --
module play.ground
-- logger/logger_release.go --
// +build release

package logger

import (
	"io"
	"io/ioutil"
	"log"
	"os"
)

var (
	writer io.Writer = os.Stderr
	Debug            = log.New(ioutil.Discard, "DEBG: ", log.LstdFlags|log.Lshortfile)
	Info             = log.New(writer, "INFO: ", log.LstdFlags)
)
-- logger/logger.go --
// +build !release

package logger

import (
	"io"
	"log"
	"os"
)

var (
	writer io.Writer = os.Stderr
	Debug            = log.New(writer, "DEBG: ", log.LstdFlags|log.Lshortfile)
	Info             = log.New(writer, "INFO: ", log.LstdFlags)
)
DEBG: 2009/11/10 23:00:00 prog.go:8: Hello!
INFO: 2009/11/10 23:00:00 World!

スタンドアロンでファイルにも残したい

package logger

import (
	"io"
	"log"
	"os"
)

var (
	Debug  *log.Logger
	Info   *log.Logger
)

func init() {
	fp, _ := os.Create("error.log")
	writer := io.MultiWriter(os.Stderr, fp)
	Debug = log.New(writer, "DEBG: ", log.LstdFlags|log.Lshortfile)
	Info = log.New(writer, "INFO: ", log.LstdFlags)
}

Discussion