Fuelで複数種類の例外を捌いてみる

# 本稿の内容を三行で

- 例外は種類を増やすと拾うのが大変
- FuelPHP では**例外クラスも拡張**できる
- なので、ひとつの **catch()** で想定される例外をまとめて捌いてみたいなと

以下本編です。


# 12/5の記事はお読みいただけましたでしょうか

いきなり自分で自分の宣伝をしたいわけではないのですが。
ちょうど1週間前、Advent Calendarでは真上に当たる場所に、こんな記事を投稿いたしました。

[**Fuelで取り回しやすいValidatorを作る**](https://qiita.com/sugimoto_teco/items/b3ed9652d277346c4f1c)

その記事の中で、**InvalidValueException** なる独自例外を投げていたので、真下に当たる今回の記事では、その例外を受け取って、エラー記録・表示処理まで繋いでいきたいと思います。

なお、本稿は**「例外の拡張を使ってこんなことができる」**という試みですので、実用性のほどはわかりませんし、同じ目的に対してもっと簡潔な方法があるかもしれません。
相変わらず長いので、お読みくださる場合は上記予めご了承ください。


# 基本思想

前記事から繰り返しになりますが、
**「様々な場所に書くことになるコードはシンプルで、誰が書いても同じ形になるように」**
というのが根底にあります。

## 投げるほう

- 例外発生はフォーマットを統一したコードで管理・記録したい
PHP の例外は、第一引数にエラーが起きたことを示すテキストを入れて投げられているのが、実装サンプルの常です。
実際、Exception クラスのコンストラクタの第一引数は **String 型**と指定されています。
ですがやはり、例外を投げるよう記述する都度エラー文を考え、あるいは思い出して書き記すのでは、間違いなく参照コストや表記ぶれを生んでしまいます。

- ユーザーには、コードに対応した文章を見せたい
前記事の続きですので、一般の利用者のいる Web サービスを想定しています。となると、
`エラーが発生しました。(エラーコード：XXXX)`
だけでは明らかに不親切で、それ以上にサポートへの問い合わせが膨れ上がる原因になるでしょう。

ですので、**例外は第一引数にエラー(≠エラー文)を持って throw され、表示がそれを解釈する際にエラー文を参照する**ようにします。
何より、多言語に対応する可能性のあるサービスであるなら、**エラー要因とコードが一対一、コードとエラー文が一対多**という設計が理想でしょう。

（Exception クラスのコンストラクタの第二引数は **$code** であり、上記のような運用は第二引数を活用することが望ましいかもしれません。ですが、そこは **Int 型**を要求するのが辛いところです。識別子について、連番で管理するとか、0始まりでない固定長の数字列で各桁に意味を持たせるとかするのであれば、第二引数は大いに利用できると思います。ただ、一意識別子にアルファベットで接頭辞・接尾辞を加えて可読性をよくしようとした場合、第一引数しか置き場がなくなります。そして今回は私の気分で、エラーコードにはアルファベットを入れたかった――`E0000XX`とかよく見るじゃないですか――ので、第一引数を要件としました）

## 拾うほう

例外を複数種類作るなら、

```php
    try {

// ~~中略~~

    } catch( \InvalidValueException $e ) {
            // バリデーションエラー時の処理

    } catch( \LogicErrorException $e ) {
            // 実装した判定でエラーの時の処理   

    } catch( \Exception $e ) {
            // それ以外の（予期しない）エラーの時の処理
    }
```

のように書くのが定石ですが、try-catchを張る度に、その中で投げられる可能性のある全ての例外について分岐と挙動を記すのは大変ですよね。
ですので拾うほうでは、**catch は一つ、その中の処理も共通とする**ようにします。

そして、例外を拾ったら、そこから

- エラーの一意識別子（E_CODE）
- エラーの説明文（E_MESSAGE）

を取得して、エラー用のページに遷移させます。


# 対象とする例外

今回は2種類の自作例外を用意します。

- InvalidValueException

バリデーションに失敗した際に、何が引っかかったかを持って throw される例外。
**「どのフィールドで」「どういう値が」「何の規則で」**引っかかり、**「どんなエラー文が」**返されたか、を**配列で**第一引数に渡しているので、これを Exception クラスの第一引数の要件である **String 型**に変換しつつ、うまいことやるのが最初の目標になります。投げる状況は前記事および次項をご参照ください。

- LogicErrorException

ロジックで諸々判定した末にエラーを返したい時に、理由を添えて throw される例外。
第一引数には前項で記載の通り、エラーの一意識別子を入れて投げます。


# 実装

前記事の実装サンプルを使います。

## 投げ方

自作例外をまとめて記述するファイル `exceptions.php` を用意し、そこに定義を加えていきます。

### バリデーション失敗時の例外（ InvalidValueException )

#### ソース

```php:fuel\app\classes\common\exceptions.php

// validationに失敗した際に投げる例外
class InvalidValueException extends \Exception {

    const REDIRECT_URL 	= \Common_Constants_Error::PLACEHOLDER_REDIRECT_URL_BACK;

    /**
     * constructor(複数のvalidationエラーを整形してmessage化するためoverride)
     * @param array $message [$val->error()]
     * @param int $code(optional)
     * @param throwable $previous(optional)
     */
    public function __construct( $message, int $code = 0, Throwable $previous = NULL ) {
        $valError = array();
        foreach( $message as $field => $error ) {
            $valError[] = array(
                'field' => $field,
                'input' => $error->value,
                'rule'  => $error->rule,
                'param' => $error->params,
                'error' => $error->get_message(),
            );
        }
        parent::__construct( json_encode( $valError ), $code, $previous );
    }

    public function getErrorCode() {
        $result = array();
        $errorList = $this->getMessage();

        foreach( json_decode( $errorList, true ) as $error ) {
            $result[] = \Common_Constants_Error::E_CODE_VALIDATION_LIST[$error['field']][$error['rule']];
        }

        return $result;
    }
}
```

#### 解説

**InvalidValueException** を投げる際、その第一引数 `$message` は、フィールド名を添え字として、バリデーションの詳細を記録したオブジェクトを格納していますが、 override したコンストラクタで、そのオブジェクトから必要な値を取得して配列に整形します。

先述の通り、Exception クラスのコンストラクタの第一引数は **String 型**と指定されていますので、上で整形した配列を `json_encode()` して文字列相当に置換し、例外として投げています。縫い針の穴を通すために糸を縒る、みたいな話です。

例外が拾われた先で `getErrorCode()` が呼び出されると、先ほど"縒って" `$message` に据えた JSON 形式の文字列を、配列に戻し、バリデーションに失敗した数だけ、対応するエラーコードを配列に格納して返却します。


### ロジックでエラー判定の際の例外（ LogicErrorException ）

#### ソース

```php:fuel\app\classes\common\exceptions.php

// ロジックで判定をエラーにする時に投げる例外
class LogicErrorException extends \Exception {

    const REDIRECT_URL 	= 'error';
    protected $errCode  = null;

    public function __construct( $code ) {
        $message = \Common_Constants_Error::E_CODE_MESSAGE_LIST[$code];
        $this->errCode = $code;

        parent::__construct( $message, 0, null );
    }

    public function getErrorCode() {
        return $this->errCode;
    }
}
```

#### 解説

InvalidValueException に比べるとシンプルです。
エラーコードを第一引数として投げられるので、それをプロパティに保存しておきます。
一方で、コードに対応するエラー文を定義から拾ってきて、そのエラー文を第一引数として例外として投げています。

## 拾い方

### ソース

```php:...\controller\customerinfo.php

use Fuel\Core\Input;

class Controller_Customerinfo
{

// ~~ 中略 ~~

    /**
     * ユーザー入力の確認
     */
    public function action_confirm()
    {
        try {

            // 入力された値の検証
            // 何れかの値で検証に失敗した場合、InvalidValueException が投げられる
            $valList = \Util_Validator::execute(
                \Common_Constants_Validation::TYPE_CUSTOMER_INFO,
                array(
                    \Common_Constants_Validation::FIELD_NAME        => Input::post('name'),
                    \Common_Constants_Validation::FIELD_NAME_KANA   => Input::post('nameKana'),
                    \Common_Constants_Validation::FIELD_POSTAL_CODE => (string)str_replace('-', '', Input::post('postalCode')),
                    \Common_Constants_Validation::FIELD_ADDRESS     => Input::post('address'),
                    \Common_Constants_Validation::FIELD_BUILDING    => Input::post('building'),
                    \Common_Constants_Validation::FIELD_GENDER      => (int)Input::post('gender'),
                )
            );

            // フィールド名を変数名にして値を格納
            foreach( $valList as $key => $value ) {
                ${$key} = $value;
            }

            // 既に住所情報が登録されているユーザーだったら LogicErrorException を投げる
            $dbCustomerInfo = \Model_Db_User_Customerinfo::selectByCustomerId( $this->customerId );
            if ( !empty( $dbCustomerInfo ) ) {
                throw new \LogicErrorException( \Common_Constants_Error::CODE_LOGIC_CUSTOMERINFO_DUPLICATE );
            }

        } catch( \Exception $e ) {
            // ログ出力も、ユーザーのIDと Exception クラスを渡すだけで完結するように作ってみましたが、今回は割愛
            new \Common_Logic_Log_History_Error( $this->customerId, $e );

            // セッションに、今回のエラーの一意識別子・対応するエラー文を保存して、専用ページにリダイレクト
            \Session::set_flash( \Common_Constants_Session::E_CODE, $this->getErrorCode( $e ) );
            \Session::set_flash( \Common_Constants_Session::E_MESSAGE, $e->getMessage() );
            $this->_redirect( $this->getRedirectUrl( $e ) );
        }

        // 検証完了後の処理。省略
    }
```

### 解説

自作例外も、Exception クラスを継承していますので `catch( \Exception $e )` で拾えます。
拾った後は、`$e` からエラーコードとエラー文を取得してセッションに保存し、例外ごとに設定する遷移先にリダイレクトさせます。

#### $e->getMessage() とそれ以外の違い

`getMessage()` は Exception クラスのメソッドなので、例外であれば必ず呼ぶことのできるものです。
一方で `getErrorCode()` や `getRedirectUrl()` は今回の拡張クラスで用意したものなので、それ以外の例外を catch した場合はこのメソッドを参照できない可能性があります。

ですので、拡張クラスで用意したメソッドについてはオブジェクトから直接呼ぶのではなく、存在確認を噛ませて参照する必要があります。

```php

    public function getErrorCode( \Exception $e ) {
        return ( method_exists( get_class($e), 'getErrorCode' ) ) ? $e->getErrorCode() : \Common_Constants_Error::E_CODE_GENERAL;
    }

    public function getRedirectUrl( \Exception $e ) {
        $result = ( defined( get_class($e)."::REDIRECT_URL" ) ) ? get_class($e)::REDIRECT_URL : 'error';

        if ( $result === \Common_Constants_Error::PLACEHOLDER_REDIRECT_URL_BACK ) {
            \Session::set_flash( \Common_Constants_Session::E_CODE, self::getErrorCode( $e ) );
            $referer = parse_url( \Input::server('HTTP_REFERER') );
            $result = preg_replace( "/^\//", '', $referer['path'] );
        }

        return $result;
    }
```

`getErrorCode()` については、対象の例外クラスが同名メソッドを持っている場合はその戻り値を、持っていない場合は汎用コード（だいたい「予期せぬエラー」ということになります）を返すようにします。

`getRedirectUrl()` については、対象の例外クラスに対応する定数が存在すればその値を、そうでなければ汎用エラーページを返すようにします。
但し、定数に特殊な文字列を指定されていた場合は、「一つ前のページ」を特定して返却します。


#### InvalidValueException を拾う場合

例として、1～3の数値のみを受け付ける **GENDER** のフィールドに **4** が渡されたとします。
指定範囲に含まれない値なので、バリデーションに失敗し、**InvalidValueException** が投げられます。
その際の第一引数 `$message` は、フィールド名を添え字とした連想配列に、各フィールドにおけるバリデーションの詳細を格納していますが、 **InvalidValueException** のコンストラクタ内で以下のような配列に整形します。
前述のように、この配列は一度 JSON 形式にエンコードされた後、`$e->getErrorCode()` で再び配列に戻されます。

```php
array(
	'field'		=> 'gender',
	'input' 	=> '4',
	'rule'		=> 'numeric_between',
	'param'		=> array(1,3),
	'error' 	=> 'The field gender must contain a numeric value between 1 and 3',
);
```

`getErrorCode()` では、以下のような配列を参照しています。
今回は **GENDER** が **numeric_between** を満たさなかったので、戻り値にはエラーコード **EV00084** が格納されています。

```php:...\common\constants\error.php

    const PREFIX_VALIDATION = 'EV';

    // バリデーションのフィールド毎の、ルールとそれを満たさない場合のエラーコードの対応表
    const E_CODE_VALIDATION_LIST = array(
        \Common_Constants_Validation::FIELD_GENDER => array(
            \Common_Constants_Validation::RULE_REQUIRED        => self::CODE_VALIDATION_GENDER_REQUIRED,
            \Common_Constants_Validation::RULE_PATTERN         => self::CODE_VALIDATION_GENDER_PATTERN,
            \Common_Constants_Validation::RULE_LENGTH_EXACT    => self::CODE_VALIDATION_GENDER_LENGTH,
            \Common_Constants_Validation::RULE_NUMERIC_BETWEEN => self::CODE_VALIDATION_GENDER_NUMERIC,
        ),
    );

    // gender
    const CODE_VALIDATION_GENDER_REQUIRED          = self::PREFIX_VALIDATION.'00081'; // 性別-空の値
    const CODE_VALIDATION_GENDER_PATTERN           = self::PREFIX_VALIDATION.'00082'; // 性別-形式が一致しない
    const CODE_VALIDATION_GENDER_LENGTH            = self::PREFIX_VALIDATION.'00083'; // 性別-文字の長さが適当でない
    const CODE_VALIDATION_GENDER_NUMERIC           = self::PREFIX_VALIDATION.'00084'; // 性別-値が有効範囲の外
```

`getRedirectUrl()` では、**InvalidValueException** のREDIRECT_URLには特別な値を設定しているので、セッションにエラーコードを保存した上で、例外が投げられた際の遷移元に戻るようになります。


#### LogicErrorException を拾う場合

今回のサンプルでは、既に住所登録済みのユーザーが、何らかの方法で入力確認画面に来てしまった場合に、その後の手順に進ませないよう、例外を投げています。

`getMessage()` は、定数で管理している配列から対応するエラー文を取得します。

```php:...\common\constants\error.php

    const PREFIX_LOGIC = 'EL';


    const CODE_LOGIC_CUSTOMERINFO_DUPLICATE = self::PREFIX_LOGIC.'00021'; // 住所情報登録の重複


    const E_CODE_MESSAGE_LIST = array(

        self::CODE_LOGIC_CUSTOMERINFO_DUPLICATE => '既に住所情報が登録されています。',
    );

```

`getErrorCode()` は、投げられた際の第一引数である **EL00021** を返却します。

`getRedirectUrl()` は、定数で指定された汎用エラーページを返却します。


## つまりどうなるかと言うと

異なる種類の例外も、同じ道をたどって、エラーコードとエラー文を、それぞれの例外に沿った遷移先に渡すことができるようになりました。

ちなみに、拡張した例外クラスを使う場合も、同じく **bootstrap への追記**が必要になります。

```php:fuel\app\bootstrap.php
// Bootstrap the framework DO NOT edit this
require COREPATH.'bootstrap.php';

\Autoloader::add_classes(array(

    'InvalidValueException'         => APPPATH.'classes/common/exceptions.php',
    'LogicErrorException'           => APPPATH.'classes/common/exceptions.php',

));
```