PHPにおける例外クラスの設計考察

Posted by Hiraku on 2017-12-02

この記事はPHP Advent Calendar 2017の2日目です。

ここ最近、本業のほうが死ぬほど忙しく、すっかりブログを書いていなかった@Hirakuです。

だいぶ前のことですが、今年のPHPカンファレンス福岡で例外の話をしてきました。

この時の発表では例外に関する概要が主だったので、例外クラスそのものの設計について補足を書いてみることにします。

例外のある世界観

PHP7ではいくつかのエラーが例外と同じ挙動を示すようになり、エラーではなく例外機構を使う言語に様変わりしました。

例外というのは、かなり侵略性の高い概念であり、例外のある世界では以下の前提に立つことが暗黙のうちに強要されます。

  • どこでも例外が発生する可能性がある
  • 例外によって中断されても、ソフトウェアが不適切な状態にならないように保証するのは各プログラマの責任(例外安全)

たとえば、以下の関数で例外が発生する可能性があるのはどの行でしょうか?

function createStruct(DateTimeImmutable $now): Struct {
    $obj = new Struct;    // 1行目
    $obj->setNow($now);   // 2行目
    return $obj->init();  // 3行目
}

正解は1~3全てです。これはStructクラスがあなたの手によって書かれ、全メソッドで例外をthrowしていないとしても 発生する可能性は消えません。

もう、PHPは言語自体が例外を投げることがある世界になっています。例えばシグナルや、ユーザーによるリクエスト中断など。今の仕様では例外ではなくても、いつ例外になってもおかしくないものがあります。

それに、ある日突然、Structクラスが他のクラス/ライブラリに依存するようなコードに変更されたとすると、自動的に依存先のクラス/ライブラリがthrowする例外が継承されるようになります。

こんな世界では、「try~catchで全例外を捉えて起こさないようにするぞ!」というのは厳しすぎます。そうではなく、以下を前提にするべきです。

  • どこでも例外が発生する可能性がある
  • 例外によって中断されても、ソフトウェアが不適切な状態にならないように保証するのは各プログラマの責任(例外安全)

例外安全に関しては福岡の資料で書いたのでそちらを参照して下さい。

PHPでthrowできるもの

「どこでも例外が発生する可能性がある」

そんな不確かな場所で、コードなんて書いてられるか!という気持ちにもなりますが、一方でPHPの場合、throwされてくるものに強い制限があるのでした。

PHPではthrow句の後ろに書けるものはThrowableインターフェースを継承したインスタンスだけに制限されています。(PHP5系ではExceptionクラスか、それを継承したクラスのインスタンスのみ。)

なので、例外として飛んできたものは必ず以下のメソッドが生えています。

  • getMessage(): string
  • getCode(): int
  • getPrevious(): Throwable

個人的にはこの制約は便利だと思っていて、最低限のインターフェースが割と豊富なので、このフォーマットに皆が合わせれば色々と生きやすくなります。

例) PHPUnitの例外ハンドリング

いくつかのフレームワークでは、どんな例外が起きたとしても必ずこれらのメソッドが生えていることを利用して、フォーマット化して例外を表示してくれます。

たとえばテストフレームワークであるPHPUnitで、テスト実行中に例外が起きると、以下のような表示になります。

.E..................................

Time: 59 ms, Memory: 4.00MB

There was 1 error:

1) Spindle\Collection\CollectionTest::testFoo
Exception: example message

/Users/hiraku/src/github.com/spindle/spindle-collection/tests/CollectionTest.php:16

{例外クラス名}: {getMessage()の中身} という感じですね。

例) symfony/consoleの例外ハンドリング

コマンドラインツールを作るときによく使われる、symfony/consoleだと、処理中に例外が発生すると以下のような表示を自動で作ってくれます。

symfony-console1.png

これも例外クラス名とgetMessageの中身を採用していますね。

messageやcodeは何を書くべきか

例外のコンストラクタは3つ引数を取ります。

  • string $message
  • int $code
  • Throwable $previous

今までに述べたような使われ方を想定するに、$messageは何が起きたのかをなるべく詳細に書くべきでしょう。 日本語か英語かは特に規定されていないのでプロジェクトの方針次第ですが、固定文言だけ、とかではなく状況を詳しく説明するidや時刻なども埋め込むと良いでしょう。

$codeに関しては、PHP内でも特にこれと言った標準はないように思います。キャッチ側と握って特定のコードを入れてもいいですし、使わなければ省略か0でかまわないでしょう。

ちなみに、webアプリケーションのエンドユーザーに対しては詳細すぎるエラーは表示するべきではない、みたいな議論もありますが、

  • 例外はあくまで詳細なmessageを作る
  • 隠したいなら一度キャッチしてハンドリングする

という方針であるべきです。そもそも、例外を投げる方は、誰がこの例外を受け止めるかを制御できないのですから。

例外の投げ直しと$previousの活用

場合によっては、依存先のライブラリが投げてきた例外をキャッチして、別の例外として投げ直すことがあります。

もし素直に例外を作り直して投げてしまうと、実質$eが握りつぶされ、真の原因がわからなくなってしまいます。

try {
    $foo = doSomething();
} catch (\Exception $e) {
    throw new MyException('doSomethingに失敗しました');
}

こういうときは、コンストラクタの第三引数である $previous を活用しましょう。

try {
    $foo = doSomething();
} catch (\Exception $e) {
    throw new MyException('doSomethingに失敗しました', 500, $e);
}

こうすると、真の原因を辿れるようにしつつ、表面上の例外クラスを差し替えることができます。 symfony/consoleとかでも、ちゃんとpreviousには対応されてて、こんな風に表示してくれます。

symfony-console2.png

もっと複雑な例外を作る

たとえば、HTMLフォームのバリデーションエラーを考えてみます。

  • family_name
  • first_name
  • family_name_kana
  • first_name_kana
  • gender
  • email

これらのうち、first_name_kanaとemailに入力エラーがあったとします。 messageは、適当に改行で複数メッセージを詰めても良いでしょう。

first_name_kanaにカタカナで入力してください。
emailは100文字以内で入力してください。

しかし、実際の場面を考えると、以下のような処理を作りたくなるのではないでしょうか。

  • フォームの具体的な場所に赤文字でエラーを出したい
  • そもそもの入力値も表示したい

こういうとき、ただのstringである $message だけでやりくりするのは厳しいものがあります。なので、例外クラスに他の値も蓄えてしまうと良いでしょう。

class ValidationException extends \RuntimeException
{
    private $params;
    private $errors;
    
    public function setParams(array $params): self
    {
        $this->params = $params;
        return $this;
    }
    
    public function getParams(): array
    {
        return $this->params;
    }
    
    public function setErrors(array $errors): self
    {
        $this->errors = $errors;
        return $this;
    }
    
    public function getErrors(): array
    {
        return $this->errors;
    }
}

で、例外を投げる場面でこんな風にします。

/* //こんなイメージ
$params = [
    'first_name_kana' => 'たろう',
    'email' => 'aaaaaaa...aaa@gmail.com',
    //...
];
 */
 
$errors = [
    'first_name_kana' => 'first_name_kanaにカタカナで入力してください。',
    'email' => 'emailは100文字以内で入力してください。',
];

throw (new ValidationException(implode("\n", $errors), 400))
   ->setParams($params)
   ->setErrors($errors);

これなら、catchしたValidationExceptionから$paramsや$errorsを取り出せて、後処理もしやすいでしょう。

例外にsetterを生やす技

PHPの例外は、newした箇所で自動的にスタックトレースを収集し、蓄えるという特徴があります。 この機能を活かすならば、例外クラスのnewは極力ベタ打ちして、関数化なども避けたほうが便利です。

一方で、例外クラスのコンストラクタは標準が決まっており、コンストラクタ引数を変更するのはあまり行儀がいいとも言えません。

そこで、先程のようにreturn $thisするsetterを生やすようにすると、その場で拡張パラメータを増やすことができて使い勝手が良くなります。

まとめ

PHPの例外というと、ともすればどんな例外型を使うべきか、みたいな議論が多くなりがちですが、 どちらかと言えば $message に何を書くべきかの方が重要なポイントだと思います。

うまく例外の示す契約に乗ると、PHPUnitなどの各種フレームワークとも過ごしやすくなりますので、一度例外設計を見直してみてはいかがでしょうか。

PHPアドベントカレンダー2017の一覧 https://qiita.com/advent-calendar/2017/php

PHPの最新記事