「Go言語による並行処理」の気になった所まとめ2 エラー伝播

前回の記事に続き「Goによる並行処理」の気になった所を書いて行きます。

今回は、 5.1 エラー伝播 について書きたいと思います。 4.5エラーハンドリングという箇所で、並行処理の中でエラーを握りつぶすべきではない、Ether(RustだとResult)のようなもので成功と失敗のどちらかを持つ結果を返すべきと書いてありました。この章ではエラーをどう伝えていくのかという方に焦点があたっています。 そして、データの流れは慎重に設計するが、エラーの流れはあまり考えられていないと問題視しています。

エラーで必要なこととして下記のことが挙げられています。

  • 何が起きたのか
  • いつどこでエラーが発生したか
  • ユーザー向けの読みやすいメッセージ
  • ユーザーがさらに情報を得るにはどうするべきか

また、すべてのエラーは次の2つのうちのどちらかに分類できると書かれています。

  • バグ
  • 既知のエッジケース(例: ネットワーク接続の切断、ディスクへの書き込みの失敗など)

上記のことを踏まえて、エラーはコンポーネントごとにふさわしい形に変換して返すべきと言う主張があります。下記の引用とサンプルコードが記載されています。

エラーが「低水準コンポーネント」で発生したとして、上位のスタックに渡されるべくエラーがきちんとした形になっていたとしましょう。 「低水準コンポーネント」の文脈では、このエラーはきちんとした形になっていると思っていたかもしれませんが、それを含むシステム全体の文脈ではそうではないかもしれません。 各コンポーネントの境界では、下から上がってきたエラーは自分のコンポーネント向けにきちんとした形のエラーになるように包んで整えてやらなければなりません。

func PostReport(id string) error {
    result, err := lowlevel.DoWork()
    if err != nil {
        // エラーがきちんとした形になっているか確認して、
        // そうでなければ誤った形のエラーを単に上位のスタックに戻して、バグである旨を示唆する
        if _, ok := err.(lowlevel.Error); ok {
            // 自分のモジュール向けの付加情報とともにやってきたエラーを包んで新しい型にする
            err = WrapErr(err, "cannot post report with id %q", id)
        }
        return err
    }
    // ...
}

エラーをラップするというのは、不要な情報を隠蔽することにもなり注意が必要ではあると書かれています。 また、自分たちの作った型のエラーであればコントロールできているが、そうでない不正なエラーの場合はバグであるとみなすことができるとのことです。その不正なエラーを潰していくことでシステムは成長していくということを主張しています。不正なエラーの場合は少なくとも予期しないエラーが発生ししたことをユーザーに伝えるべきとのことです。

下記が、サンプルコードを動く形にしたものとなります。runJobBadlowlevelモジュールのエラーをラップしていないので、具体的なエラーが不明となっています。一方runJobはラップすることでどういうことをしようとしてエラーが発生したのかを伝えることとなっています。

package main

import (
    "fmt"
)

type MyError struct {
    Inner      error
    Message    string
    StackTrace string
    Misc       map[string]interface{}
}

func wrapError(err error, messagef string, msgArgs ...interface{}) MyError {
    return MyError{
        // 包んでいるエラーを保管する。調査する必要があるときに低水準のエラー見れるようにする
        Inner:   err,
        Message: fmt.Sprintf(messagef, msgArgs...),
        // エラーが作られたときにスタックトレースを記録するためのもの
        StackTrace: string(debug.Stack()),
        // 雑多な情報を保管するための場所
        // エラーの診断をする際に助けになる並行処理のIDやスタックトレースのハッシュ、
        // あるいは他のコンテキストに関する情報を保管する
        Misc: make(map[string]interface{}),
    }
}

func (err MyError) Error() string {
    return err.Message
}

// "lowlevel" モジュール
type LowLevelErr struct {
    error
}

func isGloballyExec(path string) (bool, error) {
    info, err := os.Stat(path)
    if err != nil {
        // os.Statの呼び出しから発生する生のエラーをカスタマイズしたエラーで内包する
        // 今回の場合、このエラーから出てくるメッセージは特に問題ないので、それをそのまま使う
        return false, LowLevelErr{(wrapError(err, err.Error()))}
    }
    return info.Mode().Perm()&0100 == 0100, nil
}

// lowlevelパッケージの関数を呼び出す別のモジュール intermediate

// "intermediate" モジュール
type IntermediateErr struct {
    error
}

// エラーをラップしない、よくないversion
func runJobBad(id string) error {
    const jobBinPath = "/bad/job/binary"
    isExecutable, err := lowlevel.isGloballyExec(jobBinPath)
    if err != nil {
        // lowlevelモジュールからのエラーを渡す
        // 独自の型で内包されないまま他のモジュールから渡されたエラーをバグとみなすので、
        // この実装は後々問題となる
        return err
    } else if isExecutable == false {
        return wrapError(nil, "job binary is not executable")
    }
    return exec.Command(jobBinPath, "--id="+id).Run()
}

func runJob(id string) error {
    const jobBinPath = "/bad/job/binary"
    isExecutable, err := isGloballyExec(jobBinPath)
    if err != nil {
        // 付加情報を加えたメッセージでエラーをカスタマイズする
        return IntermediateErr{
            wrapError(
                err,
                "cannot run job %q: requisite binaries not available",
                id)}
    } else if isExecutable == false {
        return wrapError(
            nil,
            "cannot run job %q: requisite binaries are not executable",
            id,
        )
    }
    return exec.Command(jobBinPath, "--id="+id).Run()
}

func handleError(key int, err error, message string) {
    // 何が起きたかを掘り下げる必要が出てきたときのためにすべてのエラーをログ出力する
    // これを実行すると、次のようなログメッセージが表示される
    log.SetPrefix(fmt.Sprintf("[logID: %v]: ", key))
    log.Printf("%#v", err)
    fmt.Printf("[%v] %v", key, message)
}

func main() {
    log.SetOutput(os.Stdout)
    log.SetFlags(log.Ltime | log.LUTC)
    err := runJob("1")
    if err != nil {
        // ここでエラーが期待した型かどうかを確認する
        // きちんとした形式のエラーならば、メッセージを単純にそのままユーザーに渡せる
        msg := "There was an unexpected issue; please report this as a bug."
        if _, ok := err.(IntermediateErr); ok {
            msg = err.Error()
        }
        // この行ではログとエラーメッセージをID1として紐付けている
        handleError(1, err, msg)
    }
}