k-holyのPHPとか諸々メモ

Webで働くk-holyがPHP(スクリプト言語)とか諸々のことをメモしていきます。ソースコードはだいたいWindowsで動かしてます。

エラーハンドラと例外ハンドラによるエラー処理 (PHP Advent Calendar jp 2011 Day 11)

@calpo22 さんの記事 PyrusでプロジェクトローカルなPEARライブラリインストール : PHP Advent Calendar jp 2011 Day 10 - くろまほうさいきょうでんせつ にき続き、 PHP Advent Calendar jp 2011 11日目の記事です。

皆さん、エラー処理ちゃんとやってますか?
今時は、設定を書いておけばフレームワークがよしなにやってくれるよ、という方が多いかもしれません。

PHP4時代を戦い抜いた人は、皆自分なりのエラー処理関数を書いてset_error_handler()とtrigger_error()を駆使した経験があると思います。
私も当時は、サブルーチンを呼ぶたびに戻り値をチェックして return FALSE をリレーしていき、レスポンス出力の段階でFALSEが返されていればエラー画面をinclude、といった処理をしていました。

そんな泥臭い時代を経験した私にとって、PHP5に完全移行して一番嬉しかったのが、set_exception_handler()とExceptionクラスを使うことでエラー処理がとても楽になったことです。
戻り値をチェックしなくて済むようになったことで、メソッドチェーンを安心して使えるようになり、今ではExceptionを使わないコードなど考えられません。

一方で、今もWarningを返す関数があり、またPEARを使う場合はPEAR::isError()など忌まわしき過去の呪縛もあります。
コアの設定ディレクティブとの関係が深くバージョン毎の差異もあり、アプリケーションフレームワークに頼らず確実に実施しようとすると、意外とハマってしまうところもあります。

特に独自性もないネタではありますが、そんな縁の下の力持ちのエラー処理と例外処理についての情報をまとめてみます。

エラー関連の設定ディレクティブ

まずは、エラー処理に関係するPHPの設定ディレクティブについて、ざっと紹介します。
説明に(未確認)と書いてあるものについては実際に動作確認したわけではありませんので、興味のある方は自分で確認してください。
なお今回の内容は、Windows版XAMPP1.7.7に同梱のPHP5.3.8で確認しています。

error_reporting (integer) PHP_INI_ALL

多数あるエラータイプ定数のうちどれをエラーとして出力するかを、PHP定数のまたは整数値で記述します。
ユーザースクリプトで設定する場合、error_reporting()関数の利用が推奨されています。
httpd.conf や .htaccess等、PHP定数が使えない場所で設定する場合は整数値で記述する必要がありますが、全てのエラーを意味する特殊な定数 E_ALL の値はPHPのバージョンによって異なるので要注意です。
(将来に渡っても確実に全てのエラーを有効にする方法として 2147483647(32bitの符号付き整数最大値)を設定しましょう、との旨がPHPマニュアルに書いてますが、それはちょっとどうかと思います)

初期値は E_ALL & ~E_NOTICE ですが、少なくとも E_ALL でエラーや警告が発生することなく動作するように書くべきです。
PHP5から追加されたE_STRICTを有効にすれば、推奨されない記述をチェックしてくれます。(5.4からはE_ALLにE_STRICTが含まれるそうです)
PHP5.3から追加された E_DEPRECATED と E_USER_DEPRECATED も、同様に E_ALL に含まれないエラーですが、E_STRICTとの違いはよく分かりません。(何となく前者はPHP5からの非推奨、後者はPHP5.3からの非推奨と捉えてますが…)
PEARライブラリを利用する場合は、おそらくこれらの警告に頻繁に遭遇することになりますので、何らかの対策が必要となります。
また、自分でエラーハンドリングを行う場合、この設定に関わらず必ずエラーハンドラが呼ばれますので、適切に処理する必要があります。

display_errors (string) PHP_INI_ALL

エラーをレスポンス出力するかどうかを指定します。
PHP5.2.4以降、設定値がbooleanからstringに変更され、"stderr"を設定するとエラーの内容を stdout (標準出力) ではなく stderr (標準エラー出力) に送れるそうです。(恥ずかしながら今回初めて知りました)
これを有効にすると、自分でエラーハンドリングを行わない限り、問答無用でレスポンスの先頭にエラーメッセージが出力されます。
自分でエラーハンドリングを行う場合、この値に関係なく自由にエラー出力を制御できますが、エラーハンドラが有効になる以前の致命的なエラーや構文エラーについては、この値に依存することになります。
またユーザースクリプトで設定する場合、そのスクリプト自体が実行不可能なエラーを発生してしまった場合、当然ながら何も出力されません。
私の場合、開発環境ではphp.iniで"1"、本番環境では"0"を設定しますが、アプリケーションの初期化処理の中でもlocalhostかどうかで設定を動的に変更しています。
自分でエラーハンドリングを行う場合、この設定を見て適切に処理する必要があります。

display_startup_errors (boolean) PHP_INI_ALL

(未確認)
PHP起動時のエラーを出力するかどうかを指定します。
開発時はOnにしていますが、これに該当するエラーを見たことはありません。(E_CORE_ERROR や E_CORE_WARNING がこれに当たるんでしょうか?)

log_errors (boolean) PHP_INI_ALL

エラーメッセージをサーバーのエラーログ、または error_logディレクティブに設定したログファイルに記録するかどうかを指定します。
自分でエラーハンドリングを行う場合、この値に関係なく自由にログを記録できますが、エラーハンドラが有効になる以前の致命的なエラーや構文エラーについては、この値に依存することになります。
私の場合、同じサーバに複数のアプリケーションが同居することも多く、Webサーバーのログとアプリケーション固有のログは別のものとして管理した方が良いとの判断から、この値は常にOnにしています。

log_errors_max_len (integer) PHP_INI_ALL

エラー出力の一行の最大長をバイト単位で指定します。名前に反して、ログだけではなく画面表示にも適用されますのでご注意を。
初期値は1024ですが、スタックトレースを出力する場合など、多分そのままでは不足すると思います。私の場合、とりあえず4096に設定してます。
0を設定すると無制限になりますが、ログの肥大化が問題となることもあるでしょうし、どこかしら制限しておくべきと考えます。

ignore_repeated_errors (boolean) PHP_INI_ALL

(未確認)
同じソースファイルからの同じエラーメッセージの繰り返しを無視するかどうかを指定します。
Onにしたことはありませんが、使うことがあるとすると、すでに本番環境で動作している警告満載のひどいコードの管理を引き継いだ時くらいでしょうか。

ignore_repeated_source (boolean) PHP_INI_ALL

(未確認)
同じエラーメッセージの繰り返しを、異なるソースファイルの場合でも無視するかどうかを指定します。
Onにしたことはありませんが、同上、でしょう。

report_memleaks (boolean) PHP_INI_ALL

(未確認)
デバッグビルドされた環境において、error_reporting で E_WARNING を有効にしている場合に、Zend メモリマネージャーが検出したメモリリークの報告を表示するかどうかを指定します。
今回初めて知ったのですが、コンパイル時のconfigureオプションで --enable-debug を指定するとデバッグビルド版になり、拡張モジュールの作成に有用なこの機能が使えるそうです。(私にとっては未知の領域です)

track_errors (boolean) PHP_INI_ALL

有効にした場合、エラーが発生したスコープで直近に発行されたエラーメッセージが変数 $php_errormsg にセットされます。
自分でエラーハンドリングを行う場合、エラーハンドラが FALSE を返した場合にのみこの機能が有効になります。

html_errors (boolean) PHP_INI_ALL

エラーメッセージをHTMLタグで出力するかどうかを指定します。
下のxmlrpc_errorsとの比較のために、出力内容を提示しておきます。

<br />
<b>Warning</b>:  HOGE in <b>C:\Users\k-holy\Documents\demo\error.php</b> on line <b>5</b><br />

自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。

xmlrpc_errors (boolean) PHP_INI_SYSTEM PHP4.1.0以降

エラーメッセージをXML(XML-RPC)形式で出力するかどうかを指定します。
有効にすると、html_errorsの値には関係なく、以下のような構造でエラーメッセージが出力されます。
(分かりやすいようにインデント整形していますが、実際には詰めた状態で出力されます)

<?xml version="1.0"?>
<methodResponse>
    <fault>
        <value>
            <struct>
            <member>
                <name>faultCode</name>
                <value><int>0</int></value>
            </member>
            <member>
                <name>faultString</name>
                <value><string>Warning:HOGE in C:\Users\k-holy\Documents\demo\error.php on line 5</string></value>
            </member>
            </struct>
        </value>
    </fault>
</methodResponse>

自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。

xmlrpc_error_number (integer) PHP_INI_ALL PHP4.1.0以降

XML-RPCのfaultCode要素の値を指定します。
xmlrpc_errorsが有効な場合、上記XML

<member><name>faultCode</name><value><int>0</int></value></member>

のint要素にこの設定値が入ります。
自分でエラーハンドリングを行ってメッセージを生成する場合は、この設定は反映されません。

docref_root (string) PHP_INI_ALL PHP4.3.0以降

(未確認)
PHPマニュアルのローカルコピーへのURLを指定します。
html_errors が有効な場合に、この項目と docref_ext を適切に設定すると、エラーメッセージからエラーが発生した関数の説明ページにリンクを張ってくれるそうです。

docref_ext (string) PHP_INI_ALL PHP4.3.2以降

(未確認)
マニュアルのローカルコピーの拡張子を.付きで指定します。
html_errors および docref_root との組み合わせで有効になるそうです。

error_prepend_string (string) PHP_INI_ALL

エラーメッセージの前に出力する文字列を指定します。
何に使うのかと思ったら、エラーメッセージ自体を独自のHTMLタグで囲みたい場合に使えるようです。(php.iniの設定サンプルにはfontタグとか書いてました)
手頃なところでは、marqueeタグを指定すると、一服の清涼剤になって良いかもしれません。
自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。

error_append_string (string) PHP_INI_ALL

エラーメッセージの後に出力する文字列を指定します。
error_prepend_string とセットで利用する項目として想定されているようです。
自分でエラーハンドリングを行ってメッセージを生成する場合、この設定は反映されません。

error_log (string) PHP_INI_ALL

エラーメッセージを記録するログファイルへのパスを指定します。
"syslog"を指定した場合、エラーはファイルではなくシステムロガーに送られます。(未確認)
自分でエラーハンドリングを行なう場合、この設定に関係なくログ出力を行うことも可能です。
私の場合、初期設定のままWebサーバのエラーログにも記録しつつ、エラーハンドラと例外ハンドラではアプリケーション固有のログディレクトリに年月別ファイルで記録しています。


参考リンク
PHP: エラー処理 - 実行時設定 - Manual

ユーザー定義エラーハンドラ・例外ハンドラのサンプルコード

長くなりましたがここからが本題、設定ディレクティブ内で何度も触れました、自分でエラーハンドリングを行う方法をサンプルコードで紹介します。
サンプルの概要は、エラーハンドラと例外ハンドラをそれぞれ無名関数で作成し、フォーム上から動的にエラー出力の設定を変更し、各ユーザーレベルのエラーや例外を発生させることで、その動作を確認するものです。
実際は1つのスクリプトで完結させていますが、「各処理の呼び出し部分とフォーム」「エラーハンドラ」「例外ハンドラ」「エラーログ処理」「エラー画面出力処理」「ユーティリティクラス」と順を追って紹介します。

各処理の呼び出し部分とフォーム

スタックトレースのコード確認用の「スマイルセラピー」クラスと、_ini_set(), error_reporting(), set_exception_handler(), set_error_handler() の他は全てフォーム処理になります。
フォームについてはコードよりも画面のスクリーンショットを見た方が早いですね。

上部の「エラーメッセージを出力」にチェックで display_errorsを有効にします。
各エラータイプの「画面出力」にチェックを入れるとそのエラーメッセージをエラーハンドラで画面に表示、「ログ出力」にチェックを入れるとエラーログ処理を行い、画面下部の「ログ出力」のところに表示します。(ログファイルへの記録の代わりです)
「E_USER_***」のボタンはエラーを発生、「Exception」のボタンは例外をスローします。
各ハンドラで行うスタックトレースの整形を確認するために、エラー発生および例外スローは直接実行せず、「スマイルセラピー」クラスにコールバックとして渡し、様々な型の引数とともにメソッドを通過させてから実行しています。
なお、フォーム入力値のバリデーションは記事の主旨から外れるので含んでいません。

エラーハンドラ

set_error_handler() 関数の引数となる無名関数で、各処理間でデータの入出力を行うための配列 $data と、エラーログ処理を行う無名関数 $logger と、エラー画面出力を行う無名関数 $errorDisplay をuseで受け取っています。
エラーハンドラに渡される引数は、1番目がエラータイプ定数値、2番目がエラーメッセージ、3番目がエラーが発生したファイル、4番目がエラーが発生した行番号、5番目は今回扱っていませんが、エラーが発生したスコープでの全ての変数を格納した配列となっています。
処理の内容としては、エラーメッセージを生成して、フォームから設定された値と実際に発生したエラータイプにより必要な場合はログと画面出力を行い、復帰できないエラーの場合はエラー画面を表示して終了します。
エラーメッセージの生成では、通常のエラーではスタックトレースが出力されませんので、debug_backtrace() 関数を使ってスタックトレースを取得し、ユーティリティクラス(名前空間エイリアスを使って「U」としています)により例外メッセージ風に整形しています。
今回はWebからの利用のみの想定で、メッセージをpreタグで出力したりエラー画面を表示したりしてますが、コマンドラインやバッチ処理でも汎用的に使えるようにする場合、環境変数などで判断するか、外部から何らかの設定を受け取って動作を切り替える必要があるでしょう。
サンプルのように戻り値としてtrueを返した場合はここで処理を終了しますが、逆にfalseを返した場合は通常のエラーハンドラに処理を引き継ぎます。

例外ハンドラ

set_exception_handler() 関数の引数となる無名関数で、エラーハンドラと同様、各処理間でデータの入出力を行うための配列 $data と、エラーログ処理を行う無名関数 $logger と、エラー画面出力を行う無名関数 $errorDisplay をuseで受け取っています。
処理の内容もエラーハンドラとほぼ同じですが、例外処理にはエラーレベルの判定などがなく、エラーメッセージの生成は Exception クラスの各種メソッドが利用できますので、よりシンプルな記述になっています。
スタックトレースException::getTraceAsString() メソッドにより簡単に文字列で取得できますが、引数の型や配列の内容を出力するために、エラーハンドラと同様にユーティリティクラスにより加工しています。
より手抜きするなら、実は Exception::__toString() の戻り値がデフォルトの出力とほぼ同じ(先頭の「Fatal Error: Uncaught」と末尾の「thrown in ... on line ...」だけがない)状態なので、面倒なら単に文字列にキャストするだけ、というのもありだと思います。

エラーログ処理

エラーメッセージのファイルへの記録を行う想定の無名関数で、引数で指定された文字列からHTMLタグを除去し、先頭に日付を、末尾に改行を付与してロギングします。
本来は error_log() 関数によりファイルへ追記しますが、今回は単に useで渡された配列にメッセージを追加しています。
error_log()では、第2引数の値によってerror_logディレクティブの設定先、指定したアドレスへのメール、指定したファイル、SAPIのログ出力ハンドラ(PHP5.2.7以降)と、メッセージの送信先を切り替えることができます。
指定したファイルへの保存の場合、ファイルが存在しなければ作成を試みてくれます。

エラー画面出力処理

エラー画面を出力する無名関数で、HTTPステータスコードを送出して画面を表示するとともに、登録したエラーハンドラおよび例外ハンドラの解放などの終了処理を行います。
エラーログ処理で追加されたメッセージを表示するために、引数にデータ出力用の配列を受けています。
今回は固定のステータスコードとメッセージとしていますが、実際にはアプリケーション内で共通の例外インタフェースなり基底クラスとエラーコードを定義しておき、例外スロー時に適切なコードを設定することで、エラー画面で出力するステータスコードとメッセージを切り替えたりします。
(業務ロジックの実行に必要なパラメータが不足していたり妥当でない場合に400 Bad Request、実行権限がない業務ロジックが呼び出された場合に403 Forbidden、指定されたデータが見つからなかった場合に404 Not Found、等々)
Webサーバ本来のエラー画面との統合も考えると、このような文字列組み立てやテンプレートシステムを使った出力ではなく、単に静的HTMLファイルを切り替えるというのも一つの方法だと思います。

ユーティリティクラス

各処理の記述をシンプルにするための共通関数集的なユーティリティクラスです。
H()メソッドはhtmlspecialchars()の手抜き用。名前空間エイリアスとの併用で、U::H()で呼び出しています。(サンプルコードということでお許しください)
buildErrorLevels()メソッドでは get_defined_constants() 関数で取得した定数から array_filter() 関数を使ってE_USER_***定数のみ抜き出し、array_walk() 関数や usort() 関数によりフォーム出力用のデータとして加工しています。
formatTrace()メソッドとformatVar()メソッドは、いずれもスタックトレースの整形用です。特に、配列の場合は第1階層のみキーと値の両方の型が分かるようにしているところが便利で自分でも気に入ってます。


動作サンプルはこちらです。
http://k-holy.sakura.ne.jp/php-advent-2011/error_handler.php

参考リンク
PHP: エラー処理 - Manual

ユーザー定義のエラーハンドラで扱えるエラーと扱えないエラー

ざっと流しましたが、特にエラーハンドラについては色々と制約があったりするので、PHPマニュアルの記述を参考に補足します。

ユーザー定義のエラーハンドラで扱えるエラーと扱えないエラーについて。
以下のエラータイプは、ユーザ定義のエラーハンドラでは扱えません。

  • E_ERROR
  • E_PARSE
  • E_CORE_ERROR
  • E_CORE_WARNING
  • E_COMPILE_ERROR
  • E_COMPILE_WARNING
  • set_error_handler() がコールされたファイルで発生した大半の E_STRICT (PHP5以降)

逆に、ユーザー定義のエラーハンドラで処理できるエラーは以下の通りです。

  • E_WARNING
  • E_NOTICE
  • E_USER_ERROR
  • E_USER_WARNING
  • E_USER_NOTICE
  • E_STRICT (PHP5以降)
  • E_RECOVERABLE_ERROR (PHP5.2以降)
  • E_DEPRECATED (PHP5.3以降)
  • E_USER_DEPRECATED (PHP5.3以降)

ユーザー定義のエラーハンドラで扱えない E_ERROR でも、シャットダウン関数で処理できることがあります。
シャットダウン関数はスクリプト処理が完了したとき、あるいは exit() がコールされたときに実行するコールバックで、register_shutdown_function() 関数を使って登録します。
このコールバックの中で error_get_last() 関数(PHP5.2以降)を使うとE_ERROR(Fatal error)の情報が取得できるので、そこからエラーハンドラと同様のエラーメッセージ整形やロギング、メール送信などを行うことが可能です。
この方法は、発生したエラーや例外のメッセージをハンドラ内で逐次出力せず、妥当なHTMLドキュメントとしてデバッグ用エラー画面などで表示したい場合にも使えそうです。

通常のエラーハンドラや例外ハンドラとは違った処理手順となるため今回のコードでは取り上げませんでしたが、シャットダウン関数を使ったエラー処理については、以下の記事が参考になります。
PHP の「エラー処理ハンドラ」「シャットダウンハンドラ」「例外処理ハンドラ」の挙動 - Web/DB プログラミング徹底解説
PHPのset_erorr_handlerとregister_shutdown_functionとob関数について ( エラーを整形出力したい ) ::ハブろぐ

またこちらの記事では、設定ディレクティブの auto_prepend_file にシャットダウン関数を仕込んでFatal Errorをメールで通知するアイデアが紹介されています。
fatal errorでアラートメール | GANCHIKU.com


PHP4時代に書かれたレガシー以前の警告満載アプリケーションを引き継いでしまった場合など、まずはauto_prepend_fileでエラーハンドラを組み込んでログを取ってみたり、意外とこの手の知識が役立つことがありますので、未経験の方もぜひ試してみてください。

余談ですが、例外ハンドラ内で例外をスローしてみたところ、捕捉されず Fatal error: Uncaught exception のエラーが発生しました。
例外クラスの定義とただひとつの例外ハンドラで構築されたアプリケーションというネタを思いついて、実際コードも書き始めていたのですが、残念ながら不発に終わってしまいました…。


明日12日目は @kashioka さんです。