エラー処理の指針
ここでは、PEAR パッケージを PHP 5 や 6 向けに開発する際の
エラー処理の方法について説明します。PHP 5.0 で登場した、
Zend Engine 2 の「例外」をエラー処理に使用します。
エラーの定義
エラーとは、プログラムが予期せぬおかしな状態に陥り、
復旧不可能な事態のことです。ここでいう「復旧」の範囲は、
メソッドレベルとします。また、復旧が不完全な状態は
「復旧している」とみなします。
<?php
/*
* 指定したデータベースに接続します
*
* @throws Example_Datasource_Exception 指定した DSN で接続できない場合
*
*/
function connectDB($dsn)
{
$this->db =& DB::connect($dsn);
if (DB::isError($this->db)) {
throw new Example_Datasource_Exception(
"$dsn に接続できません:" . $this->db->getMessage()
);
}
}
?>
この例では、メソッドの目的は指定した DSN に接続することです。
ここでできることは PEAR DB に処理を依頼することだけなので、
もし DB からエラーを返された場合は何もせず例外を発生させます。
<?php
/*
* いくつかの候補の中から、接続可能なデータベースに接続します
*
* @throws Example_Datasource_Exception
* 設定されたデータベースのどれにも接続できなかった場合
*
* @throws Example_Config_Exception
* データベースの設定が見つからなかった場合
*/
function connect(Config $conf)
{
$dsns =& $conf->searchPath(array('config', 'db'));
if ($dsns === FALSE) throw new Example_Config_Exception(
'config/db セクションが設定されていません。'
);
$dsns =& $dsns->toArray();
foreach($dsns as $dsn) {
try {
$this->connectDB($dsn);
return;
} catch (Example_Datasource_Exception e) {
// 何らかの警告/ログ出力コードにより、
// そのデータベースに接続できなかったことを記録します
}
}
throw new Example_Datasource_Exception(
'どのデータベースにも接続できません'
);
}
?>
この例では、例外を捕捉してそこから復旧させています。低レベルの
connectDB() メソッドは、
データベースへの接続が失敗した際にエラーをスローすることしかできません。
しかし、その上位に位置する connect()
では、設定済みデータベースのいずれかひとつに接続できればよいことを知っています。
エラーからは復旧可能なので、このレベルでの例外は無視され、上位にはスローされません。
<?php
/*
* loadConfig は、渡された設定内容をパースします。設定が無効な場合は
* デフォルトの内容を設定します
*
*/
function loadConfig(Config $conf)
{
try {
$this->config = $conf->parse();
} catch (Config_Parse_Exception e) {
// 警告やログ出力のコードをここに書き、
// 不完全な復旧を行います
$this->config = $this->defaultConfig;
}
}
?>
この復旧には副作用があるので、完全ではありません。
しかし、プログラムの実行はそのまま続けられます。
例外は処理されたとみなされるので、再度スローしてはいけません。
先の例と同様、例外を黙らせた際にはログ出力や警告を行うべきでしょう。
PHP 5 用 PEAR パッケージにおけるエラー通知
PHP 5 用に書かれた PEAR パッケージのエラーは、
例外を使用して通知しなければなりません。リターンコードで示したり
PEAR_Error オブジェクトを返したりといった方法は推奨されません。
もちろん PHP 4 との互換性を提供するパッケージについてはこの限りではありません。
その場合は、PHP 4 用の PEAR コーディング規約に従ってエラー処理を行います。
例外は、先の節で述べた定義によるエラーが発生した場合に常にスローしなければなりません。
スローする例外の中には、エラーのデバッグを行うための情報と
エラーの原因をはっきりさせるための情報を十分に含める必要があります。
実際の運用時には、例外が一般使用者に見えることがないことに注意しましょう。
つまり、例外のエラーメッセージに、
技術的に複雑な内容を含めるのを躊躇する必要はないということです。
基本となる PEAR_Exception には、
エラーを表すテキストを含めることができます。これにより、
例外をスローする原因となったプログラムの状態を説明します。
また、オプションで、下位レベルの例外をラップすることもできます。
これにより、下位レベルで発生したエラーの原因をより詳細に伝えられます。
例外に含める情報は、エラーの内容によって異なります。
例外をスローする側の立場で考えると、
エラーには三種類あります。
-
事前の条件チェックによって見つかったエラー
-
下位レベルのライブラリから、エラーコードやオブジェクトで返されたエラー
-
復旧不可能な、下位のライブラリの例外
事前の条件チェックによるエラーの場合は、
失敗したチェックの内容を説明に含めましょう。
可能な限り、チェックに失敗した実際の値も含めておくようにしましょう。
通常は、この場合は他の例外をラップすることはありません。
このエラーの原因は下位レベルではないからです。
この形式のエラーは、たとえば以下のようになります。
<?php
function divide($x, $y)
{
if ($y == 0) {
throw new Example_Aritmetic_Exception('ゼロによる除算');
}
}
?>
下位レベルのライブラリからエラーコードが返された場合は、
もしそれが復旧不可能なものなら例外に変換しましょう。
エラーの説明には、もとのエラーに含まれる情報をすべて含めるようにします。
たとえば、先に示した connect メソッドなどを参考にしましょう。
<?php
/*
* 指定したデータベースに接続します
*
* @throws Example_Datasource_Exception
* 指定した DSN で接続できない場合
*/
function connectDB($dsn)
{
$this->db =& DB::connect($dsn);
if (DB::isError($this->db)) {
throw new Example_Datasource_Exception(
"$dsn に接続できません:" . $this->db->getMessage()
);
}
}
?>
下位のライブラリの例外を復旧できない場合は、
再度スローするか、そのままにしておきます。
再度スローする場合は、スローする例外の中で元の例外をラップしておく必要があります。
元の例外をそのまま放置すると、例外は処理されず、
コールスタックをさかのぼって別のハンドラを探します。
<?php
function preTaxPrice($retailPrice, $taxRate)
{
try {
return $this->divide($retailPrice, 1 + $taxRate);
} catch (Example_Aritmetic_Exception e) {
throw new Example_Tax_Exception('税率が無効です。', e);
}
}
?>
<?php
function preTaxPrice($retailPrice, $taxRate)
{
return $this->divide($retailPrice, 1 + $taxRate);
}
?>
例外を新たにスローするか元の例外を放置しておくかは、ソフトウェアの設計の問題となります。
以下のふたつの例外を除き、基本的に例外は放置しておくべきです。
-
元の例外が別のパッケージで発生したものである場合。
これをそのまま放置しておくと、内部実装の詳細が丸見えになってしまいます。
これは各レイヤの抽象化に反するまずい設計です。
-
現在のメソッド内で、
受け取ったエラーに有用なデバッグ情報を追加して再スローできる場合。
例外および通常のプログラムの流れ
例外は、決して通常のプログラムの流れで使用してはいけません。
すべての例外処理ロジック (try-catch 文) をプログラムから取り除くと、
残った部分が "One True Path"、
つまりエラーがない場合に通る処理の流れになっているべきです。
言い換えると、例外はエラーが発生した場合にのみ使用し、
通常の処理中には使ってはいけないということです。
以下は例外の間違った使用例で、
再起処理の結果を例外の「たらいまわし」機能で返そうとしています。
<?php
/**
* ツリーから再帰的の文字列を探します
* @throws ResultException
*/
public function search(TreeNode $node, $data)
{
if ($node->data === $data) {
throw new ResultException( $node );
} else {
search( $node->leftChild, $data );
search( $node->rightChild, $data );
}
}
?>
この例では、ResultException を、
深い再帰レベルから一気に "終了!" させるだけのために使用しています。
エラーを報告する際にはこの機能は便利ですが、
この例のような使い方は、単に開発者の手抜きでしかありません。
例外クラスの階層
PEAR パッケージの例外は、すべて
PEAR_Exception を継承していなければなりません。
PEAR_Exception は通常の PHP
の例外クラスとは違って例外のラッピング機能を持っています。これは、
先の節で説明した要件を満たすために必要となります。
さらに、各 PEAR パッケージでは、
<Package_Name>_Exception
という名前の例外を提供しなければなりません。そして、
パッケージ内ではその例外を継承した例外しかスローしないようにしておくことを推奨します。
例外についてのドキュメント
PHP では、Java とは異なり、
そのメソッドがどんな例外をスローするのかを
シグネチャで明示的に宣言することができません。
そのため、発生する例外についてはメソッドのヘッダでしっかり説明しておく必要があります。
例外について記述するには、
phpdoc のキーワード @throws を使用します。
<?php
/**
* このメソッドは、異星人を探します。
*
* @return array Aliens オブジェクトの配列。
* @throws AntennaBrokenException アンテナに障害が発生した場合
*
* @throws AntennaInUseException 別のプロセスが既にアンテナを使用している場合
*/
public function findAliens($color = 'green');
?>
多くの場合は、アプリケーションの中間層で下位レベルの例外を再構成し、
よりわかりやすいアプリケーション固有の例外に変換します。
これについてもきちんと説明しておく必要があります。
<?php
/**
* セッションオブジェクトを共有メモリに読み込みます
*
* @throws LoadingException 下位レベルの IOException をラップし、
* LoadingException として再スローします
*/
public function loadSessionObjects();
?>
あるいは、あなたの書いたメソッドは、
下位レベルで発生した例外を何も処理せず放置することもあるかもしれません。
そんなことをする場合にもやはり、どの例外を
捕捉しない のかを記述しておく必要があります。
<?php
/**
* データベースへのクエリを一括で実行します (それぞれ個別に実行します。トランザクション処理はありません)
* @throws SQLException 下位レベルでの SQL エラーは、このメソッドでは何も処理しません
*/
public function batchExecute();
?>
API の一部としての例外
例外は、あなたのライブラリの API において重要な役割を演じます。
開発者があなたのライブラリを使用する際には、
パッケージのどこでどんな場合に例外が発生するのかの説明が必要です。
ドキュメントが重要になります。
また、スローされるメッセージの型についての説明も、
過去との互換性を確保するために重要となります。
例外はパッケージの API の中でも最重要なところなので、
例外を変更することで過去との互換性 (BC)
をなくしてしまわないことが大切です。
過去との互換性をなくしてしまう変更には、次のようなものがあります。
過去との互換性をなくさない変更には、次のようなものがあります。