📄

2020/12/23 PHP) Exceptionエラー設計原則とアプリケーションへの導入

2024/05/24に公開

この記事は2020/12/23に書きました。

前書き

  • すべての記事は、自分の勉強目的のため主観的な内容の整理を含まれています。あくまで参考レベルで活用してください。もし誤った情報などがあればご意見をいただけるととっても嬉しいです。
  • 内容では省略するか曖昧な説明で、わかりづらいところもあると思います。そこは、連絡いただければ補足などを追加するので、ぜひ負担なくご連絡ください。
  • 今回の記事は Java の Exception・Concept に起因した内容を一部含めています。Java と PHP のエラーの抽象化モデリングは少々違いがあるので、後述する内容は必ず PHP においての正解とは言えません。PHP の Exception に焦点をおくよりも、言語に関係なく Exception と Application においてのエラー設計観点の考察を主に記事にしたいという思いがあったので、この意図のところを踏まえてご覧いただけると幸いです。

概要

本記事は、PHP7 をベースにしています。
本記事は以下の内容で構成されています。

● PART-0. 事前知識
Exception に関わる構文と特性を簡単に説明します。

● PART-1. 例外(Exceptions)と「抽象化モデリング」の理解
Exception の仕組みに対して、設計思想・核心原則を、OOP の観点と原則に基づき解説します。

● PART-2. 例外(Exceptions)の「責任」原則
Exception の「抽象レベル」「特性」「ユーザー定義例外」観点で、Exception の責任と、Exception に対しての我々の責任を元に、設計原則を解説します。

● PART-3. Application 開発においての「Exceptions 設計・導入」
上記の原則を元に、アプリケーションではどういう風に導入できるのかの一例を、紹介します。

● PART-4. 例外設計原則まとめ
PART-1 から PART-3 までの内容を踏まえて、「Exception 設計原則・目録」としてまとめます。

記事の効率的な見方

当記事は、かなりのボリュームの内容になっておりますが、全ての方に対して全ての内容が必要な訳ではないと思います。

以下の「記事を見方」を参考し、読者の方が望む方向で、この記事を効率的にご活用いただければと思います。

もちろん、例外に興味が高い方、例外を深堀たい方、例外に関していろんな議論をしたい方は、是非初めから読んでいただくと、筆者としても嬉しいです。

PART-0. 事前知識

本記事を読んでいただく前い、事前知識として必要な内容を簡単に記述します。Exception に対して、すでに経験が豊富な方は軽くみて頂くか、次の PART から読んでいただいて構いません。

「PART-0」は PHP においての Exception の基本的な使い方を話します。現段階の内容が少し新しく感じる方は Exception に関わる事例コードとかをみて、書いて、その結果を身で直接感じた上で、次に進めることをお勧めします※1

0-1. throw new Exception

throw は、投げるという意味の「伝達・伝播」として、意味として、強く意識していただけると良いと思います。

throw 構文を使うことで、新しい Exception のインスタンを throw することができます。

throw new Exception($message, $code, $previous)

0-2. try catch finally

下位CALLSTACKから伝播された例外を明示的にハンドリングできる構文です。

※ finally は、php5.5.x 以降からサポートします

try {
    //throwが予想されるコードブロック
} catch (MoreSpecificException $ex) {
    //先端は、より具体的なExceptionをcatch
} catch (MoreAbstractiveException $ex) {
    //後端は、直前と同等か、より抽象度が高いExceptionをcatchしハンドリング
} finally {
    //try blockの例外に関係なく実行されるコードブロック
}

0-3. Exception Class が提供するインタフェース

Exception クラスは、いろんなインタフェースを提供しており、エラーに関する有用な情報を取得できます。特に、getMessage()getTrace()は、アプリケーション運用にとって、非常に有益な情報を提供してくれます。

詳細は、php reference をご覧いただけます。
https://www.php.net/manual/ja/class.exception.php

0-4. class CustomMyException extends RuntimeException

Exception の属性を継承し、プログラマーが新しい Exception を定義することもできます。

アプリケーション特有のエラーに対して、「OOP と Exception の特性を生かした抽象化・構造化」設計を可能とします。

class CustomMyException extends RuntimeException {
    //...can implements you need
}

PART-1. 例外(Exceptions)と「抽象化モデリング」の理解

PART-1 からは Exception を含む、言語が提供する例外モデルに対する考察と思想に基づいた「設計論」の話が主になります。

Exception とエラーに対する設計論は、様々な議論が存在し、システム要件と開発状況によって、正解というのが難しい分野だと思います。筆者自身にとっても苦難を重ねている分野でもある分、主観的な意見を多数含まれています。

そこを認識いただいた上で、参考までに読んでいただけると幸いです。筆者自身もいろんな意見をいただき、また知識の糧にしたいと存じます。

1-1. PHPの例外(Exception)の構成

1-1-1. 例外の構成図

PHP で基本提供している例外の構造は、上記の図になります。

  • ここで話す「例外」は、throwable を継承する全てのクラスを意味します。
    • Error クラスと、それを継承するクラスも「例外」です。
  • ブロックは is-a 関係を表しています。特定クラスが特定の複数のブロックに含まれる場合、複数ブロックで示しているクラス名と is-a 関係が成立します。(インタフェース名も、便宜上クラス名と称します。)
    • PDOException は、PDOException 自分自身であり、RuntimeException でもあり, Exception でもあり, Throwable でもあります。
  • 色がついているクラスは、子クラスを持つ親クラスです。
    • RuntimeException は、PDOException を子クラスで持ちます。

しかし、ここで全てのクラスの意味を説明したりはしません。
大事なのは構造です。上記の親とこの関係、ブロックと is-a 関係を主に意識していただいても大丈夫です。

なので、これからは図を簡略化して、説明していきます。

一つ一つの例外を確認したい方は、言語レファレンスや、以下の記事をお勧めします。
https://www.php.net/manual/en/reserved.exceptions.php
https://www.php.net/manual/en/spl.exceptions.php
https://qiita.com/mpyw/items/c69da9589e72ceac470c

1-1-2. 例外クラスの説明

上記に表している例外に関して、一部のみを簡略に説明します。しかし、今すぐその意味を全て理解する必要はありません。 各自の例外の継承するクラスと、その関係性を意識して頂くと良いです。

● Throwable
throw 特性を持つ全てのクラスの最上位インタフェース

● Error
PHP 内部的に発生可能な全てのエラーのベースクラス

● Exception
プルグラマー・ユーザーから起因可能な全ての例外エラーのベースクラス
Throwable を継承

● RuntimeException
コンパイル段階で言語が探知できず、実行時に発生可能性がある例外。またはその特性を持つ子クラスの最上位クラス
Exception を継承

● LogicException
未具現のメソッドの呼出や、誤った引数の指定など、実装の問題などで発生させることができる例外
Exception を継承

● ClosedGeneratorException
PHP のジェネレーターを使う時、すでに closed のジェネレーターに対して実行を試みた時発生する例外
Exception を継承

1-2. PHP の Exception の理解の核心は、OOP の「抽象化」

1-2-1. 例外は「抽象化」されたモデル

OOP 設計の核心は「抽象化モデリング」「抽象化されたモデル」と言えます。
もっと具体的に定義すると、「解決(具現)すべき課題を、クラスコンセプトに基づき抽象化モデリングし、解決手段を状態(member)と行動(method)をコードとして記述するプログラミング手法」と言えます。

PHP の Exception の概念も、まさに OOP の抽象化モデリングの事例です。
まず、Exception を実際に扱う前に、ここを意識するのはすごく大事です。

1-2-2. 簡略化した例外構成図と抽象レベル

上記の図は、PHP の例外構造を簡略化した上に、格例外の抽象レベルを表しています。

  • Level の数字が低いほど、抽象レベルは高く、より抽象的で、幅広い意味を持ちます。
    • Throwable は、「全ての例外」その物なのでとっても抽象的で、Throwable だという情報だけでは具体的に何なのかを特定できません。
  • Level の数字が高いほど、中量レベルは低く、より具体的で、比較的に明確な意味を持ちます。
    • PDOException は、より具体的で、PHP Data Object で発生した例外という特定ができるほど具体的です。

1-3. PHP のエラーは、プログラムで起き得る「事故を階層的に抽象化」したモデルである

では、例外というのは、何を課題を解決するためにあるのかを考えてみましょう。

まず、例外は、何らかの「事故」と理解しても問題はないと思います。

そして例外の概念は、「プログラムで起き得る事故を課題として扱い解決するため」に抽象化されたモデルと言えます。

この概念は、現実世界の事故の概念ともかなり似ています。

では、現実世界の「事故」を抽象化した物と、PHP の例外を、以下の観点で比較してみましょう。

  • 現実世界と構成・構造が類似しているか?
  • どれだけ抽象的かによって、抽象的、または具体的な事故が持つ意味が、事故と PHP 例外の間で類似しているか?

1-4. PHP の Exception は、「上位階層なほど抽象的、下位階層なほど具体的」である

うまくモデリングできたでしょうか。

正確に当てはまる訳ではないですが、現実世界の事故と、PHP の例外の階層を当てはめて比較してみましょう。

  • Level の数字が低いほど、抽象レベルは高く、より抽象的で、幅広い意味を持ちます。
    • 事故は、「全ての事故」その物なのでとっても抽象的で、事故だという情報だけでは具体的に何なのかを特定できません。
    • いまの情報では、筆者の生命封建や自動車保険で処理できる事案なのかわかりません。勝手に処理することもできません。
  • Level の数字が高いほど、中量レベルは低く、より具体的で、比較的に明確な意味を持ちます。
    • 交通事故 → 物損事故は、より具体的で、自動車そのものの物損被害事故が発生したと特定できるほど具体的です。
    • 幸い、筆者は自動車保険があるので、筆者が保険社を通してリカバリーできそうです。

「事故」という単語は、現実世界の全ての種類の事故を意味しており、とっても抽象的です。PHP 例外の最上位の Throwable もまた、システム・プログラムないで起き得る全ての事故の意味を持つので、とっても抽象的だと言えます。

「自然災害事故」と、「人為災害事故」は、より具体的ですね。
「自然災害事故」は、普通は環境によって起きるので、PHP の Error Class と似てると思います。
「人為災害事故」は、人により起き得る物なので、PHP の Exception Class と似ていると言えるでしょう。

しかし、まだ「人為災害事故」と言っても、具体的ではありません。どういう事故か、どういう風に対処すればいいか、現段階では予測できません。

「交通事故」までくると、かなり具体的になりました。RuntimeException に当たるレベルと似ている感じですね。

「物損事故」レベルまでくると、どういう原因・内容・対処法で良いのか予想がつくようになります。PDO Exception や、Custom Exceptions が、ここに当たる感じになります。

ここで、注目するべきは、「抽象レベルに基づいた階層構造」ということです。
現実世界の事故のモデリングもそうですが、PHP の Exception もまた、「どれだけ抽象的か?」を基準に、階層的に構造化されているという認識はすごく大事です。

抽象レベルにより、例外自体が持つ責任と、我々が持つ責任が大きく変わるからです。

1-5. Specific Exception can be Abstractive

OOP の基本原則には、継承による「is-a relationship」という原則があります。
「交通事故」というクラスが「事故」というクラスを継承すると、交通事故は交通事故自身であり、事故でもある(交通事故 is a 事故)という原則です。

ここで私たちが注目するところは、「a」という冠詞です。なぜいきなり英語?という考えるかもしませんが、「a」は「何かの、何らかの」の意味に近い不定冠詞と言えます。ざっくりいうと「指定されていない何らかの事故」という意味で、抽象的な概念を含むようになります。「あの、その」みたいに特定された一つの意味に近い「the」ではなく、「a」で表記しているところの理由はそこにあります。

つまり、「is-a relationship」は、同一性だけじゃなく、「抽象性」も表している原則です。

ここで、人に内容を伝えるときに、「交通事故 → 物損事故」と伝えるのと、「事故」と伝えるのでは、情報の抽象度が変わってきます。「事故」を聞くと、交通事故か、火事か、自然災害かわからないから、反応にも困るでしょう。そこでどういう事故?と聞いても、事故よ事故とだけ言われる、我々は具体的な情報を特定できないので、相当困るでしょう。

- 交通事故→物損事故 is a 交通事故→物損事故 (具体的)
- 交通事故→物損事故 is a 交通事故
- 交通事故→物損事故 is a 人為災害事故
- 交通事故→物損事故 is a 事故 (抽象的)

Exception と、その配下の Exception たちも、同じ関係です。

- PDO Exception is a PDOException (具体的)
- PDO Exception is a RuntimeException
- PDO Exception is a Exception
- PDO Exception is a Throwable (抽象的)

と言えます。

つまり、PDO Exception と言われると、「PHP Data Object 処理でエラーになったな」と予想できるものが、Throwable と言われると、どういう例外か予想しづらくなります。

そして、予想しづらい事故は、個人レベルで全て対応仕切れないし、無理に処理しようとしても状況が悪化する場合がおおいです。そして、情報を知るべき団体や国家は、その情報を把握できなくなります。

「Exception の世界もまた、それは同じだと言えます」

1-6. 抽象的な例外ほど予測と正確な対処が難しい

今までみてきたように、Throwable,または Exception クラスは、とっても多くのプログラムでの「事故」を意味している概念なので、正確な原因と対処が難しいのが一般的です。

public function createNewData()
{
...
    try {
        $data = $clientDataServer->getTokenData($dataId);
        $saveToDatabaseUsingPDO->insertNew($data);
        ...
    } catch (Exception $ex) {
        Logger::critical("DB WORK FAILED!!", $ex->getMessage, $ex->getTrace());
        //some recovery logic for DB WORK
        return;
    }

上の例は、よくないと言えるエラーハンドリング例です。

プログラマーの意図としては、データの生成時に、DB 作業時で発生可能な何らかのエラーをハンドリング・リカバリーしたい意図があります。

しかし、is-a原則を覚えていますでしょうか?

Exception クラスは、DB 作業に関わる saveToDatabaseByPDO の例外だけでなく、clientDataServer や、その他で起き得る全てのエラーに対して、PDO エラーと同一視してしまいます。

ここは、具体的な PDO Exception を catch し、追加に clientDataServer で起き得る具体的な Exception を catch するか、予測できない例外は catch せず、上位に委任することが、一般的には望ましいです。

    try {
        ...
    } catch (ClientDataServerException $ex) {
        ...
    } catch (PDOException $ex) {

1-7. try-catch をするということは、一種の「保険」をかけること

プルグラムで、例外が予想されるポイントで、例外が起きた時の対応処理を事前に決めて実装したりします。

この概念もまた、現実世界の事故と類似しています。

筆者は自動車をもっていて、自動車保険に加入しています。
そこで、筆者がドライブをするとなると、「事故が会ったら保険証理しよう」という前提条件がすでに当たり前の認識なっていますね。

ここをコードで表現すると、以下の模様になります。

class 筆者
{
    public function __construct(
        $自動車, //筆者は自動車をもっている
        $自動車保険社 //筆者は自動車保険者に加入している
    ){
        ...
    }

    public function doDrive()
    {
        ...
        try {
            $this->自動車->運転する()
        } catch (交通事故\物損事故) {
            $this->自動車保険社->保険処理->請求()
        }
    }
}
  • 自動車保険会社のクラスは、保険会社のサービスに例えます。事前に登録しないと保険サービスを受けられないから、必ず登録しましょう
  • try&catch 構文は、車両登録と保険会社の連絡先を前もって用意しておくとこに例えますね。自動車事故が起き得る状況にはちゃんと備えて起きましょう
  • catch の中身の実装は、事故発生時の対応知識ですね。我々は、すでに学校や封建会社で学んでいるから当たり前にしっいることですが、システムはわからないので、ちゃんとコードで教えてあげましょう。

では次は、以下のシチュエーションを想像してみてください。


1-7-1. try - catch していない。(保険をかけていない)

いうまでもなく、お金的にも、民事訴訟的なところでも大変になるでしょう。

※ プログラム上でもも同じく大変になるかもしれません。

1-7-2. 「事故」として、catch していて処理している場合**

どういう事故なのかによって、できることの範囲は違ってきます。

  • 自動車事項:自動車保険処理・連絡
  • 盗難事故:警察に申告・連絡
  • 事故:事態把握・連絡

そして、自動車事故を、自動車事故と認識せず、事故と認識すると、確実にしてもいものは、「自体把握・連絡」です。

この状態で、「自動車保険処理」をやっても、運が良ければ、いい対応になるかもすが、「実は盗難事故でした」だったり、「自動車事故なのに、間違って警察に申告てしまった」など、謝る処理をしてしまう可能性がおおきいでしょう。

※ プログラム上でも同じく、意図しない変な処理をしてしまうかもしれません。

1-7-3. 筆者がどうすることもできない「自然災害事故」が起きた場合

個人レベルでどうにかなる自然災害なら、事故として扱っても何とかなるかもしれせん。

しかし、個人レベルではどうしようもなく、国家レベルで対応するしかない自然災が起きたらどうでしょう。個人レベルで無理やり処理しようとすると、むしろ状況を悪化させる可能性が非常高くないでしょうか。

こういう時は、状況把握・事故に対する必須対応後、迅速に家に委任するのがいいでしょう。

※ プログラム上でも同じく、ログ記録・必須対応だけ行って、上位階層に委任するが最善な場合が多いです。


この論理は、プログラムでの例外と try-catch 関係と非常に類似しています。
つまり、 「例外を try-catch することは、一種の保険をかけること」 と言えると、筆者は思います。

👉 PART-1 のまとめ「例外と抽象化モデルの理解」

PART-1 の内容をまとめると、以下になります。

  • 例外(Exceptions)は、プログラム上で起き得る「事故を階層的に抽象化」モデリングした物である
  • 「上位階層なほど抽象的、下位階層なほど具体的」である
  • 「具体的」は「抽象的」にもなれる
  • 抽象的な例外ほど予測と正確な対処が難しい
  • try-catch をするということは、一種の「保険」をかけること

PART-2. 例外(Exceptions)の「責任」原則

「PART-1」では、例外はシステム、またはアプリケーション上での「事故」と表現しました。

現実世界でも、個人、または団体・国家が事故に対する事後処理をするように、アプリケーションを生み出した我々は、ここで起き得る「例外」という事故に対して、何らかの責任を果たす必要があります。

しかし、現実世界の事故も個人・団体レベルでは解決仕切れない物もあるように、我々が解決仕切れない例外も有ったりします。そういう問題は、無理やり解決しようとするか、誤って解決をすると、もっと大変な事故による2次被害も起き得るのでしょう。そういう時は、もっと上位の団体や国家に委任するように、上位に委任し委ねるという考え方が一般的には好ましいです。

では、我々は、様々な例外に対して、どういう風に責任をとり、解決、または委任するべきでしょう。

ここは、実はすごく難しいところですが、 「抽象レベル」「Exception 特性別」「Custom Exception」 の三つの観点でお話しします。

2-1. 「抽象レベル」観点での責任と原則

2-1-1. catch する時は、「具体的」な Exception を catch する

「具体的」な Exception というのは、以下のような特徴を持ちます。

  • 比較的に明確な例外状況の意味をもっており、例外の特定・処理において明確な予想ができる
  • 明確に予想できる例外は、必要有無によって、「後処理」後、「完結」するか「委任」するかを柔軟に選択できる
  • 上位に委任するほど、抽象的になったしまう傾向が強い※2

まず、よくないコードの例をみましょう。

public function deepDepthMethod
{
    ...code - should be thrown a MyValidationException which is HighSpecificException
    ...code - should be thrown a PDOException which is SomeSpecificException of Database Handling Driver
    } catch (Exception $ex) {
        ...some recovery logic & logging
        return $resultOfThisCase;

上記のコードがよくない理由は、以下になります。

  • 具体的な例外の予想がつくポイントで、抽象的例外として扱っている
  • 抽象的例外を扱うことで、予想しづらく、何か正確にわからない何らかの問題が起きた時も、該当メソッドで処理し完結させてしまう。(正しい処理じゃないかもしれないのに)
  • 上位階層が知るべき情報も、このレベルで遮断され、適切な対応ができなくなる※3

なので、こういう場合は、以下の原則を意識しながら実装するのが好ましいです。


1) 具体的に予想できる Exception を catch する

すでに、MyValidationException が例外として発生する可能性を我々はすでに知っています。その場合は、具体的な MyValidationException のみ処理し、それ以外の予測できない問題は、無理に catch せず、上位に委ねます。

public function deepDepthMethod
{
    ...code should be thrown a MyValidationException which is HighSpecificException
    ...code should be thrown a PDOException which is SomeSpecificException of Database Handling Driver
    } catch (MyValidationException $ex) {
        ...some recovery logic & logging
        return $resultOfThisCase; //完結
    } finally {
        //必要な場合
    }

2) 具体的な Exception を明示的に上位に委任する

具体的に予想される例外に対して、catch したとして、必ずしも完結させる必要はありません。必要に応じて、必要な処理だけをした後、上位に委ねることも可能です。

  • 予測される例外に対して、このポイントでは必要な最小処理だけを行って、残りの処理は、上位の共通処理に委ねたい。
  • 予測しづらい例外が発生した時でも、必ずこのポイントで遂行しなければいけない処理がある時、必要な処理だけを行って上位に委ねます。
public function deepDepthMethod
{
    ...code should be thrown a MyValidationException which is HighSpecificException
    ...code should be thrown a PDOException which is SomeSpecificException of Database Handling Driver
    } catch (ValidationException $ex) {
        ...省略
    } catch (PDOException $ex) {
        ...some recovery logic & logging
        throw $ex; //委任
    }

2-1-2.「抽象的」な Exception は、なるべく上位に委任する

「抽象的」な Exception というのは、以下のような特徴を持ちます。

  • 多くの例外状況の意味をもっており、具体的な例外の特定・処理が難しい。
  • 無理やり catch し、処理しようとすると、例外を間違って特定・処理してしまう可能性が大きい。

例えば、「抽象レベルが高い Exception を処理している」というのは、以下のように Exception や Throwable という相対的最上位例外を catch している場合だと表現できます。

public function deepDepthMethod
{
    ...code should be thrown a Throwable which is High-Abstractive
    } catch (Exception $ex) {
        ...some recovery logic & logging
        return false;
    } catch (Throwable $e) {
        ...some recovery logic & logging
        return false;

上記の例は、deepDepthMethod という、比較的に「Call Stack の下位ポイント※4」から呼ばれる設定のコードです。

このコードがよくない理由は、以下になります。

  • deepDepthMethod がカバーできる責任を超えて、後処理し完結させている。
    • 新しい問題を生み出す可能性
  • この「完結させ return する」構造だと、上位ポイントのメソッド各自が、return 結果に対しての責任者になり、全てのポイントで、return 結果による処理を実装しなきゃいけない
    • エラーの制御処理実装の大変さ。

なので、こういう場合は、以下の原則を意識しながら、実装するのが好ましいです。


1) 抽象的な例外は、なるべく catch しない。

catch せず、上位に責任を委任することです。
この場合は、何が起きるのかわからない Exception に対しての対応なので、
phpdoc の throws 構文も書かない方が、筆者的にはお勧めです※5

/**
 * dont describe 「throws」
 */
public function deepDepthMethod()
{
    //no use try catch
    ...code should be thrown a Throwable

2) 抽象的な Exception が予想されるが、必ず後処理をする必要がある場合は、「処理後委任」する

場合によっては、何らかの例外が起きた時、何らかの処理が必須な状況もあると思います。しかしそういうときには、処理後完結せず、「処理後委任」を意識しておくと良いです。

状況に応じて、以下の方法で対処できます。

① finally 構文を使った後処理

//finally構文で、例外に関係なく、後処理を行えます。
//例外が起きなくても実行されるところは注意しましょう。
//php 5.5.x versionからサポートします。
public function deepDepthMethod
{
    ...code should be thrown a Throwable
    } finally {
        ...some finally logic && logging

② そのまま再伝播(re-throw)

//そのままexceptionを再伝播できます。
public function deepDepthMethod
{
    ...code should be thrown a Throwable
    } catch (Exception $ex) {
        ...some recovery logic & logging
        throw $ex

③ Custom Exception でラッピング(Wrapping)

//Exception Wrappingは、アプリケーション全体におけるエラー設計が、かなり重要になってきます。
//必ずpreviousに原本のExceptionをアサインしてあげましょう。
//上位レベルのエラー処理で、previousの情報も処理&ログするようにしましょう。
public function deepDepthMethod
{
    ...code should be thrown a Throwable
    } catch (Exception $ex) {
        ...some recovery logic & logging
        throw new CustomException('message', 'code', $ex);
        // ANTI PATTERN >> throw new Exception('message', 'code', $ex)

2-1-3. 抽象的な例外に対する後処理は、できるだけ上位階層のポイントで処理する

では、予測が難しい抽象的な例外に対しては、何もしなくていいのか?と聞かれると、そうではありません。

  • 予測できない例外に対して、 「上位階層で意図的に検知」 すること
  • 必要なら発生した「予測できなかった例外」に対して、 「原因と特定し具体的な例外として再定義」、潜在的な問題を解決すること

は、アプリケーション運用においてとっても大事なことです。

予測できなかった抽象的な例外も、実のところふかぼれば、我々ば単純に予測できなかっただけで、原因・現象・対象が明確な、具体的例外の場合は多いからです。

そして、ここで例える上位階層のポイントは、アプリケーションの実行ポイントを意味しており、例えるなら以下になります。


1) アプリケーションのエントリーポイント(進入ポイントの try catch)

アプリケーションの最上位進入ポイントで、最上位の例外を検知し、運用上気付けるような構造で Application を設計することもできます。

try {
    $application = new Application();

    //applicationのビジネスロジックの進入
    //ビジネスロジックでは、具体的な例外は処理しつつ、予想できない抽象的例外に対しては、最上位まで委任させる
    $application->run($request, $response);

    //applicationのビジネスロジックの完全終了
} catch (Exception $ex){
    //Applicationレベルで責任を取れなかった、予測できない例外に対して、ログを残し、指定をレスポンスを返す
    $logger->critical($ex);
    $application->response(500)->toJson()->send();
    throw $ex;
}

2) フレームワーク階層 (Error Hook, Error Handler、フレームワーク搭載の処理・ロギング)

例えば、Symfony には、Event Handler のインタフェースを提供しており、onKernelExceptionのインタフェースを具現すると、Framework においての Global な例外に対して、処理ができます。

詳細は、Symfony Framework の Event Listener をご参考できます。
https://symfony.com/doc/current/event_dispatcher.html

// src/EventListener/ExceptionListener.php
class ExceptionListener
{
    public function onKernelException(ExceptionEvent $event)
    {
        // You get the exception object from the received event
        $ex = $event->getThrowable();
        $logger->critical($ex);
        $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
        $event->setResponse($response);
    }
}

このように、大体の framework は、例外に対して、制御インタフェースを提供する場合が多いので、予測できない例外の検知と対策として有用に活用できます。

3) PHP・コア階層 (PHP とウェブサーバーの基本エラー処理・ロギング)

例外をコードで特に Catch しないと、PHP の基本処理としてエラーページを表示し、ログに残します。(apache error log など)

これらの基本仕様にしたがって、ログ検知などをかければ、それなりに運用することはできます。

しかし、筆者としては、① や ② のようにアプリケーション要件に合わせて設計と実装するのをお勧めします。経験としては、PHP の基本エラー処理とロギングでは、情報が不足して、調査と特定が難しい場合が多かった記憶があったからですね。

もちろん、中途半端な ① や ② の方法を行うよりは、③ が良い場合もあるので、アプリケーションと要件と状況に合わせて導入を検討するのが良いでしょう。


2-2. 例外の特性観点での責任と原則

この題目は、PHP の例外の構造と特性上、分類がかなり難しいところではあります。
その理由はいろいろありますが、「①PHP では Exception を「the base class for all user exceptions」と定義している特性、② 実行要請ごとにコンパイルと実行を同時に行うという特性、③ コンパイルと実行の境界が曖昧という特性」、この 3 つの特性で、PHP での Exception 設計において、かなり複雑にするポイントだと思います。
それでも、この概念の理解は、Exception 設計の根元を理解するのにおいて、大事な概念だと思い、話すことにしました。あくまで参考までに見ていただき、実装時の観点の一つとして留めていただけると幸いです。

Exception も実は、各種類の例外に対して、それぞれの特性をもっています。
その中で、Exception 全体に共通する属性と特性をもって、説明します。

  • Runtime(Unchecked) Type
  • Non-Runtime(Checked) Type

2-2-1. Runtime Exceptions vs Non-Runtime Exceptions

まず、Runtime Exceptions は、RuntimeException の特性を継承する Exception たちです。PDOExceptionが代表的な例です。

Runtime Exception を継承しない Exception は、全て Non-Runtime Exception と分類できます※6

Runtime と Non-Runtime は、以下の意味と違いを持ちます。

① Runtime

Runtime は、アプリケーションの実行時に発生可能性がある例外を意味します。
ここで大事なのは、実行時に「発生するかもしれないし、しないかもしれない」という特性を理解するのが大事です。

代表的に PDOException を例えられます。

※ Java の Exception Architecture Concept では、「unchecked type」と分類されます。

● PDOException の例え

PDOException: SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate...

を見た経験はあるのでしょうか?上記のエラーは、DB Insert 時に unique key field に対して重複データを入れようとした時に発生します。逆に重複しないデータを入れたときには、アプリケーションは正常に動くようになります。
そして、データというのは、直接データをもらい実行してみる前までは、発生するかしないかわかりません。ただ発生可能性もあるという予測ができるだけです。

つまり Runtime 例外はプログラムの実行中、 「実行条件によって発生有無が変わる例外」 です。

② Non-Runtime

Non-Runtime 例外は、コンパイル時に発生する可能性がある例外です※7
場合によりますが、「コンパイル時に高い確実で発生可能」な例外を意味します。

Throwable 配下の Error を継承する例外(CompileError)や、RuntimeException を継承しない Exception(LogicException, ClosedGeneratorException)が該当します。

※ Java の Exception Architecture Concept では、「checked type」と分類されます。

● LogicException の例え

LogicException は、プログラマーに提供される Exception インタフェースですが、未実装のメソッドの呼び出し(BadMethodCallException)など、コードレベルのミスに対する例外として定義できます。

● ClosedGeneratorException の例え

ClosedGeneratorException は、php の Generator 機能に関わる例外です。

PHP の Generator は、とある連続的なデータセットを、code looping を通して返却を保証するインタフェースであり、そのゆえに大量のデータの操作に関しても、最低のメモリーで処理を可能とします。

「code looping」を意識してください。つまり「実行前に連続的データを code に変換し実行」するという意味をもっています。

ClosedGeneratorException は、すでに Closed された Generator を呼び出そうとする時発生します。そしてこのエラーは、実行時のデータ条件により変わる例外というよりは、コンパイル時の Code Error として見なすこともできます。

の事例は、以下の stackoverflow で詳細に説明されているので、ご参考ください。
https://stackoverflow.com/questions/17483806/what-does-yield-mean-in-php
https://stackoverflow.com/questions/47183250/in-php-what-is-the-closedgeneratorexception
(※リンクのミスがあったので修正しました。)

2-2-2. (Java 引用) Concept -「Checked」or「Unchecked」Exception

Java では、Runtime 例外特性を「Unchecked」、Non-Runtime 例外特性を「Checked」に分類します。ここに関して、もっと詳しく見たい方は、Java の Exception Concept をご参考いただけると良いと思います。

この概念は 100%当てはまる訳ではないですが、相当部分が当てはまるので、その特性と責任を引用し説明します。

このように、PHP の Runtime Exception は「Unchecked Type」に、Non-Runtime Exception は「Checked Type」しても、かなり当てはまるところが多いと考えられます。

ここで注目するべきところは、 「確認時点」と、「後処理責任義務」 のところです。
ここに関しては混乱するかもしれませんが、次の Step で解説します。

###  2-2-3. 「Runtime・Unchecked」。プログラマーが、プリケーション運用においてもっと注目するべき Exception Type

ここで、Runtime・Unchecked Type(以下 Runtime と表記)の「確認時点」と「後処理責任有無」をもう一度確認してみましょう。

  • Runtime Type
    • 確認時点:実行段階(発生しない可能性が常にある)
    • 後処理責任有無:明示的に強制しない

ここで、疑問が浮かんできます。

  • Q1) 同じ例外なので、なぜ処理を強制しないのでしょうか?
  • Q2) なぜ、処理を強制しない Runtime Type を、プログラマーは、もっと注目しなきゃ行けないでしょうか?
  • Q3) 具体的に、どういう部分を注目すればいいでしょうか?

この疑問に対して、回答しながら、その理由を解説します。


Q1) 同じ例外なのに、なぜ Runtime Type には「後処理」を強制しないのでしょうか?

ここでの 「後処理責任有無」というのは、人の観点ではありません。
言語とコンピューターシステムにおいての「処理責任有無」を表しています。

わかりやすく解釈すると、観点に正確ではないかもですが、以下のような理解で良いと思います。

  • 後処理責任有無-必ず処理必要
    • 解説:処理してくれないと、プログラムを「コンパイルと実行ができない」から、処理してくれ!
  • 後処理責任有無-明示的に強制しない
    • 解説:一旦コンパイルは成功したし「実行もできた」から処理を強制はしないよ!ただ、何が起きるか「システムである私にはわからない」から、「君が責任をとって処理するかしないか決めてね!」

ここで、 「君が責任をとって処理するかしないか決めてね!」 が、大事な題目です。

Runtime Type の例外は、システムでは発生するか否かを判断できないので、処理を強制せず、まず実行を優先します。判断できない・発生しないかもしれない例外に対して、プログラムは実行を止めてまで、処理を強制することはできないからです。

つまり、Non-Runtime とは違って、 「システムには予想できない Runtime 特性の例外の責任は全的にプゴグラマーにある」 ということです。

Q2) なぜ、処理を強制しない Runtime Type を、プログラマーは、もっと注目しなきゃ行けないでしょうか?

サービス運用において、本当に難しく、深刻な状況を生み出すのは、どういう Type の例外でしょう。

筆者の経験では、Runtime 時に発生する予測しなかった問題が圧倒的ですね。
理由は、以下になります。

● Non-Runtime の問題
主に、環境設定や、コーディングミスによる問題。
大体は実行の前段階で発生する。
故に、

  • 発見・原因特定が簡単、修正対応が明確
  • 実行されてもいないので、「システムの整合性の崩れ、永続性データの汚染」なども発生しづらい

● Runtime の問題
主に、予測していない入力値や、論理的欠陥による問題。
大体実行途中で、実行条件により、発生するかもしれないし、しないかもしれない。
故に、

  • 発見・原因特定が難しい、修正対応も困難
  • 実行途中で起きたので、「サービスの整合性の崩れ、永続性データの汚染」が発生しやすい。

「サービスの整合性の崩れ、永続性データの汚染」の捕捉するとこうです。

● サービスの整合性の崩れ
データの整形・数値の計算などで、意図していない論理的欠陥が発生することで、「サービスの論理的整合性」が崩れること

● 永続性データの汚染
Database や File などで、処理途中で止まったか、意図していない結果のデータが、保存されることで、「永続性データの無欠性が崩れること」

このように、Non-Runtime は、大体すぐ気づけて復旧する間にユーザーはその機能を使えないというレベルで止まる場合が多いですが、Runtime のエラーは、サービスの整合性と無欠性そのものを崩してしまう場合も有り得ます

そしてここは、復旧が聞かないほどの「事業レベルでの損害」と繋がる場合も少なくはありません。

Q3) 具体的にどういう部分を注目すればいいでしょうか?

筆者として注目するべきポイントは、以下だと思っています。

  • 予測できる Runtime 特性の例外に関しては、必ずその影響を考え、必要な時は記録・通知・復旧対応をする
  • 予測しづらいポイントに関しては、上位レベルで必ず catch し、詳細ログを記録する(stack trace まで)
  • 予測しづらいポイントでの例外は、critical level とみなし、即時に気付けるようにして、その原因と影響の把握ができるように試みる

正直なところ、Runtime 時に発生できる例外を全て完璧に対象しておくのは、すごく難しいことです。

なので、大事なのは 「発生した時必ず気付けること、原因特定と復旧のために詳細に記録すること、一度発生した問題に対しては永久対応を試みること」 と言えます。


2-3. Custom Exception に対する責任と原則

我々は時々、言語で提供している Exception のインタフェースを継承し、Custom Exception を作る場合があります。特に Framework では、Framework 独自の Custom Exception を多数もっていたりもします。

では、この Custom Exception に対して、どういう責任を付与して、また我々は責任をはたすべきでしょうか。

2-3-1. 主に Runtime Exception 属性を持つ Custom Exception を設計すること

まず、Custom Exception を作る用途は様々ですが、主に推奨するのは、以下の目的での Exception です。

  • Data Validation
  • Broken Data & Status Integrity
  • Access Control & Status Control
  • API Communication Handling
  • その他、ライブラリやフレームワークにおける、エラー処理

ここで、全体的に通用する特徴は「Runtime 特性で起き得る問題」ということです。

つまり、我々が Custom Exception を作る主なニーズは、「システムで責任を取れない Runtime 時に
の問題を人間が対処するために、対処すべき問題を例外としてモデリングする」ことと言えると思います。

※ なぜ、Non-Runtime Exception の CustomException は主な関心事ではないのか?

未具現のメソットの呼び出しとかの例外をアプリケーション独自で処理したく、LogicException を継承して、コードレベルエラーに対して Non-Runtime Exception を定義することもできますが、必要性は低いです。

2-3-2.「明確・具体的」な Exception であること

Custom Exception は、プログラマーが定義する例外です。
つまりプログラマーが、何らかの意図を前もって定義するということです。
そしてその意図は明確で具体的でなければなりません。

明確ではなく、抽象的な意図をもって CustomException を設計すると、システムの例外体型が崩壊します。

ここは、アプリケーションの要件により、様々な答えが正解になったり不正化になったりもする、難しいところではありますが、PART3 の「Application においての Exception 設計」を参考していただければと思います。

まずは、 CustomException は、「明確・具体的な例外であること」 を心に留めておいてください。

👉 PART-2 のまとめ「例外の責任と原則」

PART-2 の内容をまとめると、以下になります。

● 「抽象レベル」観点での責任と原則

  • catch する時は、「具体的」な Exception を catch する
  • 「抽象的」な Exception は、なるべく上位に委任する
  • 「抽象的」な例外に対する後処理は、できるだけ上位階層のポイントで処理する

● 「例外特性」観点での責任と原則

  • Runtime と Non-Runtime を意識して設計・ハンドリングする
  • サービス・アプリケーション運用においては、Runtime 特性の例外を注目し対応・運用する

※ Runtime 特性の例外を軽視しては行けないという意味です。Non-Runtime 特性の例外を軽視してもいいという意味ではありません。

● 「Custom Exception」に対する責任と原則

  • Custom Exception は、Runtime Exception を焦点に設計するのが好ましい
  • Custom Exception は、「明確・具体的」であること

#  PART-3. Application 開発においての「Exceptions 設計・導入」

今回は、実際に Application の開発・運用において、どういう風に設計することができるのかを話します。

ここの話は、Application の要件と開発組織によって、正解だったり不正解だったりする、難しいところですが、筆者が考える、今までの原則を踏まえて解説しようと思います。

ここでお話しする Exception 設計と導入は、以下の意味を示しています。

  • アプリケーション独自処理で、人が定義した、人が予想できる、処理するべきエラーという問題を Custom Exception として設計し定義
  • プログラムで起き得る具体的な Exception の検知・処理
  • プログラムで起き得る抽象的な Exception における検知・処理
  • Exceptions 設計導入アプリケーションの運用

このパートは、Symfony Framework の内容を一部含めていますが、基本原則自体は変わらないので、どのアプリケーションにおいても参考にできれば幸いです。

そして、これまでみてきた内容の元に、話していくので、内容自体は重複する部分が多いとおもいます。今まで話した概念を、より活用に近い観点で再整理するという目線でご覧いただくと良いと思います。

3-1. Exception 設計の導入の長所

開発するアプリケーションにおいて、Exception 設計の導入は、以下の長所を持ちます。

3-1-1. Application における、OOP に基づいた Exception 導入の長所


1) 長所

Exception は「throw・上位伝播」特性をもっています。
そしてこの特性は、以下の長所を提供します。

  • エラーに対して、Exception クラスの特性をもって定義し、詳細な情報を記述できる
    • エラーの特定と対処がしやすくなる。
  • エラーの制御処理が飛躍的に効率的になる。
    • Exception を任意のポイントで発生させ、上位に伝播し、必要なポイントで検知・処理・委任できる。
  • アプリケーション全体のエラーを、階層化・構造化できる
    • エラーという問題は、抽象化モデリングし、綺麗な設計ができる

2) 長所解説 - Exception がいない場合と Exception がある場合のお話し

最近のプログラムは、昔に比べてより複雑になり、様々なライブラリやフレームワークが提供するクラスのメソッドを、階層的に呼ぶ傾向が多くなりました。

こういう環境で、例えば Exception 概念がない言語とみなし、特定のエラーを処理してみましょう。

① Exception 概念がない場合

class EntryPoint
{
    public function execute()
    {
        (new DepthLevel1())->do();
    }
}
class DepthLevel1
{
    public function do()
    {
        (new DepthLevel2())->do();
    }
}
class DepthLevel2
{
    public function do()
    {
        ...
        if ($validationError) {
            return false or error情報を含む変数 or 強制終了
        }

        if ($databaseWorkingError) {
            return false or error情報を含む変数 or 強制終了
        }
    }
}

//実行
(new EntryPoint())->execute();

上記は Exception を使わない場合、よくみられるエラー処理パターンです。
比較的に CallStackが深くなかった昔は、何とかやってこれましたが、プログラムが複雑になり、特に OOP の特性上 CallStack に深くなるしかない現代プログラムでは、以下の課題を抱えています。

  • EntryPoint までエラー情報を伝達し、最終結果をユーザーに表示するためには、各自の CallPoint で、return を受け取り適切な処理をするロジックを実装するか、強制的に実行を終了して結果を返すしかできません。
  • エラーに対して、特定の CallPoint で処理した後、完結させたり委任したりする場合、処理制御がとっても難しいです。
  • return でのエラー伝達は、明確なエラーを伝えることと、エラー構造を守って開発し、プログラムを維持するのが難しいです。
  • 上記の理由により、エラー処理自体がバグポイントになる可能性が高いです。

では、今の構造を Exception を導入したコードに変えてみましょう。

② Exception 概念がある場合

class EntryPoint
{
    public function execute()
    {
        try {
            (new DepthLevel1())->do();
        } catch (ValidationException $ex) {
            ...response 400. BadRequest
        } catch (Exception $ex) {
            ...logging specific information about Unknown Exception
            throw $ex; //もっと上位に委任
        }
    }
}
class DepthLevel1
{
    public function do()
    {
        try {
            (new DepthLevel2())->do();
        } catch (PDOException $ex) {
            ...data check & recovery if needed & logging
            throw $ex;
        }
    }
}
class DepthLevel2
{
    public function do()
    {
        ...
        if (validationError) {
            throw new ValidationException($message);
        }

        $stmt->execute(); //will be throw PDOException
    }
}

$result = (new EntryPoint())->execute();

このように、

  • 上位伝播の特性で、必要なポイントで、必要な処理を実装することで、簡単に制御処理を実現できます。
  • Exception たちは、各自で具体的な意味と、詳細情報を含めているので、予測・原因特定・処理もシンプルになります。
  • Exception たちは、すでにクラスとして構造化され定義されているので、コード観点での維持補修もシンプルになります。
  • 制御の追加・修正もまた、柔軟に行える構造になっています。

OOP の言語では、今まで話した OOP 言語の特性・プログラムの複雑化・Exception 導入の長所をもっと、アプリケーションにおける Exception 設計の導入が勧められます。

3-1-2. Application における、Custom Exception 設計の長所

Custom Exception は、「3-1-1」で語った長所を、サービス・アプリケーションにおいて、解決しなきゃいけない問題である「アプリケーション特有のエラー」に対しても引き継げることにあります。

この長所は、これから説明する「設計パート」をご覧いただくと明確になると思います。

3-2. 導入における Application 構造説明

今回のアプリケーションは、「ユーザーを生成する」というシンプルな目的をはたすという、アプリケーションを想定します。

まず、アプリケーションのクラス構造と、階層を表すと、以下になります。

SymfonyFramework と DDD に起因する構造の説明は省略します。
DDD に関しては、以下の URL や、関連の書籍をご参考いただけると幸いです。

http://uniknow.github.io/AgileDev/site/0.1.8-SNAPSHOT/parent/ddd/core/layered_architecture.html

格クラスの役目を簡単に説明します。


3-2-1. Application Layer

ユーザーの要請受信と、結果の応答、そして「サービスの呼出選定と制御」を果たします。ビジネスロジック処理の責任は持ちません。

● Controller/UserController
ユーザーを生成するための処理のエントリーポイントとなります。

3-2-2. Domain / Service Layer

ドメインモデルの特定ドメインに対して、ビジネス要求に対する処理の責任を持ちます。

● Service/RequestValiator
ユーザーのリクエストに対して、リクエスト情報の整合性をチェックし、その結果を上位に返す機能を果たします。

● Service/UserManagement
ユーザー情報を生成するための処理機能を担当します。
Entity/User のドメインに対して、永続性レポジトリー(DB)作業を遂行します。

3-3-3. Domain / Entity Layer

ドメインモデルの特定ドメインに対して、ドメイン属性スキームの定義を持ちます。

● Entity/User
ユーザーというドメインに対する属性スキームの定義を記述したクラスです。
(簡単に例えると、DB の user テーブルの構造と属性がクラスに記述されています。)

Repository 階層の ORM/EntityManager を通して、ドメイン属性情報を DB に永続的に保存したり、照会したり、修正したり、消したりします。

3-3-4. Repository Layer

インフラに当たる階層です。
Database や File など、永続性システムを利用するため提供されているパッケージや、基本提供コンポーネントなどが、この階層に属します。

● ORM/EntityManage (Doctrine パッケージ・Framework 提供)
Database の永続性システムとのコミュニケーション・処理要請などを行うための DriverLibrary になります。


3-3. Application に Exception 設計導入

「3-2」で説明したアプリケーションの構成に、Exceptions 観点を追加して設計します。

CustomException と、説明のため、一部 PHP の Exception、Framework 提供 Exception を追加で表現します。

クラス構成図は以下になります。

では、今からは、設計観点に基づいて、クラス解説をしていきます。

3-3-1. 抽象階層 CustomException 定義と設計

Point

  • カスタム例外の基盤になる上位階層の設計
  • 抽象レベルが高い階層ということを意識
    • 相対的。アプリケーション(CustomException)範囲に限って、抽象的という意味です。
  • カスタム例外の拡張などで、階層的抽象化・構造の統一性を意識して設計

ここで、抽象階層※8の CustomException は、以下と言えます。
抽象階層の Exception Class として、継承のみを許可し、インスタンス生成は許可しないようにするため、抽象クラスとして定義しています。

① GenieAppException (Bundle/Exception)

  • RuntimeException を継承し、RuntimeException が持つ特性と責任を継承します。
  • 全ての Runtime 特性の CustomException の親になります。
  • プログラマーのミスなどにより CustomException の誤った発生・処理漏れなどで、正しく処理されなかった CustomException に対して、上位レイヤで catch し、適切な情報記録と処理を果たします。

② ServiceException (Bundle/Exception/Service/)

  • GenieAppException を継承します。
  • 全ての Service 階層の Base Exception になります。
  • ServiceException は、getUserMessage()メソッドを持ちます。
    • ユーザーへの適切な応答メッセージの情報を提供する義務を持ちます。

3-3-2. 具体階層 CustomException の設計と設計

Point

  • カスタム例外の具体的なエラーを設計
  • 抽象レベルが低く、具体的な階層ということを意識
  • カスタム例外のエラーは、一番具体的で明確であること

ここで、具体階層の CustomException は、以下と言えます。

① RequestValidatorException (Bundle/Exception/Service/)

  • ServiceException を継承します。
  • Service/RequestValidator から、発生します。
    • Service と Exception を 1:1 でマッピングされるように定義し、各自のクラスの責任を単一責任になるように設計します。
  • Service/RequestValidator から発生する時、明確なエラー情報をセットし、上位伝播します
    • ServiceException は、この情報を元に、getUserMessage()の結果を返すことができます。

3-3-3. Service/UserManagement クラスに対する捕捉

UserManagement サービスに対しては、CustomException を定義する必要がないという設定です。

理由としては、

  • UserEntity と EntityManager を通して、DB にデータを生成するという責任だけを持つサービス
  • 予想される例外は、Entity 階層で発生する Entity&ORM Driver の例外(PDOException など)

の理由であり、UserManagement サービス独自で発生させる例外は不要です。

3-3. 例外のハンドリング

3-3-1. アプリケーション下位階層での、例外の発生とハンドリング

Point

  • CustomException を、必要に応じで意図的に発生させる
  • CustomException が保持する情報は、明確であること
  • Exception を catch する時は、可能であれば具体的に catch する
  • 可能であれば、catch するポイントでしかできない処理が必ず必要な時だけ catch し、必要な処理だけをした後、上位に委任する
  • 処理必要がないか、予想できない、責任を取れない例外は上位に委任する。

① RequestValidator での CustomException の意図的発生

ビジネスロジックの処理で、検証に失敗したら、該当クラスとマッピングされる RequestValidatorException を throw するようにします。

これで、RequestValidator による Exception に対して、上位階層のどのポイントでも処理、またはスルーでき、必要に応じて処理制御を具現できるようになります。

namespace Bundle\Service;

use Bundle\Exception\Service\RequestValidatorException;

class RequestValidator
{
    ...
    public function validate(...)
    {
        ...
        if ($validationFail) {
            throw new RequestValidatorException(...);
        }
    }
}

② Service/UserManagement 内の潜在的な例外のハンドリング

ここは前もってお話しした通り、CustomException の定義は不要という定義と、
Repository 階層の EntityManager は、プレイムワークや PHP が提供する特定 Exception を発生させる可能性があるので、具体的に予想がつく例外に対して対処します。

※ ここは、各自の具体的例外に対して、処理が必要なプログラム要件だと定義します
例外発生が予想されても、このポイントで必ず処理すべき要件がないなら、catch を省略し上位に委任することも場合によっては可能です。

// Service/UserManagement
class UserManagement
{
    ...
    public function create(EntityManager $em)
    {
        ...
        try{
            $em->persist(...)
            $em->flush();
        } catch (DBALException $ex) {
            //some recovery logic & logging for Case of DBALException
            throw $ex;
        } catch (PDOException $ex) {
            //some recovery logic & logging for Case of PDOException
            throw $ex;
        } catch (ORMException $ex) {
            //some recovery logic & logging for Case of ORMException
            throw $ex;
        }
    }
}

3-3-2. アプリケーションでの上位段階での例外のハンドリング

Point

  • アプリケーションにおいて、意図的に発生するエラーに対して共通の処理が必要な場合は、アプリケーションの上位ポイントで例外ハンドリングを行うことができる。
  • アプリケーション要求事項としての共通エラー処理のみを処理する
  • 可能であれば、予測できない、責任取れないエラーは、最上位に委任する。
  • Controller の責任を意識する。(Controller の責任はロジックの流れの制御)

「アプリケーション」でおいての上位は、Contoller の ActionMethod を基準とします。

ここでは、「プルグラマーの意図によって発生可能なカスタム例外」に対しての処理責任を持ちます。

今回の場合は、RequestValidationException がその事例です。

そして、RequestValidationException が提供する情報をもって、意味のあるエラーメッセージ情報を応答としてレスポンスします。

class UserController
{
    public function create(
        LoggerInterface $logger,
        RequestValidator $validator,
        UserManagement $userManagement
    ){
        try {
            $validator->validate(...);
            $userManagement->create(...);
        } catch (RequestValidatorException $ex) {
            $logger->info(...);
            $response = new JsonResponse(...);
            $response->setStatusCode(Response::HTTP_BAD_REQUEST);
            $event->setResponse($response);
        }
    }
}

3-3-3. インフラ階層観点御最上位段階での例外のハンドリング

Point

  • 想定していない例外に対して、探知の責任をはたす
  • critical level として扱い、ログ記録と通知を前提とする
  • 可能であれば、発生した例外に対して、恒久対応を試み、具体的な問題として再定義する。
    • 正しいエラーハンドリングをアクリケーションに追加するという意味です

アプリケーションを含め、アプリケーション実行のための基盤階層(Framework, PHP)においての最上位段階での例外ハンドリングの一例になります。

今回は、Framework で提供する ExceptionListener という機能を使います。

この機能は、EventHook 概念で、Framework のロジック処理の段階の一部を横取りすることが可能です。

つまり、Framework に従い我々が実装したビジネスロジックのどこかで、catch していない得体不明の例外が投げられても、全てここで横取りできます。

ただし、ここは、サービス全体においての最上位と見なすべきなので、「想定されていない得体不明の例外の探知」を目的としたハンドリングを試みます。

// src/EventListener/ExceptionListener.php
class ExceptionListener
{
    public function onKernelException(ExceptionEvent $event)
    {
        // You get the exception object from the received event
        $ex = $event->getThrowable();

        // Can handle for each Exceptions if you need
        if ($ex instanceOf ServiceException) {
            ...logic For Unexpected Custom Exception
        } else if ($ex instanceOf GenieAppException) {
            ...logic For Unexpected Custom Exception
        } else {
            ...logic For Unexpected UnknownException
        }

        $logger->critical($ex); //logging with stackTrace
        $response->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
        $event->setResponse($response);
    }
}

3-4. Application への Exception 設計導入例におけるまとめ(長所と課題)

ユーザー情報を生成するという単純な機能に対して、Symfony(DDD)のアプリケーション構造に対する、Exceptions 設計導入方法の例を説明しました。

最後に、上記の設計において、筆者が思う長所と課題を記述します。実際のアプリケーションにおいての開発の時に、参考になると幸いです。

3-4-1. 長所

1) エラーの構造化

我々がサービスを開発する時、我々は様々な操作ケースに対して、常に正常結果のみを処理している訳ではありません。

「入力値検証・アクセス制御・操作プログレス制御・ステータス制御・外部 API 通信問題・システム状態によるトラブル」 など、様さざなイレギュラーケースに対して、我々は、アプリケーション要件として定義し、非正常結果として扱わなければなりません。

こういう、 「非正常」という課題に対して、Exceptions 設計導入は、課題に対する「階層的抽象化」モデルの確立を実現できる手法です。

これがもたらす、メリットに対しては、PART1 と PART3 の内容となります。

2) エラーに対する制御処理の柔軟性

例外は、「上位伝播」という特性をもっています。
そして catch には、「ポリモーフィズム」原理が適用されるので、エラー処理に対して、「どこで」「どういう」例外を、「処理」するか、「委任」するかを、柔軟に制御できます。

特に、エラーの世界では、この柔軟さがシステムの「完結さ」と「底欠陥さ」をもたらしてくれます。

その理由に関しては、PART 2と PART3 の内容となります。

3) エラー処理の共通化

意図的に共通化したい例外、想定していない例外に対して、アプリケーション・インフラの上位階層で、共通処理を実現できることで、エラーに対する後処理の「統一性」「処理保証」「探知保証」をもたらしてくれます。

それで、エラー処理に対しる処理の漏れや、処理漏れ、認知漏れを防げます。

PART3 で紹介した、Entry Point での、Validation エラーに対する共通処理と、EventListener での onKernelException()の処理がその例です。

3-4-2. 課題

1) 維持補修開発において、設計構造の共通認識としてのハードル

しかし、この設計領域は、筆者自身としても、高難易度の設計領域です。

まず、設計のためには、「言語・フレームワーク観点でのプログラム設計」「基本提供の例外設計」「ビジネス・サービス要件に対しての要求定義と例外設計」のスキルがまず必要となってきます。

そして、作られた設計に対しても、チームで開発する時は「共通認識」として浸透させる必要があります。そうでない場合は、開発や維持補修段階で、統一性がくずれる可能性が高いです。

ここに関しては、難しいところですが、以下の対策を意識することで、補えると思います。

● ドキュメント化と、ドキュメントの運用
設計と、コードをドキュメント化し、運用することで、全体情報の熟知、最新情報に維持することができます。そして、プログラムの改修時の「監理」の役目も果たします。

しかし、やはり時間が必要な対策です。
なので、「必要な文書だけシンプルに維持する」ことが大事です。

2) 維持補修開発において、統一性を守というハードル

上記の内容でも少し話しましたが、アプリケーションの統一性を維持することがハードルになります。

  • 変化し続けるビジネス要件
  • 多数の人がプログラムを開発することによる、コードの差
  • 現実的な時間確保の難しさ

などが、問題になってきます。

ここに関しても、やはり難しいところですが、以下の対策を意識することで、補えると思います。

● 実装前にインタフェース構造実装からする・レビュー後、具体的ロジックを実装
実装時にロジックをすぐ実装するのではなく、まずメソッドのインタフェースと、依存関係の先に「疑似コード」として、作成し、初期段階でコードレビューを行います。

実際には詳細設計の下位工程のレビューと言えます。

これにより、プログラムの構造に対しての整合性に影響する可能性がある問題に関して事前に検知する可能性が高くなります。

● 周期的なリファクタリング

周期的リファクタリングを時間を持つと、プログラムはもちろん、アプリケーションの構造、ドキュメントの整理などもできます。


もちろん、上記に説明した問題は、Exceptions 設計に限る話ではないですが、Exceptions 設計は、場合によってはその課題の難易度を高め、さらに複雑なアプリケーションになってしまう可能性もまたあります。

そこを注意することは、とても大事です。

PART-4. 例外設計原則まとめ

4-1. Exceptions 設計原則まとめ(筆者主観含む)

今まで見てきた内容を、原則という文章でまとめます。
それ以外に、覚えておくと良いと思う原則も少し加えています。

4-1-1. OOP 観点での原則

  • 例外(Exceptions)は、プログラム上で起き得る「事故を階層的に抽象化」モデリングした物である
  • 「上位階層なほど抽象的、下位階層なほど具体的」である
  • 「具体的」は「抽象的」にもなれる
  • 抽象的な例外ほど予測と正確な対処が難しい
  • try-catch をするということは、一種の「保険」をかけること

4-1-2.「抽象レベル」観点での責任と原則

  • catch する時は、「具体的」な Exception を catch する
  • catch した時は、該当ポイントの制御フローまで責任をとる※9
  • 例外発生前後は、クラスの状態が有効生を保つように意識する
  • 「抽象的」な Exception は、なるべく上位に委任する
  • できれば、Exception, RuntimeException のような General Exceptions を catch しない。
  • 責任を取れそうにない例外は catch しない
  • ほっとけば、最上位階層でまとめて処理される
  • 「抽象的」な例外に対する後処理は、できるだけ上位階層のポイントで処理する
  • 最上位のエラー処理は必ず「探知・ログ記録・通知」の責任をとる(特別なことがない限り)

4-1-3.「例外特性」観点での責任と原則

  • Runtime と Non-Runtime を意識して設計・ハンドリングする
  • サービス・アプリケーション運用においては、Runtime 特性の例外を注目し対応・運用する

4-1-4.「Custom Exception」に対する責任と原則

  • Custom Exception は、Runtime Exception を焦点に設計するのが好ましい
  • Custom Exception は、「明確・具体的」であること

4-2-5. その他、意識しておけば良い原則

  • できれば、複数階層で重複で処理しない
    • ここは、具体的な例外に対してはしない、抽象的な例外に対しては最小限でという意味で解釈して良いと思います。
  • できれば、Error は処理しない
  • できれば、Non-Runtime(Checked)属性の例外は、ビジネス的な意味がある要件の実現必要性がある時のみ導入する
  • 例外の記録は、ログフレームワークを使い処理する。(監視・通知体勢も備える)

今回の後書き

最初、この記事を考えた理由は、最近、頭の中で考えていた「古いサービスの現状のエラー処理の課題間」と、追加開発においての「エラー設計」に関して、ふわっと思っていたところからです。

もちろん、今までの経験である程度の知識や経験はあったと思ってましたが、改めて「筆者自身は Exception に対してどれだけ理解していて、説明できるか?」を、自分自身に問うようになりました。

考えてみると、破片的な知識と経験が多くあるものの「まとまってない」ということが、自分自身の中で出した結論です。

そこで、この記事の作成を通して、以下の成そうとしました。

  • ゴール
    • 「まとまって明確な知識」として、自分自身の中で再構築
    • 筆者自身の知識の情報検証
  • 過程
    • 破片的な知識と経験の可視化
    • 上記の内容を、理論と照らし合わし検証
    • 上記の内容を構造的に整理
    • 上記の内容から核心を抽出
    • 核心を強く認識し「まとまって明確な知識」として、自分自身の中で再構築

ゴールは、ある程度満たせたんじゃないかなと、思っています。

しかしやってみながら、まだ足りないところも多いなと思いながら、書いた感じです。
特に以下の部分などなどに関しては、まだまだ足りず、精進しないという反省もあります。

  • もっと深い OOP の理解
  • ビジネス課題を踏まえた DDD アーキテクチャ設計
  • 基本提供エラー機能・クラスの理解
  • FRAMEWORK 自体の理解
  • Application においての、理想的なエラー設計・運用

そして、思ったより分量が多くなってしまったのは、筆者自身の課題かもしれません。

ともあれ、これからも、開発であれ、なんであれ、頑張って楽しくやって行きたいと存じます。

※注釈

※1)
Exception に関わる事例コードとかをみて、書いて、その結果を身で直接感じた上で、次に進めることをお勧めします

本の分量だと 10page 内外だと思います。まずは全てを理解するより、書いたコードに対する結果を体感することの先行が大事だと思うので、軽い感じでお試しください。

※2
上位に委任するほど、抽象的になったしまう傾向が強い

例外は、発生ポイントからだんだん上位に伝播されると、その上位ではだんだん抽象的例外として扱うことになるという意味です。

上位にいくほど、例外が発生するポイントとその種類もだんだん増えていくのがその理由です。

※3
上位階層が知るべき情報も、このレベルで遮断され、適切な対応ができなくなる。

実は、特定の例外に対して、上位段階でも、ログを残したりとか通知をするとか、なんらかの対応の責任があるかもしれません。

しかし、発生ポイントで処理するまでは良いですが、上位階層でやるべき責務がある状態で、上位に伝播(報告)せず、例外を揉み消してしまうと、いろんな問題を起こしてしまう可能性もあります。

※4
Call Stack の下位ポイント

Call Stackいうのは、プログラムのメソッドの呼出の深さを意味します。
以下の記事をご参考いただけます。
https://qiita.com/righteous/items/494340cf16c7a35f742e

※5
何が起きるのかわからない Exception に対しての対応なので、phpdoc の throws 構文も書かない方が、筆者的にはお勧めです

phpdocで、throwsを明示的に記載するのは、「ハンドリングをすることを強くアピールする」ことだと思います。なので、他の人がみると、不要にcatchする傾向が現れるかもしれません。

※6
Runtime Exception を継承しない Exception は、全て Non-Runtime Exception と分類できます

しかし、前もって話したように、PHPの特性で境界が曖昧なところがあると思うのが、筆者としての考えです。

※7
Non-Runtime 例外は、コンパイル時に発生する可能性がある例外です。

上記と同じく、PHPの特性で境界が曖昧なところがあると思うのが、筆者としての考えです。

※8
抽象階層

プログラマーが具現するアプリケーション範囲に限定してでの、抽象階層という意味です。
FRAMEWORK, PHPなどインフラ階層まで含めると、インフラ階層よりは、Custom Exceptionが遥かに具体的階層に存在することになります。

抽象的というのは、あくまで相対的ということを意識すると良いです。基準によって、基準より抽象的にもなるし、具体的にも慣れます。

※9
catch した時は、該当ポイントの制御フローまで責任をとる

catchをした場合は、処理として、該当ポイントの処理の最後やで責任をもって制御の責任をとることです。

方法としては、

  • 上位に委任する (制御を途中で中断し、上位に責任を委ねる)
  • 処理を完結する場合、該当ポイントの処理の結果の整合性に責任をとる。

ということです。

例えば、{変数宣言; try {変数に値をアサイン;} catch {エラー記録;} 変数操作・変更・リターン}が悪い例。catch 以降の処理が、制御責任を違反する動きだと言えます。

Discussion