じぶん対策

日々学んだことをアウトプットして備忘録にしています。

PHPの例外入門!

はじめに

PHPやLaravelを使って開発をしていると、例外処理という言葉をよく耳にします。
前半は例外処理について、初学者を対象に解説していきます。(PHPにはこういう機構があるよといった入門的な部分)
後半はPHP,Laravelの例外の概要と、筆者の考える実践パターンをいくつか紹介します。

対象読者

  • ジュニアエンジニアで、PHP/Laravelを使った開発を始めたばかりの人
  • PHP以外の言語経験があり、PHPにおける例外処理の仕組みを知りたい人
  • WEB業界に入ったばかりの駆け出しエンジニアで、エラーハンドリング?なにそれおいしいの?状態だった過去の自分

例外処理とは

まずは知らないIT用語を調べるときに大変お世話になるサイトから見てみましょう

参考: わわわIT用語辞典

例外処理(読:レイガイショリ 英:exception handling)とは
(想定内の)エラーが起きたときにやる処理のこと
です。

エラーには種類があります。

実はエラーは2種類に分けられます。
1.想定内のエラー
2.想定外のエラー
の2つです。

例外処理は、プログラムの実行中に発生する予期せぬエラーに対処するためのプログラミング手法です。 プログラムはエラーが発生した際にも制御された方法で動作を継続または適切に終了することができます。

具体的には、例外が発生した時に

  • リトライする
  • エラーの内容をログに記録する
  • メールやSlackなどで通知する
  • ユーザーに対してエラーが発生したことを通知する

といった処理を行います。

上記は概念的な例外処理についての説明ですが、例外という単語は、プログラミング言語の機能としての文脈で使用されることもあります。
プログラミング言語の機能を利用して、上記の例外に対して任意の処理を実装することも例外処理と呼ばれます。

例外処理の基本概念

PHPに限らず、多くのプログラミング言語(JavaC#など)では似たような例外処理の仕組みが用意されています。
Go,Erlang,Rustなどとは例外の考え方が少し異なるので、これらの言語の経験しかない場合は少し戸惑うかもしれません。

  • try-catch-finally構文tryブロック内でコードを実行し、エラー(例外)が発生した場合にはcatchブロックでそれを捕捉し、処理します。finallyブロックはエラーの有無に関わらず、最後に実行されるコードです。
  • 例外の伝播:例外が発生した場合、それを捕捉して適切に処理するか、あるいはさらに上のレベルへ伝播させることができます。
  • カスタム例外の定義:特定のエラー条件に対応するために、独自の例外クラスを定義することが可能です。

PHPにおける例外機構

PHPでも上記のtry-catch-finally構文を使って例外処理を行うことができます。(公式ドキュメント 例外)

PHPにおける例外処理は、PHP 5以降で導入され、PHP 7では更に拡張されました。これにより、開発者はコードの実行をより柔軟に制御し、エラー発生時の動作を明確に定義できます。

コード例(PHP8.2)

PHPの例外処理は、以下のような特徴を持ちます。
公式からの引用

例外がスローされ、現在の関数スコープに catch ブロックがなかった場合、 その例外は、マッチする catch ブロックが見つかるまで関数のコールスタックを "遡って" いきます。 その途中で見つかった全ての finally ブロックが実行されます。 グローバルスコープに遡るまで全てのコールスタックを探しても、マッチする catch ブロックが見つからない場合は、 グローバルな例外ハンドラが設定されていない限り fatal error となり、 プログラムが終了します。

わかりやすく、以下のようなコードを書いてみましょう。パターン別に2つ例外処理を書いてみます。

パターン1 呼び出し元関数側で例外をキャッチして例外処理

function divide($dividend, $divisor) {
    if ($divisor == 0) {
        throw new Exception("除算エラー:0で除算しようとしました。");
    }
    return $dividend / $divisor;
}

function doCalculation() {
    try {
        $result = divide(5, 0); // ここで例外が発生
    }
    catch (Exception $e) {
        echo "エラーが発生しました: " . $e->getMessage();
    }
    return $result;
}

この例では、divide()関数内で$divisorが0の場合に例外を発生させ、doCalculation()関数内でその例外を捕捉しています。
ちなみに、PHPでは文字列連結には.を使います。+を使用する言語もありますが、PHP.を使用するのはPerl由来のものだと思われます。

パターン2 呼び出し元関数側で例外をキャッチせず、さらにその呼び出し元で例外処理

PHPでは例外が発生すると、その例外は呼び出し元の関数に伝播します。

function divide($dividend, $divisor) {
    if ($divisor == 0) {
        throw new Exception("除算エラー:0で除算しようとしました。");
    }
    return $dividend / $divisor;
}

function doCalculation() {
    return divide(5, 0); // ここで例外が発生
}

try {
    doCalculation(); // この関数の内部で例外が発生
} catch (Exception $e) {
    echo "エラーが発生しました: " . $e->getMessage(); // ここで例外をキャッチ
}

doCalculation関数はdivide関数を呼び出し、divide関数で例外が発生します。しかし、doCalculation関数内にはtry-catchブロックがありません。 このコードでは、doCalculation関数内で発生した例外が、関数外のtry-catchブロックで捕捉されます。つまり、例外は発生した場所からコールスタックを遡り、最初に見つかった適切なcatchブロックで処理されるのです。

このようなコールスタックを遡って、より上位のレベルの処理にコントロールを移す手法は大域脱出と呼ばれます。
大域脱出自体は例外に限った用語ではないですが、基本的には例外に関することが多いようです。

PHPの例外クラス

PHPの例外処理はExceptionクラスを中心に構築されています。このクラスは、エラーメッセージ、エラーコード、ファイル名、エラーが発生した行番号などの情報を提供します。例外はthrowキーワードで発生させ、catchブロックで捕捉します。

try {
    // 例外を発生させる
    throw new Exception("エラーメッセージ", 1);
} catch (Exception $e) {
    echo "例外が捕捉されました: " . $e->getMessage();
}

PHP 7のErrorクラス

PHP 7では、Exceptionクラスとは別にErrorクラスが導入されました。これにより、PHPエンジンによる致命的なエラー(例えばメモリの不足やタイプエラー)もオブジェクト指向の方法で処理できるようになりました。ErrorクラスはExceptionクラスと同様の方法で使用できますが、主に内部システムエラーを表すために使われます。

try {
    // 致命的なエラーを発生させる
    throw new Error("致命的なエラーが発生しました", 1);
} catch (Error $e) {
    echo "エラーが捕捉されました: " . $e->getMessage();
}

ただし、基本的にErrorは開発中に発生するものであり、ユーザーには表示されないようにするのが望ましいです。
例えば、最もよく目にするのはType Errorですが、開発中に発生した場合は発生しないように修正するのが望ましく、ハンドリングするコードも書くことは少ないでしょう。

カスタム例外

PHPでは独自の例外クラスを作成し、特定のエラーシナリオに適した例外処理を実装できます。これにより、アプリケーション固有のエラーハンドリングを作成し、エラーの原因をより明確にすることが可能です。

class MyCustomException extends Exception {
    // カスタム例外クラスの実装
}

try {
    throw new MyCustomException("カスタムエラーが発生しました");
} catch (MyCustomException $e) {
    echo "カスタム例外が捕捉されました: " . $e->getMessage();
}

PHPの例外機構を使用することで、エラーの特定、捕捉、および処理をより効果的に行うことができ、より安全で信頼性の高いアプリケーションを構築することが可能になります。

PHPにおける例外の関係性

PHPの例外処理は、以下のような関係性を持ちます。
すべてのthrowされるオブジェクトは、Throwableインターフェースを実装しています。

  • Throwable
    • Exception
      • ErrorException
      • RuntimeException
        • LogicException
        • ...
    • Error
      • TypeError
      • ParseError
      • ...

Exceptionクラスは、すべての例外の基底クラスです。
Errorクラスは、すべてのエラーの基底クラスです。
これらの仕組みは、Javaなどの言語と近い構造です。

Throwableをキャッチすることもできるので、以下のようなコードを書くことで、投げられる例外全てをキャッチするようにすることもできます。

try {
    throw new Exception("エラーメッセージ", 1);
} catch (Throwable $e) {
    echo "例外が捕捉されました: " . $e->getMessage();
}

Javaとの例外区分の違い

Javaでは、例外には検査例外と非検査例外の2種類の例外があるらしいです。

参考: 段階的に理解する Java 例外処理

検査例外(Checked Exeptions)

コンパイル時にチェックされる例外。コンパイルを通すためには、try-catchで例外をキャッチするか、throwsで例外を投げることを明示する必要がある。

非検査例外(Unchecked Exceptions)

コンパイル時にチェックされない例外。実行時にエラーが発生するもの。

PHPでは、全ての例外は検査されないため、コンパイル時に例外をチェックすることはありません。つまりこの観点からは全ての例外は非検査例外と言えます。

ただしPHPコミュニティの中でもスタンスが分かれる部分でもあるらしく、

  • PHPの例外は全て非検査例外である
  • RuntimeException以外は検査例外である

という意見があるようです。

前者はコンパイル時の警告有無で区別していて、後者はRuntimeExceptionがあるのでこちらを実行時エラーとして、それ以外は検査例外としているということですね。
スタンスの是非には触れませんが、Javaの例外の区分はPHPにおける例外を区分するために参考になるかもしれません。

Laravelにおける例外機構

Laravelにおける例外処理の公式ドキュメントを要約して紹介します。

Laravelの例外処理システム

  • 中心的なハンドラー: App\Exceptions\Handlerクラスは、Laravelで発生するすべての例外をハンドリングする中心的な場所です。このクラスをカスタマイズすることで、アプリケーション特有の例外処理を実装できます。
  • 例外のレポーティング: Laravelでは例外が自動的にアプリケーションのログに記録されます。開発者はこの情報を利用して、発生したエラーの分析と対処が可能です。
  • カスタム例外の作成: 特定のエラーシナリオに適した独自の例外クラスを作成し、これを利用して例外を投げることができます。これにより、エラーハンドリングをより柔軟に行うことができます。

具体的なエラーハンドリングの方法

  • HTTPレスポンスのカスタマイズ: 例外に基づいてカスタマイズ可能なHTTPレスポンスを生成することが可能です。これにより、ユーザーに向けたより適切なフィードバックを提供できます。
  • ログとエラーレポーティング: Laravelでは、例外が発生した際に自動的にログに記録されるため、開発者はエラーの原因を追跡しやすくなります。

Laravelにおける例外の種類

PHPフレームワークであるLaravelでは、PHPのExceptionを継承するかたちで、独自の例外クラスを用意しています。

その詳細な種類については割愛しますが、以下の記事が参考になるかもしれません。

例外の命名の参考にするために Laravel の例外すべて漁ってみた

自作例外の実装によるメリット

さて、これまで見た通り、PHPでは独自にExceptionクラスを継承して例外クラスを作成することができます。
独自の例外クラスを作成することで、以下のようなメリットがあります。

  • 独自の例外を定義することで、発生したエラーの種類と原因をより明確にできる
  • 例外のメッセージ、処理を統一し、一貫性を保つことができる
  • 独自の例外を用いることで、発生したエラーがアプリケーション固有のものか、ライブラリやフレームワーク起因のものかを区別できる

例外処理における基本戦術

Laravelでは、例外処理をまとめて行うApp\Exceptions\Handlerクラスが用意されています。
つまり、フレームワーク側の思想としては、それぞれの処理で例外を投げて、Handler内でその例外処理を記述することを想定しているとも取れます。
例外という機構がコールスタックを遡っていく(大域脱出)という性質を利用して、例外処理をまとめて行うことは自然な流れです。

例外処理におけるアンチパターン

例外処理において、基本的に避けるべきアンチパターンを紹介します。

  • 例外の無視

最もわかりやすく、避けるべきアンチパターンは、例外を握りつぶすことです。
例外を無視した場合にいいことは何もなく、単に怠惰から無視されることがほとんどです。
どんなに問題が発生しにくいコードでも、エラーチェックとエラーハンドリングは必要です。

  • 例外をアプリケーションの制御フローに使用する

たまに見かけるのは、for文でループする最中に例外を投げて、break的に例外を利用しているようなものです。
プログラム上の異常を示す例外を通常フローの処理に使用するのはあきらかな誤用なので避けましょう。

  • 広すぎるキャッチ

PHPは例外クラスの設計上、Throwableをキャッチすれば全ての例外をキャッチできます。
ただし、これを行うと細かいエラーハンドリングを全て無視してしまうことになります。
Handlerなどの機構のメリットを活かせなくなってしまうので、各ユースケースでは想定している例外をキャッチし、それ以外は上位のレイヤーに任せるようにしましょう。

参考:
エラーを無視するな
例外設計における大罪

例外以外のエラーハンドリングとそのトレードオフ

いくつかのプログラミング言語では、PHPのtry-catchとは異なるエラーハンドリングのアプローチを採用しています。その背景にはtry-catchを使用することによる課題が存在します。

  • return以外の制御フローを持つ
    • 通常、関数はreturnによって上位の関数に返されます。プログラミングにおいて、処理の出口を1箇所にするという規約(MISRA-C)もあるように、処理の複雑さが増加することになります。
  • throwした場合にcatchされる箇所に処理が移動する。
    • 実質的にgoto文と同じように処理順が飛躍するため、処理を追いにくくなってしまう問題があります。
  • 呼び出し元のメソッドは、呼び出し先のメソッド内でthrowされる例外を知っておく必要がある

エラーハンドリングの方法には、これまで紹介した例外処理以外の方法もあります。

他の言語で例外処理以外の方法を採用しているものをいくつか調査してみました。

Scala

try-catch-finallyという例外機構自体は存在するようです。

import scala.util.{Try, Success, Failure}

def divide(x: Int, y: Int): Try[Int] = {
  Try(x / y)
}

val result = divide(10, 0) match {
  case Success(value) => s"結果は$valueです"
  case Failure(exception) => s"エラー: ${exception.getMessage}"
}

println(result)

ただ、関数型言語の性質上、関数の入出力以外の作用(副作用)を避けるために、あまり推奨されていません。

ほかに用意されている方法として、Eitherという抽象クラスを利用します。
eitherは「どちらか一方」という意味を持ち、LeftRightという2つのサブクラスを持ちます。
Tryとくらべると、例外処理以外の用途も表現でき、一方が正しい値、もう一方がエラー、もしくは代替の値を表現することができます。
TryよりEitherが一般的に使用されているようです。

def safeDivideEither(x: Int, y: Int): Either[String, Int] = {
  if (y != 0) Right(x / y) else Left("0で割ることはできません")
}

val result = safeDivideEither(10, 0) match {
  case Right(value) => s"結果は$valueです"
  case Left(error) => s"エラー: $error"
}

println(result)

Rust

Rustには「例外」の概念が存在しません。代わりに、主に以下の2つの方法でエラーを扱います:

  1. Result: RustのResult<T, E>型は、操作が成功した場合の値(Ok)またはエラーの場合のエラータイプ(Err)を格納します。これは関数型プログラミングの影響を受けたアプローチで、エラーが発生する可能性のある関数は、そのエラーを明示的にResult型として返す必要があります。

     fn divide(x: i32, y: i32) -> Result<i32, &'static str> {
         if y == 0 {
             Err("0で割ることはできません")
         } else {
             Ok(x / y)
         }
     }
    
     let result = match divide(10, 0) {
         Ok(value) => format!("結果: {}", value),
         Err(e) => format!("エラー: {}", e),
     };
    

    この例では、divide関数はResult<i32, &'static str>型を返します。これにより、エラーの発生がコードの中で明示的に扱われます。

  2. panicマクロ: Rustでは、通常の操作中に回復不能なエラーが発生した場合、panicマクロを使用してプログラムをクラッシュさせることができます。これはJavaのunchecked exceptionに似ていますが、Rustでは通常、本当に回復不能なエラーにのみ使用されます。

     fn risky_operation() {
         panic!("何か問題が発生しました");
     }
    

Rustでは、エラーは常に明示的に扱われる必要があります。これはJavaのchecked exceptionに似ていますが、RustではResult型を使ってエラーの存在を明示的に表現します。
Rustではエラー処理がより前面に出ており、エラーの可能性を無視することが難しいと言えます。

Go

Go言語のエラーハンドリングはJavaとは大きく異なり、よりシンプルで明示的なアプローチを採用しています。Goでは「例外」の概念がなく、エラーは普通の値として扱われ、プログラムのフローの中で明示的に管理されます。

  1. エラーは値: Goでは、エラーはerrorインターフェース型の値として表されます。このインターフェースはError()という単一のメソッドを持ち、エラーメッセージを文字列で返します。

  2. 明示的なエラーチェック: Goの関数は、エラーが発生する可能性がある場合、通常はその結果とともにエラーを返します。呼び出し元はこのエラーをチェックし、適切に対処する必要があります。

     func divide(x, y int) (int, error) {
         if y == 0 {
             return 0, errors.New("0で割ることはできません")
         }
         return x / y, nil
     }
    
     result, err := divide(10, 0)
     if err != nil {
         fmt.Println("エラー:", err)
     } else {
         fmt.Println("結果:", result)
     }
    

    この例では、divide関数は結果とエラーの両方を返し、エラーがある場合はそれがチェックされ、処理されます。

  3. panicrecover: Goにはpanicrecoverというメカニズムもありますが、これらは通常のエラーハンドリングとは異なり、主にプログラムが回復不能な状態に陥った時に使用されます。panicはプログラムをクラッシュさせ、recoverはプログラムがpanicから回復しようとする際に使用されます。

Goのエラーハンドリングのアプローチは、エラーを単なる値として扱い、プログラムの自然なフローの中でそれらを管理することに重点を置いています。

個人的なPHPを使用する場合の例外処理のベストプラクティス

これまで、PHP、Laravelにおける例外処理の仕組みとその課題、他の言語におけるエラーハンドリング方法について見てきました。
その中で、PHPの例外処理における課題を解決しつつ、うまく言語機構に乗っかったベストプラクティスを考えてみます。

課題について再度整理すると、

  • return以外の制御フローを持つ
  • throwした場合にcatchされる箇所に処理が移動する
  • 呼び出し元のメソッドは、呼び出し先のメソッド内でthrowされる例外を知っておく必要がある

という3つの課題がありました。

これらの解決をPHPで実践するためにPHPで取れるアプローチは、以下のようなものが考えられます。

  • できるだけ各階層で例外をキャッチして投げ直す
  • PHPDoc等を利用して、呼び出し元で投げられる例外を知る

できるだけ各階層で例外をキャッチして投げ直す

これは処理が大域脱出する際に追いづらくなることの回避を目的としています。ただ、例外処理で書くことがなく、投げ直すだけになってしまうことが多くなりすぎるとあまりメリットを享受できないため、設計の段階でどのようなレベルまで例外を投げるかを決めておく必要があります。
ある程度レイヤー分けされたアプリケーションであれば、レイヤーごとに自作例外を用意するのも方法としては検討できそうです。

ビジネスロジックを書いたドメイン層と、その外側の層では利用する例外は分けた方が良く、この区別のために自作例外を用意するといいと思います。
よくあるのは、UnauthorizedExceptionやNotFoundExceptionといった、HTTPステータス由来の例外をビジネスロジックからスローしてしまうような例です。
分離のために、ビジネスロジックからは独自の自作例外を投げ、それをHandlerのような外側の層でキャッチして、HTTPステータスに変換するといった仕組みにしておけば、関心を分離することができます。

また、想定内のエラーと想定外のエラーを区別して処理することも有効な手段です。
Webにおいて、エラーは全てレスポンスとして返されます。

Laravelの場合はHandlerに例外が到達した時点でAPMに自動的にエラーとして記録する挙動がデフォルトなので、想定内のエラーについてはそれぞれのコントローラー部分で適切にレスポンスを生成して返し、想定外のエラーについてはサーバーエラーとしてHandlerまで伝播させるといった方法を取ることができます。

PHPDoc等を利用して、呼び出し元で投げられる例外を知る

PHP自体が動的型付け言語であるため、内部で投げられる例外を事前に言語側の仕組みで全て把握することは難しいです。
少しでも例外を把握するために、PHPDocを利用して、呼び出し元で投げられる例外を知ることができます。
IDEによっては、自動で記載してくれたり、警告を出してくれたりするので、頼っていくのも良いかもしれません。

PhpStormでは、PHPDocの@throwsに応じて警告を出してくれます。

デフォルトでは下記の例外が除外(継承先を含む)されており、それ以外の例外については警告が出るようになっています。

  • \RuntimeException
  • \Error
  • \LogicException

参考:
PhpStorm の throws のチェックがうるさい

PHPDocにおける @throws の使い方 2021 ver.

上記や、先述したJavaでの例外の分類から、予期できない例外はRuntimeExceptionに属すると言えるため、RuntimeExceptionでラップして投げ直すというのが良いかもしれません。

例えば、下記のコードでは、Repositoryで発生した例外をUseCaseで処理します。
検査例外の処理パターンとして、1つ有用な方法として、RuntimeExceptionでラップして投げ直すという方法があります。
このように、例外のラップを行うことで、呼び出し元に情報を伝えることができます。

class UseCase {
    public function execute() {
        $repository = new Repository();
        try {
            $repository->findById();
        } catch (PDOException $e) {
            throw new RuntimeException($e->getMessage(), $e->getCode(), $e);
        }
    }
}

class Repository {
    /**
     * @throws PDOException
     */
    public function findById() {
        // 処理
    }
}

参考: 【Java】汎用的な例外処理の対応方法、リトライの考え方など

まとめ

  • 例外は想定内のエラーと想定外のエラーに分けられる
  • PHPの例外処理はJavaの例外処理と似ているが、検査例外と非検査例外の区別など違う点もある
  • 他の言語では、例外機構の代わりにEitherなどを用いてエラーハンドリングを行うこともある
  • Laravelでは、例外処理をまとめて行うApp\Exceptions\Handlerクラスが用意されている
  • 想定内のエラーの場合、PHPの例外処理における課題の多くは、できるだけ各階層で例外をキャッチして投げ直すことで解決でき、特にドメイン層では独自例外を用意して、関心の分離を行うことができる
  • 想定外のエラーの場合、Handlerを利用してアプリケーション側で正常に扱えないエラーをサーバーエラーとして返すと同時にAPMへの記録をすることができる

所感

最終的に出た結論はHandlerで想定外のエラーを扱い、各コントローラーでは階層ごとに例外をキャッチして投げ直すという方法で、今回の記事を書く前後で大きく変わらない結果となりました。
ですが、他言語での例外の仕組み、Javaでの例外の分類を調べてみることで、自作例外を作成する際の設計の解像度があがりました。

想定している例外と、想定していない例外を区別することが重要で、Webシステムにおいては、想定している例外は最終的に適切なエラーレスポンスを生成して返すことになります。そのため、各層での例外の扱いを明確にすることが重要です。
一方で、想定していない例外についてはサーバーエラーとして扱うほかなく、Handlerを使用して、レスポンスの生成をすると同時にAPMへの記録を行うことで、Laravelの仕組みをうまく活かすことができます。

例外の伝播においてはPHPの場合、PHPDocやIDEのサポートをうまく活用していくことで呼び出し元への情報伝達をうまく行うことができます。

実際にプロダクトのコードを書く場合には今回調査したような例外処理の仕組みとクラスの設計を踏まえて、自作例外側の設計も抽象化ができると関心を分離しつつ楽にハンドリングできそうです。