🥅

Goalieでdefer文のエラーを正しく扱う

に公開

本記事は golang.tokyo #40で発表したLT「Never miss defer'd errors!」の解説資料です。
スライドは talks.ras0q.com を参照ください。

https://x.com/ras0q/status/1962463413508202897

よくある defer 文での悩み

Goでアプリケーションやツールの開発を行う際、誰しも一度は defer 文でのエラーハンドリングに困ったことがあるのではないでしょうか。
現時点でピンと来ていない方もいると思うので、簡単な例を紹介します。 defer 文を使ったことがある人ならすぐに納得できる内容だと思います。


次のコードは、引数に与えられたファイルパスからファイルを開き、何らかの処理をする関数 (processFile) です。
開かれたファイルリソースは、関数終了時に正常に閉じる必要があります。ファイルを正常に閉じておくことで、システムから不当に占有され続けるのを防ぎます。

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close() // ⚠️
    // ... Do something ...

    return nil
}

ここで、ファイルを閉じる関数 f.Close() は返り値にエラーを持ちますが、ここでは実行だけしてそのエラーを受け取る処理を書いていません。

govet や golangci-lint などの linter を通して errcheck をプロジェクトで有効化している場合は、以下のエラーを受け取ります。
このエラーに苦しんだ人は多いのではないでしょうか。

errcheck: Error return value of `f.Close` is not checked


errcheck の警告に対する対策として、以下のように f.Close() の返り値エラーを使い、ログとして出力するパターンをよく見かけます。

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
-   defer f.Close() // ⚠️
+   defer func() {
+       if cerr := f.Close(); cerr != nil {
+           log.Printf("failed to close: %v", cerr)
+       }
+   }()
    // ... do something ...

    return nil
}

先ほどの全くエラーを無視していた場合と比べ、ログにエラーが出力される分開発者がエラーを発見しやすくなりました。
しかし、関数 processFile 自体には f.Close() のエラーは伝わっておらず、この関数は実際にはエラーが起きているにも関わらず正常に終了してしまいます。


defer 文で実行する処理はファイルのクローズだけではありません。データベース接続を切ったり、HTTPクライアントのレスポンスボディを閉じたり、様々な箇所で defer 文は用いられます。
そして、返り値のエラーを無視するたびに errcheck から同じ警告を貰います。


そして開発者であるあなたは、この警告にうんざりし、遂には警告をすべて無視する制御コメントを書いてしまうのです...

func processFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
-   defer f.Close() // ⚠️
+   defer f.Close() //nolint: errcheck

    // ... Do something ...

    return nil
}

こうなってしまってはあなただけでなく他の開発者も defer 文でエラーが起きているか全く気づけません。

どうすればよかったのか

では、本当はどのように対策すればよかったのでしょうか。

このdefer文での問題を解決するために、「Goalie」というライブラリを開発しました。


Goalie?

Goalie (ras0q/goalie)は、 defer 文で実行する関数のエラーハンドリングに特化したライブラリです。
機能は1つだけ、defer 文で実行した f.Close() などの 関数のエラーを収集し、呼び出し元 (先ほどの例の processFile) でまとめて返すというものです。

https://github.com/ras0q/goalie

ちなみに、「Goalie」という名前はゴールキーパーの別名から取っています。
意味的にも「Go」から始まることもピッタリで、かなり気に入っています。

In many team sports that involve scoring goals, the goalkeeper (sometimes termed goaltender, netminder, GK, goalie, or keeper) is a designated player charged with directly preventing the opposing team from scoring by blocking or intercepting opposing shots on goal.
https://en.wikipedia.org/wiki/Goalkeeper


Goalie を用いると、以下のように defer のエラーをハンドリングすることができます。

-func processFile(path string) error {
+func processFile(path string) (err error) {
+   g := goalie.New()
+   defer g.Collect(&err) // ✅ Collect errors...

    f, err := os.Open(path)
    if err != nil {
        return err
    }
-   defer f.Close()
+   defer g.Guard(f.Close) // ✅ Guarded!

    // ... do something ...

    return nil
}

返り値を 名前付き返り値 にし、関数の最初で err という変数を定義します。
これにより、return より後のタイミングで呼び出し元に返すエラーを加工することができます。

名前付き返り値を設定したら、次はGoalieのインスタンスを作成し、g.Collect(&err) で今定義したエラー err と紐づけて後続のエラーを収集します。
エラーは最後に収集して呼び出し元に返す必要があるので、defer で実行します。
ちなみに、g.Collect は返り値を持たないため、この行が errcheck に警告されることはありません。

+   g := goalie.New()
+   defer g.Collect(&err) // ✅ Collect errors...

次に、先ほどファイルクローズを行っていた箇所をGoalieでラップします。これにより、クローズでエラーが発生しても先ほど設定した defer g.Collect(&err) で回収することができます。

-   defer f.Close()
+   defer g.Guard(f.Close) // ✅ Guarded!
Goalieのデモ

これまで使ってきた processFile に、ファイル書き込み処理 writeToFile を加えます。
しかし writeToFile にはバグがあり、引数に渡されたファイルをこの関数の中で閉じてしまっています。これでは、writeToFileprocessFile で二度ファイルが閉じられることになってしまい、二度目に呼ばれた processFile での f.Close() ではエラーが発生するはずです。

このデモでは、このバグを正しく検知し、正しく異常終了する (言葉って難しい) ことを確かめます。

// 指定されたパスを開いて文字列を書き込む関数
func processFile(path string) (err error) {
    g := goalie.New()
    defer g.Collect(&err)

    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer g.Guard(f.Close)

    // ファイル書き込み
    writeToFile(f)

    // 別の処理でもエラーが起きたと仮定
    return ErrInternal
}

var ErrInternal = errors.New("internal error")

// ファイルに文字列を書き込む関数
// 🚨 渡されたファイルをこの関数内でCloseしている
func writeToFile(f *os.File) {
    f.Write([]byte("Hello, golang.tokyo!"))
    f.Close()
}

processFilemain 関数から呼び出し、実行すると、正しくエラーを error として検知できていることが分かります。

func main() {
    err := processFile("input.txt")
    if err != nil {
        fmt.Printf("[ERROR]:\n%+v\n\n", err)
        fmt.Println(
            "is os.ErrClosed?:",
            errors.Is(err, os.ErrClosed),
        )
        fmt.Println(
            "is ErrInternal?: ",
            errors.Is(err, ErrInternal),
        )
    }
}
$ go run main.go
[ERROR]:
internal error
close go.mod: file already closed

is os.ErrClosed?: true
is ErrInternal?:  true

実装解説

実は、Goalieの実装のコア部分はこの14行のみでできています。
Guardでエラーをスタックし、Collectで結合して1つのエラーにするというシンプルな実装です。

type Goalie struct {
    errs []error
}

func (g *Goalie) Guard(errFunc func() error) {
    if err := errFunc(); err != nil {
        g.errs = append([]error{err}, g.errs...)
    }
}

func (g *Goalie) Collect(errp *error) {
    errs := append(g.errs, *errp)
    *errp = errors.Join(errs...)
}

このアイデアは、Go公式で使われているエラーハンドリングのテクニックを基にしています。
[Go Packages] を構成している golang.org/x/pkgsite では、以下のように defer で名前付き返り値の err をラップする処理が頻繁に使われています。

func Wrap(errp *error, format string, args ...any) {
    if *errp != nil {
        *errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
    }
}

// NewServer creates a new Server for the given database and template directory.
func NewServer(scfg ServerConfig) (_ *Server, err error) {
    defer derrors.Wrap(&err, "NewServer(...)")

    s := &Server{ ... }

    return s, nil
}

以下の記事で良くまとめられているので、気になった方はご確認ください。

pkgsite/internal/derrors を使ったGoのエラーラッピング

https://zenn.dev/furon/articles/b7275043eb7dcb


コア実装は14行ですが、他のエラーハンドリングライブラリと共に使うことができるオプションも用意しています。
以下は cockroachdb/errors と統合する例です。

import (
    "github.com/cockroachdb/errors"
    "github.com/ras0q/goalie"
)

func processFile(path string) (err error) {
    g := goalie.New(
        goalie.WithJoinErrorsFunc(errors.Join),
    )
    defer g.Collect(&err)

    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer g.Guard(f.Close)

    // ... Do something ...

    return nil
}

Goalieを今すぐ使う

元を返せば大量の errcheck の警告に疲弊して //nolint を書くに至ってしまったため、Goalieが開発されたからとて逐一入れるのはそれはそれで億劫です。

安心してください。マイグレーションツールを用意しています。
既存のプロジェクトで以下のコマンドを実行し、Goalieが組み込まれているのを確認してください。

go run github.com/ras0q/goalie/usegoalie/cmd/usegoalie@latest -fix ./...

余談ですが、マイグレーションツールはLLMをかなり頼っています。ライブラリ本体ではない (最悪バグが起きても支障にならない) 便利機能をLLMに任せるのは良い体験でした。
また、使わせたいライブラリのマイグレーションツールを use{foo} のような形式のCLIとして提供すると短く正確に意図を伝えることができるためそこそこ気に入ってます。

終わりに

ご清聴ありがとうございました。
(ここまで記事を読んでくださった方もありがとうございます。)

是非 Goalie をあなたのGoプロジェクトに取り込んでみてください。
気に入ったらGitHubにスターを押してもらえるととても喜びます。

Discussion