Golang 入門

Go Panic と Recover の仕組み

これまでの章では、Go言語が予期されるビジネスロジック上のエラーを処理するために、明示的な error の戻り値(リターン値)を推奨していることを繰り返し強調してきました。これはプログラムを堅牢に稼働させるための常態です。

しかし、予期せぬ事態は常に起こり得ます。配列の境界外(インデックス範囲外)アクセス、nilポインタへのデリファレンス(参照解決)、あるいはプログラム起動時にコアとなるデータベースへの接続に失敗するなど、予測不可能かつ回復不能なエラーが存在します。このようなレベルの災厄に直面した際、通常の error を返却することにはもはや意味がなく、プログラムは即座に停止しなければなりません。

ここで登場するのが、Go言語の panic(パニック)recover(リカバー) メカニズムです。これらは通常のコントロールフローとして使用すべきではなく、プログラムを徹底的な破壊から守るための「最後の防衛線」として理解する必要があります。

1. panic(パニック)を理解する

panic は組み込み関数です。その役割は、Goランタイム(Runtime)に対して「極めて深刻な問題が発生し、現在の正常な実行パスを継続できない」というアラートを発することにあります。

1.1 panic 発生時の内部フロー

コード内で panic がトリガーされると(自ら呼び出した場合でも、ゼロ除算のようにランタイムがスローした場合でも)、以下のような一連の連鎖反応が起こります:

  1. 即時停止: 現在実行中の関数の通常のロジックが瞬時に停止します。
  2. スタック展開 (Stack Unwinding): Goランタイムは現在の関数から「逆戻り」を開始します(コールスタックを逆方向にトラバースします)。
  3. defer の実行: 逆戻りの過程で、各階層の関数で既に登録されている defer ステートメントを厳密に実行します。これは非常に優れた設計であり、たとえプログラムが停止する場合でも、開かれたファイルや取得されたロック(Lock)を安全に解放することを保証します。
  4. プログラムのクラッシュ: 最上位(main 関数や現在のゴルーチン(Goroutine)の起点)まで戻っても、この panic を「収束」させる者がいない場合、プログラムは完全にクラッシュして終了します。その際、コンソールには具体的な panic メッセージと詳細なスタックトレース(Stack Trace)が表示され、バグの特定に役立てられます。

1.2 panic をトリガーするコード例

package main

import "fmt"

func divide(a, b int) int {
	if b == 0 {
		// 主動的に panic を発生させ、説明メッセージを渡す(型は任意だが通常は string)
		panic("致命的なエラー:除数はゼロにできません!") 
	}
	return a / b
}

func main() {
	fmt.Println("プログラム実行開始...")
	result := divide(10, 0) // ここで panic が発生
		
	// panic が発生したため、この行は実行されません
	fmt.Println("計算結果:", result) 
}

上記のコードを実行すると、以下のようなクラッシュメッセージが出力されます:

プログラム実行開始...
panic: 致命的なエラー:除数はゼロにできません!

goroutine 1 [running]:
main.divide(...)
        /path/to/your/file.go:8
main.main()
        /path/to/your/file.go:15 +0x...
exit status 2

1.3 どのような時に panic を使うべきか?

使用すべき場面は 極めて稀 です。真の「絶体絶命」の状況でのみ使用してください:

  • 回復不能なシステムステータス: 例えばメモリデータが深刻に破壊されている場合。
  • 開発者の低レベルなロジックバグ: 本来理論上 switch で到達しないはずの default ブランチに入った場合など。これはコードに重大な欠陥があることを示しており、即座に露出させる必要があります。
  • 必須の初期化の失敗: 例えば Web サービスの起動時に、必須の設定ファイルが見つからない場合など。中途半端な状態で起動を続けるよりも、即座に panic で終了させる方が賢明な選択です(これを Fail-Fast:即時失敗 と呼びます)。

2. recover(リカバー)を理解する

panic が爆弾の起爆であるならば、recover は爆弾処理のスペシャリストです。

recover も組み込み関数です。その唯一の役割は、進行中の panic をインターセプト(遮断)してキャッチし、プログラムのクラッシュを阻止して、通常の実行フローに復帰させることにあります。

2.1 recover の3大鉄則

recover を使って panic を正常にインターセプトするには、以下のルールを厳守する必要があります:

  1. 必ず defer の中で呼び出す: panic 発生後は通常のコード実行が停止するため、defer ステートメント内のコードのみが実行可能です。したがって、recoverdefer 関数内部に配置しなければなりません。
  2. panic 発生時のみ値を持つ: 現在 panic が発生していない場合、recover()nil を返します。panic が発生している場合、panic に渡されたその値が返されます。
  3. 現在のゴルーチン内でのみ有効: recover は現在の並行スレッド(ゴルーチン)で投げられた panic のみを取り除くことができます。他のゴルーチンがクラッシュした場合、それを救い出すことはできません。

2.2 panic のインターセプトに成功する例

上記の除算関数に保護網を被せてみましょう:

package main

import "fmt"

// 爆弾処理スペシャリスト関数
func recoverFromPanic() {
	// recover() を呼び出し、爆弾をキャッチしたかチェック(戻り値が nil 以外か)
	if r := recover(); r != nil {
		fmt.Println("Panic のインターセプトに成功しました!エラー内容は:", r)
		fmt.Println("プログラムは正常な状態に復帰しました。クリーンアップを実行します...")
	}
}

func riskyFunction(value int) {
	// 1. 危険な操作が行われる前に、まず defer でスペシャリストを登録する
	defer recoverFromPanic() 
		
	fmt.Println("危険な操作を実行中...")
	if value < 0 {
		// 2. 爆弾を起爆
		panic("負の値は入力できません!") 
	}
	fmt.Println("無事に通過しました。値は:", value)
}

func main() {
	fmt.Println("--- main 関数開始 ---")
		
	riskyFunction(-5) // 内部で panic が発生するが、自身の defer によって救出される
		
	// 3. panic がインターセプトされたため、main 関数は災厄を知らずに継続実行されます
	fmt.Println("--- main 関数正常終了 ---") 
}

出力結果:

--- main 関数開始 ---
危険な操作を実行中...
Panic のインターセプトに成功しました!エラー内容は: 負の値は入力できません!
プログラムは正常な状態に復帰しました。クリーンアップを実行します...
--- main 関数正常終了 ---

3. 実戦:Web フレームワークにおけるグローバルリカバリ

recover は日常的な業務開発で多用されるものではありませんが、すべての Go Web フレームワーク(Gin, Echo など)やバックエンドのデーモンプロセスにおける標準装備です。

Web サーバーにおいて、ある一つの API 呼び出しのバグで panic が発生したからといって、サーバープロセス全体がクラッシュし、他のすべてのユーザーの接続を道連れにすることは絶対に許されません。

通常、フレームワークは最外層のインターセプター(ミドルウェア / Middleware)に defer recover() を配置します。

package main

import (
	"log"
	"fmt"
)

// Web フレームワークのグローバルリカバリミドルウェアをシミュレート
func SafeHandler(handler func()) {
	defer func() {
		if r := recover(); r != nil {
			// 1. ログに現場の状況を記録する(必須!)
			log.Printf("【致命的エラー】サーバーが Panic をキャッチしました: %v\n", r)
			// 2. panic を発生させたユーザーに対して、フレンドリーな 500 エラーを返す
			fmt.Println("HTTP 500: サーバー内部エラーが発生しました。時間をおいて再度お試しください。")
						
			// 注意:ここで再度 panic をスローしていないため、サーバーのメインプロセスは生存し続けます!
		}
	}()
		
	// 実際のビジネスロジック(リクエスト)を実行
	handler()
}

// 出来の悪いビジネスロジック API
func BadAPI() {
	var a []int
	// 致命的なバグ:空のスライスの10番目の要素にアクセス(index out of range panic が発生)
	fmt.Println(a[10]) 
}

// 正常なビジネスロジック API
func GoodAPI() {
	fmt.Println("HTTP 200: データの取得に成功しました。")
}

func main() {
	fmt.Println("Web サーバー起動中...")
		
	fmt.Println(">> ユーザー A からのリクエストを受信:")
	SafeHandler(BadAPI) // ユーザー A のリクエストが致命的バグを誘発
		
	fmt.Println("\n>> ユーザー B からのリクエストを受信:")
	SafeHandler(GoodAPI) // SafeHandler の保護によりサーバーはダウンせず、ユーザー B は正常にアクセス可能!
}

4. ベストプラクティスと注意点

  • Panic/Recover を Try/Catch として使わない: 繰り返しになりますが、ビジネスロジック上のエラー(パスワード間違い、ファイル不在など)は、素直に return err してください。panic の乱用は、コードのコントロールフローを極めて混乱させ、テストを困難にします。
  • 必ずログを記録する: panic をキャッチした後、それを黙って飲み込んで(握り潰して)はいけません。必ず r の内容を出力し、可能であればその時点でのスタックトレース(debug.Stack())も記録してください。そうでなければ、本番環境のどこにバグがあるのか永遠に分からなくなります。
  • 選択的な再スロー (Re-panic): panic をキャッチして簡単なログを記録した後、そのエラーがあまりに深刻で現在のレイヤーでは処理しきれないと判断した場合は、再度 panic(r) を呼び出して爆弾をさらに上位のレイヤーに投げ返すことも検討してください。