TT Lab
はじめる
学ぶ 学習パス コース

Goでサーバを作る

if err != nilがうんざりする理由

TT Labで続きを見る

一言でいうと

Goはエラーを値として返します。そのためエラー処理を飛ばすことはできず、代わりにコードが長くなります。その長さはコストではなく、明示性の対価です。

なぜ必要なのか

例外のある言語では、どの行が例外を投げうるのかがコードを見てもわかりません。だから「ここで落ちる可能性はあるのか」を確認するには、呼び出しグラフをたどる必要があります。

Goはそれをシグネチャに書きます。

func ReadConfig(path string) (*Config, error)

この関数が失敗しうるという事実が型に含まれています。呼び出す側はそれを無視できません。無視するには_と書く必要があり、それは意図が表に出ます。

エラーのラップ

エラーをそのまま上へ返すと、「どこで起きたか」が失われます。%wでラップします。

if err != nil {
    return fmt.Errorf("설정 읽기(%s): %w", path, err)
}

%wは元のエラーを中に抱えます。そのため、上位でerrors.Isやerrors.Asを使って原因を問い合わせられます。

if errors.Is(err, os.ErrNotExist) { ... }

var perr *os.PathError
if errors.As(err, &perr) { fmt.Println(perr.Path) }

%vでラップすると文字列だけが残り、この問い合わせができなくなります。ラップするときは常に%wです。

いつpanicするのか

ほとんどしません。panicは「プログラムが動き続ける理由がない」場合だけです。初期化の失敗や不変条件の違反がそれです。リクエスト処理中のpanicはそのリクエストだけを落とすべきなので、HTTPサーバーはデフォルトで捕捉してくれます。

よくある勘違い

「エラーをログに残し、さらに返す」: そうすると、同じ事故がログに何度も出力されます。処理するか、上へ返すか、どちらか1つだけにします。ログは最終的に処理する場所で1回だけ残します。

「nilのエラーなら結果は有効」: Goの慣例としては正しいですが、強制ではありません。ライブラリによっては、両方とも有効だったり、両方ともnilだったりします。ドキュメントを確認します。

センチネルエラーとカスタム型のどちらを使うのか

呼び出し側が何を知る必要があるかで決めます。

「この場合かどうか」だけわかればよいならセンチネルです。 パッケージレベルで値を1つ宣言し、 errors.Isで比較します。

var ErrNotFound = errors.New("not found")

if errors.Is(err, ErrNotFound) { ... }

追加の情報が必要なら型です。 どのフィールドがなぜ誤っているのかといった情報は、値に入れておく 必要があります。

type ValidationError struct {
    Field  string
    Reason string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Reason)
}

var verr *ValidationError
if errors.As(err, &verr) {
    log.Printf("필드 %s 가 문제: %s", verr.Field, verr.Reason)
}

注意すべき落とし穴が1つあります。nilのカスタムエラーをerrorインターフェースに 入れると、err != nilが真になります。

func do() error {
    var e *ValidationError    // nil
    ...
    return e                  // ❌ 인터페이스는 (타입, nil) 이라 nil 이 아니다
}
if do() != nil { /* 오류가 없는데 여기로 들어온다 */ }

具象型の変数をそのまま返さず、エラーがなければreturn nilと明示します。 Goでもっとも古い落とし穴で、今でも出てきます。

複数のエラーを一度に

Go 1.20から、errors.Joinで複数のエラーをまとめられます。バリデーションのように「全部集めて一度に 知らせる」必要がある場面に向いています。

var errs error
for _, f := range fields {
    if err := f.Validate(); err != nil {
        errs = errors.Join(errs, err)
    }
}
return errs      // 하나도 없으면 nil 이다

fmt.Errorfも%wを複数受け取れます。errors.Isはまとめられたものをすべてたどります。

層ごとに何を付けるのか

エラーをラップするときは、自分が知っていることだけを付けます。下の層がすでに言ったことを 繰り返すと、メッセージが長くなるだけです。

層 付けるもの 付けないもの
リポジトリ テーブル・キー SQL全文(ログにのみ)
サービス 何をしようとしたか リポジトリの詳細
ハンドラー リクエスト識別子 内部構造

そして、ユーザーに送るメッセージとログに残すメッセージを分けます。 内部の パスがそのままHTTPレスポンスに出てしまうと、それが情報漏えいです。

if err != nil {
    log.Printf("주문 조회 실패: %v", err)          // 전체 사슬
    http.Error(w, "일시적인 오류입니다", 500)       // 사용자에게는 이것만
}

実務で本当に大切なこと

エラーメッセージは、上から下へ読んだときに経路になるように書きます。

사용자 조회(id=42): 설정 읽기(/etc/app.yaml): open /etc/app.yaml: no such file

各層が自分の知っている文脈を1つずつ付けると、このようになります。大文字で始めず、ピリオドで終えないのが慣例です。ほかのメッセージの中に埋め込まれるからです。