Goalieでdefer文のエラーを正しく扱う
本記事は golang.tokyo #40で発表したLT「Never miss defer
'd errors!」の解説資料です。
スライドは talks.ras0q.com を参照ください。
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
) でまとめて返すというものです。
ちなみに、「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
にはバグがあり、引数に渡されたファイルをこの関数の中で閉じてしまっています。これでは、writeToFile
と processFile
で二度ファイルが閉じられることになってしまい、二度目に呼ばれた 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()
}
processFile
を main
関数から呼び出し、実行すると、正しくエラーを 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のエラーラッピング
コア実装は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