PHP でどのように Exception/RuntimeException/LogicException を使い分けるか

  • 102
    いいね
  • 0
    コメント

PHP は各種プログラム言語の中でも比較的高級な (表現力が豊かで最適な記述を選ぶのに知識を必要とする) 例外モデルを持っていると言えます。そんな PHP の例外の各区分とその使い分けを整理し、PHP の例外モデルの設計意図を考察したいと思います。

PHP例外の分類

PHP の例外は Java とは異なり、(Error を合わせると) 合計 4 つの区分に分類されます。Java には 2 区分しかありません。(PHP では Java の Error に相当するものは発生しません。PHP の Error は Java では RuntimeException の一種に分類されています)

PHP Java
なし Error
Error
LogicException
RuntimeException
RuntimeException
Exception Exception

Exception

Exception から派生した例外のうち、LogicException / RuntimeException でないものは、それらとは明確に区別されます。

この種の例外が発生した場合、クライアントコードはかならずエラーハンドリングしなければ (あるいは、発生させた場合はハンドリングされることを期待しなければ) なりません。

Throwable
    Exception
        << UserException >>

PHP は Java と異なり、コンパイラで型チェックしないため、例外の捕捉に関する文法エラーを出すことができません。仕方がないので代わりに、捕捉されるべき例外を phpdoc に @throws として書き、その旨を API 仕様として示します。

/**
 * @throws SomeException
 */
public function someProcess()
{
    // ...
    throw new SomeException("これは正常系では扱えないから代替処理して");
}

(@throws にある例外が RuntimeException のときは捕捉必須ではありません)

コール側は、try-catch で捕捉するか、さらに @throws を使ってコールスタックの上に投げるか、必ずそのどちらかを選ばなければならない、という決まりにします。

PHP でも Java でも、捕捉しなければならない例外は、API 仕様の一部です。代替処理の仕様が明確に定義されている場合、その仕様をコードで表明させるために、捕捉必須例外を使います。通常、例外とは第二の return と言えます。

とはいえ、起こりうることを仕様書にすべて列挙するのはばかげています。API 仕様としての例外は、その文脈に自然に登場する概念 (会計なら金額が合わない、など) のみにすべきです。

(もしもディスクが破損してファイルシステムからソースが消えたら... もし DNS がサーバー名を解決できなかったら... そのような心配をいちいち表明していてはきりがありません。)

Error

Error は PHP 7.0 から導入された概念で、Exception とともに Throwable インターフェースを実装するものです。

Error はアプリケーションのロジック中で発生させたり、捕捉したりしてはいけません。これは、言語上とても基本的な何かが間違っているという問題をプログラマーに教えるためにあります。なので、迂闊な例外処理に紛れてしまわないようになっています。

Error は Exception との継承関係がないので、未熟なプログラマーが catch (\Exception $e) と書いても絶対に捕捉されません。 (それと同時に、フレームワーク最上位で何らかの独自処理ができなければならないため、Throwable の一種となっています。)

Throwable
    Error 
        TypeError
        ParseError
        AssertionError
        ArithmeticError
        DivisionByZeroError

もしコンパイラがあれば、コンパイル時にエラーになってしまうようなものが Error に当たります。

Error は、PHPUnit やフレームワークのロガーでのみ取り扱われます。

もし Error が本番環境で起きるなら、それはコード品質管理上の深刻な問題です。リリースされたコード変更が、テスト時に実行されていなかったことを示すからです。

LogicException

プログラムのロジック内でのエラーを表す例外です。 この類の例外が出た場合は、自分が書いたコードを修正すべきです。

LogicException は Exception の一種として提供されていますが、この種の例外は Error と同じようにアプリケーションロジック中で捕捉してはいけません。

LogicException は、ライブラリ作者にとっての Error です。PHP コードを書くプログラマーは自分で Error を発生させることができないので、Exception を使うことになります。Error が PHP 言語上の異常なら、LogicException は API の使い方における基本的な間違いを示す異常です。

Throwable
    Exception
        LogicException
            BadFunctionCallException
                BadMethodCallException
            DomainException
            InvalidArgumentException
            LengthException
            OutOfRangeException

どんなとき LogicException を発生させるべきかというと、異常の原因がクライアントコードのクラスのロジック内にあると思われるとき、つまり、プログラマーの誰かに責任があると言えるときです。

もし発生した場合は、いくらインフラレベルで努力しても解消できないバグなので、すぐにプログラマーを招集します。

RuntimeException

実行時にだけ発生するようなエラーの際にスローされます。

LogicException が、起こしてはいけない、修正されるべきバグであるのに対して、PHP の RuntimeException は、いくらロジックで努力しても、どうしても発生してしまう、運用/インフラレベルの障害の発生を表します。

Throwable
    Exception
        RuntimeException 
            OutOfBoundsException 
            OverflowException 
            RangeException 
            UnderflowException 
            UnexpectedValueException 
            PDOException 

名前に注意してください。Java の RuntimeException は、コンパイル時に対する実行時という意味ですが、PHPの RuntimeException はどちらかといえば、テスト環境に対する実行環境に近いニュアンスを持ちます。実行時というよりは、運用時と言い換えたほうが近いかもしれません。

たとえば、テスト用の仮想環境ではまず起こせない障害に、トランザクション中のケーブル断線があります。アクセス過多でバッファが枯渇するかもしれません。昨日まで JSON を返していたサーバーが急に "It works." しか言わなるかも...

RuntimeException の捕捉は任意です。捕捉しなければならないわけでも、捕捉してはいけないわけでもありません。

が、特別な理由がないかぎり、RuntimeException はなるべく捕捉しないことをオススメします。捕捉必須例外でないということは、どうせマトモな代替処理が書ける状況ではありません。であれば、下手なことをするより不具合の直接原因をそのまま表しているほうが有用です。

一般的に RuntimeException は @throws を書きません。網羅すると多くなりすぎるからです。しかし、任意での捕捉がとくに有効な例外があることを表したい場合は、ユーザーに気づかせるため @throws に書くことがあります。

RuntimeException を発生させるのは、プログラマーの責任とは限らないような異常終了が起きたときです。

もし発生しても、インフラ/コンフィグとユーザーの利用状況によっては、十分に起こりえる障害かもしれないので、ひとまずプログラマーが慌てる話ではありません。

タグ付け分類

各ライブラリ開発者は、捕捉必須例外によって独自の例外体系を構成することができ、そのルートに独自の例外を設けることで、さまざまな例外を包括的に捕捉することができます。たとえば、NotFound と Forbidden は HttpStatus の一種であると汎化すれば、Web レスポンスを返すところで HttpStatus だけを捕捉するように作ることができます。

しかし、ライブラリ内にも RuntimeException の子孫は必要です。PHP は Java 同様、単一継承の抽象モデルしかないため、extends ではどうしても、RuntimeException の派生を Exception 下の独自例外継承ツリーの仲間にすることができません。

そこで、ライブラリで発生する例外には、ライブラリ固有のマーカーインターフェースを付け、それを分類タグとして用います。

interface MyLibraryException { }

class MyException extends \Exception implements MyLibraryException { }

class MyRuntimeException extends \RuntimeException implements MyLibraryException { }

// トップレベルのエラーハンドラ
try {

} catch (MyLibraryException) {
    // 特定のライブラリから発生する例外は通常の障害報告に入れず
} catch (\RuntimeException $e) {
    // その他の実行時例外はすべて運用時障害とし
} catch (\Exception $e) {
    // 捕捉していない例外はバグ
}

実例から使い分けを学ぶ

PhpStorm での実証

「LogicException / RuntimeException が発生しても @throws を書くべきでないし、@throws がなければどんな例外が発生するかもわからないので捕捉すべきでない」 という理屈の証拠がこれです。

スクリーンショット 2016-12-05 4.53.47.png

PhpStorm はデフォルトで、コードフォーマッタが RuntimeException と LogicException を無視するように設定されています。

class UserException extends \Exception {}

/**
 * @throws UserException
 */
function a()
{
    throw new \LogicException();
    throw new \RuntimeException();
    throw new UserException();
}

LogicException と RuntimeException の使い分け

次のコードは Slim の Container クラスの抜粋です。

class Container extends PimpleContainer implements ContainerInterface
{

    public function get($id)
    {
        if (!$this->offsetExists($id)) {
            throw new ContainerValueNotFoundException(sprintf('Identifier "%s" is not defined.', $id));
        }
        try {
            return $this->offsetGet($id);
        } catch (\InvalidArgumentException $exception) {
            if ($this->exceptionThrownByContainer($exception)) {
                throw new SlimContainerException(
                    sprintf('Container error while retrieving "%s"', $id),
                    null,
                    $exception
                );
            } else {
                throw $exception;
            }
        }
    }

Slim の DI コンテナは Pimple の派生でできています。が、get() 時に起きる例外がより表現豊かになっています。

ContainerValueNotFoundException は RuntimeException です。本来あるべき識別子でオブジェクトが定義されていないのは、おそらくコンフィギュレーションのミスです。ContainerValueNotFoundException が起きたなら、いくらクラス実装を見ても、おそらく何の不具合も見つからないでしょう。DI のコンフィグがミスしているのだから。

SlimContainerException は LogicException です。名前は登録されていた、なのにコンテナの中で InvalidArgumentException が発生する、そんなことは理論上、 Pimple にバグが入らないかぎりありえません。つまり逆にいうと SlimContainerException は、Pimple のリリースによって何かバグが入ったか、挙動が変わったか、そういったデグレ事故を教えようとしてているのです。

RuntimeException と Exception の使い分け

ファイルシステム抽象化ライブラリの Flysystem が独自に定義している例外クラスを集めてみました。

class NotSupportedException extends RuntimeException { }

NotSupportedException は次の箇所で発生しています。

  • ハンドリング未実装のサーバー OS に FTP 接続してしまった
  • シンボリックリンクできない OS でシンボリックリンクを操作しようとしている

こんなことを毎度心配していても無駄ですね。何かの間違いで接続先設定を間違っただけ、これは運用ミスです。

class FileExistsException extends Exception { }
class FileNotFoundException extends Exception { }
class UnreadableFileException extends Exception { }

FileExistsException / FileNotFoundException は、ないと思っていたファイルがあった場合、あると思っていたファイルがなかった場合に発生します。こちらは、プログラマーが常に考慮して、どう扱うかを決めなければならないことです。場合によっては運用時のミスかもしれません (RuntimeException に翻訳して再スローする) し、偶然名前が重複したユーザーに何かメッセージ返す必要がある (「すでに処理済みのようですがどうしますか」表示) かもしれません。

(Flysystem の UnreadableFileException は、適切でない気がします。ディレクトリを削除しようとした時、万一読めないファイルが含まれていたら中止、というとき起きる例外です。これはもしかしたら、本来 RuntimeException であるべきかもしれません。)

マーカーインターフェースの事例

画像ライブラリの Imagine の例外は次のようになっています。

namespace Imagine\Exception;

interface Exception { }
class RuntimeException extends \RuntimeException implements Exception { }
class NotSupportedException extends RuntimeException implements Exception { }
class InvalidArgumentException extends \InvalidArgumentException implements Exception { }
class OutOfBoundsException extends \OutOfBoundsException implements Exception { }

NotSupportedException は Imagine 独自の RuntimeException の一種です。しかし、InvalidArgumentException は PHP の LogicException の子、OutOfBoundsException は PHP の RuntimeException の子、
これらはひとつの継承ツリーにきれいに収まりません。そこで、Imagine\Exception\Exception インターフェースでタグ付けしてグループ化されています。

これを活かすと、RuntimeException や LogicException が起きたとき、Imagine 内で起きた問題のみを特別扱いできます。

try {
    // 複雑な業務と画像処理を同時に行うプロセス
} catch (\Imagine\Exception\Exception $e) {
    Logger::warn("何か画像処理に問題があったようです");
    throw $e;
} catch (\Exception $e) {
    Logger::info("画像処理には問題がりませんでしたが...");
    throw $e;
}

その他トピック

契約的プログラミングと LogicException

LogicException には 2 つの解釈があります。ひとつは、クライアントコードがライブラリに対して保証すべき事前条件を守っていないということ。もうひとつは、ロジック階層の内部に、通常ありえない状態が発生していたことの示唆です。これは、同じことを別の視点から見ているだけで、本質はひとつです。

たとえば、注文個数 0 以下で決済しないことを決済メソッドの事前条件とするなら、その違反は LogicException ではじくべきです。通常の例外でつねに捕捉させるべきものではありません。そんなのは基本的にありえないことだ、でよいのです。もし万が一起きたら、それはきっと、発注先最適化アルゴリズムの計算のどこかがバグっている証拠です。バグがあることを前提にエラーリカバリのシナリオを考えるのは無駄です。すぐにバグ修正リリースを。

質の高いプログラムとは何でしょう。高品質なプログラムが信頼できるのは、「ライブラリ内部の状態が正しければ、ライブラリのライブラリに対する前提条件は破られないはずだ」 という契約が適切に連鎖しているためです。LogicException は、これが論理的に破綻したことを表します。

このメソッド、普通はそんな呼び出し方はしないだろう、と思うような場合でも、万一暴走すると危険だという局面では、できるかぎり LogicException を発生させておきましょう。

たとえば、空文字列の1文字目を得る、なんて馬鹿馬鹿しいことを直接求める人はいません。しかし、空文字列はセットできないはずだと思っていた変数に、実はある特定条件下で書き換えられるとわかったら... その1文字目とは...

生の Exception を避ける

プログラマーに即座にバグを知らせるヒントになるこの貴重な LogicException を、知らない間に try-catch で握りつぶしてしまわないように注意しなければなりません。ここまでの理屈がわかれば、なぜ次のような例外処理が良くないとされているか、理解できるはずです。

try {
    // ...
} catch (\Exception $e) {
    Log::warning($e->getMessage());
}

また、絶対に、通常の捕捉必須例外のつもりで生の Exception クラスを使ってはいけません。もしやってしまうと、意図せず LogicException と RuntimeException (@throws に書かれていない) を握りつぶしてしまうからです。

/**
 * @throws \Exception
 */
function processOne()
{
    if (...) {
        throw new \Exception("処理対象がないから何もしないよ");
    }
    if (...) {
        throw new \LogicException("ひどいエラー");
    }
    // ...
}

try {
    processOne();
} catch (\Exception $e) {
    $message = "なにも起きませんでした”;
    // このブロックが LogicException(ひどいエラー) を握りつぶす
}

RuntimeException の後始末

RuntimeException は、ライブラリの API 仕様にない種類の運用時障害です。起きた時点でほぼ復帰の望みはありません。だからすぐに中断して終わってしまえ、といっても、問題となったリクエスト以外は正常なのであれば、他のリクエストを邪魔する可能性があるもの (リソースロックなど) は、メソッドから外に出る前に片付けておく必要があります。

そのさい、捕捉しない例外がそのままの形で上に投げられているかを意識しましょう。

try {
    // ...
    // success
} catch (SomeRecoverbleException $e) {
    // 失敗時の後処理
    // なにか意味のある復旧処理
} catch (\Exception $e) { // その他、捕捉しろという指定のない例外は RuntimeException も巻き添えで
    // 失敗時の後処理
    thrown $e; // 片付けだけして捕捉しなかったフリをする
}

何があっても同じ後処理でよい場合、finally 句を活かして、\Exception を記述するのを避けましょう。余計なものを書いてしまうと、ミスが混入するリスク、可読性を下げるリスクがあります。書かなくて済む場合は書かないのがもっとも安全なのです。

次のコードは、finally で書き換えることができます。

$this->workingResource->prepare();
try {
    // ...
    $this->workingResource->clean();
} catch (SomeRecoverbleException $e) {
    // なにか意味のある復旧処理
    $this->workingResource->clean();
} catch(\Exception $e) {
    $this->workingResource->clean();
    throw $e;
}
$this->workingResource->prepare();
try {
    // ...
} catch (SomeRecoverbleException $e) {
    // なにか意味のある復旧処理
} finally {
    $this->workingResource->clean();
}

捕捉: 実は現在のバージョンの PhpStorm (2016.3.1) ではこの RuntimeException 後始末をうまく扱えません。詳細と対策については PhpStorm 2016.3 の例外インスペクションどうしたらいい? - なんたらノート第三期ベータ にて。

Javaとの比較で理解する

分類の数の差

Java の例外の大分類は、捕捉必須である一般的な Exception と、捕捉しなくてもよい RuntimeException のたった二種です。(JVM の発生させる Error は PHP と比較しようがないのでいったん無視します)

PHP に比べると Java は、捕捉必須でない例外が非常にざっくりしています。

Java でもやはり、PHP でいう Error や LogicException のようなものがあります。メソッドコール時にオブジェクトが null だった場合は NullPointerException が発生し、ゼロ除算すれば ArithmeticException が起きます。これは起きないようにバグを修正すべきです。

また、PHP の RuntimeException にあたる BufferOverflowException / BufferUnderflowException もあります。これらを封じるためにと、バグ修正と称してデータを黙ってドロップしたら大変です。運用上の障害の証拠として、潰してはならない実行時例外です。

この二者が、ともに、Java では同じ RuntimeException になります。

PHP の例外は高級で曖昧な例外モデルで、Java の例外は低級で厳密な例外モデルといえます。

分類のしかたの差

何を Exception とし、何を RuntimeException とするかの指針に、PHP と Java とでかなり差があります。

Java はプログラム言語の世界の外部で起きることをいっさい信用していません。IOException も SQLException も捕捉必須例外です。

という事情から、Java には try-catchthrows を必要とする箇所が多すぎて、ひとまず例外を握りつぶして書いてみて、ついついそのまま忘れてしまう、というような話をよく聞きます。

たしかに、汎用プラグラミング言語 Java にとって、通信障害のような例外は「起こり得る例外的状況、想定の範囲内」です。

いっぽう、Web に特化した PHP にとっての通信障害やファイルアクセス失敗は、「起きてしまうとどうしようもない状況、想定外」です。なので、 PDOException は RuntimeException の一種です。

PHP は、アプリケーションが稼働するインフラ全体を、まるで Java が JVM のメモリを信用するように、いったん全面的に信じ、まあけど障害が出たら出た時さ、というラインで手を打った言語だと言えます。

分類の数を増やし、どこからがバグで、どこからが障害なのかを決めておくと、責任の所在が明確になります。

Java が記述時にエラーを起こさせない方へ努力する機械的な言語だとすると、PHP は、エラーを起こした事実を事後にうまくさばける人間的な言語を目指している、そんな印象があります。

まとめと問題

このように、PHP の例外は非常に表現力が豊かで、機械の都合ではなく人の都合で問題を扱ってくれる、なかなか出来のいいヤツです。

しかし現状、この使い分けポリシーをすべての PHP プログラマーが共有できているとは言えません。フレームワーク作者でさえ、それぞれ異なる思想を持っているのが実情です。また、PHP 5.1 以前からの伝統を引き継ぐ文化やエクステンションでは、SPL に含まれる LogicException と RuntimeException への依存を避ける習慣さえあります。

個人的に、この意識の違いに関して、私がもっとも影響が大きいと感じたのは、Symfony の例外設計です。Symfony の内部設計には、捕捉必須例外にあたる独自の例外体系がありません。ほぼ全ての例外が、LogicException か RuntimeException のどちらかに分類されています。

Slim の HTTP リクエストにおいて、NotFoundException は Exception の子孫となっています。それが発生しうるメソッドに素直に @throws を書き、どこでどんな HTTP ステータスを取るか知るヒントとすることができます。

しかし Symfony の HttpKernel では、NotFoundHttpException は RuntimeException です。それだと、HTTP ステータスはコントローラーの仕様にならず、いつどんな値を取るかわからない、暗黙的な障害と混ざる存在になってしまいます。

これは、どちらが正しいということではなく、人や文化によって大きく差があり、フレームワーク/ライブラリ間で相互運用時のギャップとなっているということです。このエントリで説明した解釈もまた、あくまで私が理解した範囲でのまとめにすぎません。

このように、PHP の例外は、ポテンシャルは高いけど、人に優しい自由さがあるため逆に、人によって認識のずれが生じるものでもあります。願わくば、いつか、これが標準のベストプラクティスだというモデルが現れて、誰もがそれを認識できる世界になりますように。

参考文献: » Exception Best Practices in PHP 5.3 Ralph Schindler