PHP Exceptions 種類與使用情境說明

Written by Simon Asika on

file

更新為 PHP 7 以後的版本

PHP 的 Exceptions 提供我們一個方便的方法處理錯誤,不過許多人並不是完全知道每一種不同的Exception代表什麼意思,以及如何使用。有鑑於中文資料實在太少了,我在這邊做一點講解,讓大家可以更流暢的使用不同的 Exception 物件。

在看這篇文章之前,妳可能需要先具備基礎的 Exception 與 try catch 知識。

  • http://blog.xuite.net/ghostjackyo/WorkTest/37743727-PHP+Try+Catch
  • http://jaceju.net/2010-04-23-handle-php-error-and-exception/
  • http://www.w3school.com.cn/php/php_exception.asp

主要 Exception

在 PHP 5,Exception 是最終端的主要類別,所有 PHP exception 類別都由此繼承出來。這個 Exception 提供了 getCode(), getMessage()getTrace() 等方法,讓我們取得當下的執行資訊。

要知道,Exception 並不是只有要丟的時候才 new 出來,我們可以在產生錯誤時預先 new 出來,記錄下當下的資訊,然後後續才決定何時丟出去。

$exception = new Exception;

// Do something

throw $exception;

或者接下來之後,做一點處理與判斷再繼續丟

catch (Exception $e)
{
    if (DEBUG)
    {
        throw $e;
    }

    exit('Sorry, system error.');
}

PHP7 以後

c66c98c187d8823cc3b5cd1e601c87de

via https://www.slideshare.net/Codemotion/what-to-expect-from-php7

  • Throwable
    • Error
      • ArithmeticError
        • DivisionByZeroError
      • AssertionError
      • ParseError
      • TypeError
        • ArgumentCountError
    • Exception
      • ...

最上層 Exception 改為 Throwable,其下分出 ErrorException 兩大分支。 Error 用來取代原有的錯誤訊息,因此,php7以後的錯誤大多可以用 try catch 捕獲了。

Throwable

Throwable 是最上層所有可以被 throw 關鍵字丟出的 interface,它不能直接 new,也不能直接用 class implement,一定要另外從 Error 或 Exception 繼承出來才能使用。使用 Throwable 的好處是可以連同一般語法錯誤也當作 Exception 進行錯誤處理與顯示除錯訊息。

在 PHP5 中,可以用兩層 catch 來確保同時運作在 5 與 7 中所有錯誤都被捕獲:

try {
    // ...
} catch (Exception $e) {
    // ...
} catch (Throwable $t) {
    // ...
}

Throwable 與 Exception 的順序可以自行調整,若 Throwable 放上面表示在 php7 中後面的 Exception 永遠不會被捕獲。

Error 部分

基本上,Error 系列的 Exception 都不需要自行丟出,但以下稍微說明使用的情境。

ArithmeticError

算數錯誤,並不常見,舉例來說當你用位元運算子位移-1時

try {
    var_dump(1 >> -1);
} catch (ArithmeticError $e) {
    echo $e->getMessage(); // ArithmeticError: Bit shift by negative number
}

DivisionByZeroError

數字除以 0 時會丟出的錯誤

try {
    var_dump(5 / 0);
} catch (DivisionByZeroError $e) {
    echo $e->getMessage(); // Division by zero
}

AssertionError

使用 assert() 來斷言時丟出的錯誤。

try {
    assert(2 < 1, 'Two is less than one');
}
catch (AssertionError $e) {
    echo $e->getMessage(); // Warning: assert(): Two is less than one failed
}

比較特別的點在於,php 預設不會真的丟出 AssertionError,你必須在 php.ini 中配置 assert.exception1

也可以在 assert() 第二個參數放入 Throwable 物件,它會幫你丟出這個物件。

詳情請見 PHP 文件: http://php.net/manual/en/function.assert.php

ParseError

當 require 的檔案有語法錯誤時丟出的 Error,eval() 執行的程式有語法錯誤也會丟出來,例如以下範例:

try {
    eval('$a = 4'); // 沒有分號

    // OR

    require 'some-file-with-syntax-error.php';
}
catch (ParseError $e) {
    echo $e->getMessage(); // PHP Parse error:  syntax error, unexpected end of file
}

在檔案本身的語法錯誤因為會直接編譯失敗,也就不會丟出 Error 了,而是直接 Fatal Error。

TypeError

由於 PHP7 加入了型別判斷,當型別錯誤時就會丟出此 Error

declare (strict_types = 1);

function plus(int $i) 
{
    return $i + 1;
}

try {
    plus('5');
}
catch (TypeError $e) {
    echo $e->getMessage(); // Argument 1 passed to plus() must be of the type integer, string given
}

注意,沒有加 declare (strict_types = 1); 的話,php 還是會幫你把 '5' 轉成 5

ArgumentCountError (PHP 7.1)

傳入函式的參數數量太少或不正確時丟出

function foo($a, $b){
    return $a / $b;
}

try{
    foo(1);
} catch (ArgumentCountError $e) {
    echo $e->getMessage(); // Too few arguments to function foo(), 1 passed in
}

Exception 部分

Runtime 與 Logic Exception

RuntimeExceptionLogicException 是唯二由 Exception 繼承出來的核心例外類別,也代表 Exception 的兩大分支。

RuntimeException

首先是 RuntimeExcption,這是執行期例外,所謂的執行期例外就是指程式身本以外無法由開發者控制的狀況。例如呼叫資料庫,但是資料庫沒有回應,或者呼叫遠端 API ,可是 API 卻沒有傳回正確的數值。另外也包含檔案系統或環境的問題,例如程式要抓取的的某個外部檔案不存在,或者應該安裝的外部函式庫沒有正常運作,這些因為都不是開發者可以控制的,我們就統稱為 RuntimeException。

Web 開發最常見的狀況就是資料庫連線失敗,或者SQL查詢錯誤。例如 PDO 所丟出來的 PDOException 就是既承自 RuntimeException。所以我們要判斷是否是資料庫錯誤可以這樣寫:

try {
    // some database operation
    $db->fetch();
} catch (RuntimeException $e) {
    // Database error
} catch (Exception $e) {
    // Other error
}

我們並不知道 $db 是否是 PDO 還是其他資料庫物件,但是只要是資料庫錯誤,慣例上都應該回傳 RuntimeExcepiton,因此就能夠跟其他的 Exceptions 分開來。

LogicException

LogicException 則是反過來,屬於程式本身的問題,應該要是開發者事前就解決的問題。例如某個應該要被 Override 的 method 沒有被 override,但 class 本身又因故無法設為 abstract 時,我們就在 method 中丟出 LogicException,要求開發者一定要處理這個問題。另外,class未被引入,呼叫不存在的物件,或是妳把數值除以零等等都屬於此例外。

class Command
{
    public function execute()
    {
        if ($this->handler instanceof Closure) {
            return $this->handler();
        }

        throw new LogicExcpetion('Handler should be callable.');
    }
}

由此可知,一個正常運作的系統,應該可以允許 RuntimeException 的出現,但是 LogicException 是應該要完全見不到的。

其他更多 Exceptions

接下來則是簡單說明一下其他各種不同的 exceptions 差異。

Runtime 系列

UnexpectedValueException

當一個返回的結果不是預期的值或類型時,例如資料庫正確返回資料過來,或者函式正確返回資料,但資料內容卻不是我們想要的時候,則丟出此例外。

$response = HttpClient::get($url);

if (!$response->getHead()) {
    throw new UnexpectedValueException('Response not return anything.');
}

OutOfBoundsException

當我們從一個不確定長度的陣列或容器取值,但索引值不合法或不存在時,丟出此例外。因為不確定長度的陣列意味著內容可能是由外部資源或資料庫決定的,不屬於開發者的控制範圍,故為Runtime Error。

$items = Database::loadAll('users');

if (!isset($items[$i])) {
    throw new OutOfBoundsException('User ' . $i . ' not exists.');
}

OverflowException

當我們加入一個元素到滿載的容器時,同樣的此容器的實際容量不被開發者控制,只有嘗試加進去後才會發現此錯誤,那麼便丟出此例外。

// In a for loop... We only allow 3 items
if ($i < 3) {
    $this->items[$i] = $item;
}

throw new OverflowException('Too many items');

RangeException

這是 DomainException 的 Runtime 版本,通常用在你要存取一組預先定義好的資料集內沒有的項目時。例如 foo 不屬於 weekdays:

if (Weekdays::isWeekday('foo')) {
    throw new RangeException('foo is not in weekdays.');
}

另外當我們試圖存取的資源不存在時,也可以丟出此例外。例如某個 extension 或資料庫 Driver 尚未安裝時,因為外部 extension 與套件不一定是開發者可以控制的,可能是網管或主機商決定,因此也屬於 Runtime error。

$dbType = 'mysqli';

if (!extension_loaded($dbType)) {
    throw new RangeException($dbType . ' has not installed.');
}

UnderflowException

當我們從一個空容器或空陣列移除元素時,就會丟出此例外。

if (count($array) > 1) {
    array_pop($array);
} else {
    throw new UnderflowException('Do not remove element from an empty array.');
}

Logic 系列

BadFunctionCallException

呼叫了一個未被定義,或不允許被使用的函式,或者函式缺乏必備的參數,還有當 is_callable() 或類似的函式回傳否時,丟出此例外。由於這不是使用者決定的,而是開發者的問題,故屬於 Logic Exception。

if (!is_callable($this->handler)) {
    throw new BadFunctionCallException('Function is not callable.');
}

BadMethodCallException

與 BadFunctionCallException 相似,是 Method 用的版本。

有時可以用在 __call() 抓不到對應的執行對象時:

public function __call($name, $args) 
{
    if (is_callable($this->marcos, $name)) {
        return $this->marcos[$name](...$args);
    }

    throw new BadMethodCallException(
        sprintf('Method %s::%s() not found.', get_called_class(), __FUNCTION__)
    );
}

InvalidArgumentException

不合規格的函式參數丟進來時,比如應該是字串,卻拿到陣列時,丟出此例外。與 TypeError 的差別在於不單純只有型別的不合,任何不允許的內容都可以丟出此例外。

function flower($sakura)
{
    if (is_array($sakura) || is_object($string))
    {
        throw new InvalidArgumentException('Sakura should not be array or object.');
    }
}

DomainException

這是 RangeException 的 Logic 版本,用在存取一系列已定義的資料群集時,找不到對應的資源則丟出此例外。例如當我們限制允許處理的圖片類型時:

switch($mime) {
    case 'image/jpeg':
        // ...
    case 'image/png':
        // ...
    default:
        throw new DomainException('Only allow PNG/JPG files.');
}

又或者你嘗試處理某張 tiff 圖片,但 tiff 的解析引擎卻還沒實作在程式中,皆可屬於 DomainException

也可以用在針對外部資源的內部實現是否存在的判斷,例如有人想要操作 MangoDB 時,這個 Driver 的 class 卻尚未在框架中的資料庫存取器實作。

abstract class DatabaseDriver
{
    public static function getInstance($type, $dsn)
    {
        $class = 'DatabseDriver' . ucfirst($type);

        if (!class_exists($class))
        {
            throw new DomainException($type . ' driver not found.');
        }

        return new $class($dsn);
    }
}

DatabaseDriver::getInstance('mongodb'); // mongodb driver not found.

LengthException

當一個檔案或參數的長度不符合或高於預期時,例如一個檔案砸湊值長度跟原本預設的不一樣,則丟出此例外。

$uuid = Uuid::v4();

if (strlen($uuid) > 36) {
    throw new LengthException('Hey, this is not UUID!!!');
}

OutOfRangeException

OutOfBoundsException 的 Logic 版本,當我們試圖從一個固定長度資料集存取不合法的索引值時丟錯。

例如

$steps = ['1st', '2nd', '3rd'];

if (!in_array($currentStep, $steps)) {
    throw new OutOfRangeException('Hey, there won\'t be more than 4 steps...');
}

與 RangeException 和 DomainException 的差別在於他們是針對值本身的存在與否,而 OutOfRangeException 與 OutOfBoundsException 是針對索引的存在與否。

總覽圖

最後可以看一下這張圖:

8281e3673909c6794b21718b795d287a

抓取第一層 Exceptions

大多數時候,我們都被要求所有 exceptions 應該要被抓取,或者不應該直接丟上第一層,因為這樣會錯誤的把系統資訊展示到終端使用者身上。不過我們可以借此覆蓋 php 的最上層 exception 捕獲行為,讓 php 用你自定的 view 來展示錯誤頁面,這樣就可以在一般模式關掉時顯示單純的500錯誤畫面,dubug 模式啟動時則顯示完整的自定 debug 系統資訊畫面。

abstract class ErrorHandler
{
    public static function exception(\Exception $exception)
    {
        $response = (new Response())
            ->withStatus(500);

        if (!IS_DEBUG) {
            $response->getBody()
                ->write('Internal error, please contact administrator.');

            Output::emit($response);

            exit();
        }

        $response->getBody()->write(
            View::render('system.error-page'), ['exception' => $exception]
        );

        Output::emit($response);

        exit();
    }
}

// Register our handler
restore_exception_handler();
set_exception_handler(array('ErrorHandler', 'exception'));

把 Error 也當作 excepion 處理 (for PHP5)

我們稍微擴展一下上面的 class,連 error 行為都採用自定的 handler,並且把相關資訊做成 ErrorException 丟出,則 try catch 也可以補獲一般的 php 語言級別的錯誤了。

abstract class ErrorHandler
{
    public static function exception(\Exception $exception)
    {
        $response = (new Response())
            ->withStatus(500);

        if (!IS_DEBUG) {
            $response->getBody()
                ->write('Internal error, please contact administrator.');

            Output::emit($response);

            exit();
        }

        $response->getBody()->write(
            View::render('system.error-page'), ['exception' => $exception]
        );

        Output::emit($response);

        exit();
    }

    public static function error($errno ,$errstr ,$errfile, $errline ,$errcontext)
    {
        $content = sprintf('%s. File: %s (line: %s)', $errstr, $errfile, $errno);
        $exception = new ErrorException($content, $errno, 1, $errfile, $errline);
        static::exception($exception);
    }

    public static function registerErrorHandler()
    {
        restore_error_handler();
        restore_exception_handler();

        set_error_handler(array('ErrorHandler', 'error'));
        set_exception_handler(array('ErrorHandler', 'exception'));
    }
}

// Register our handler
ErrorHandler::registerErrorHandler();

PHP7 中不再需要這樣處理了

總結

大概介紹一些比較重要的 Excepiton 到此,善用這些不同的 exceptions,或是自定新的exception class 能夠讓你的程式有更多除錯上的彈性。例如:

try {
    $app->execute();
} catch (RoutingException $e) {
    // RoutingException 是我們為自己的程式定義的新 Exception
    $app->exit('Bad route', 404);
} catch (RuntimeException $e) {
    // Runtime 類的 exception 跑一個流程讓我們額外除錯
    // 例如丟 log 記錄之類的
    Logger::log($e->getMessage(), LOG_LEVEL_ERROR);

    if (IS_DEBUG) {
        throw $e;
    }

    $app->exit('SQL error', 500);
} catch (Throwable $e) {
    // 最後所有其他 exception & Error 統一處理
    if (IS_DEBUG) {
        throw $e;
    }

    $app->exit('Internal error', 500);
}

有沒有注意到沒有 LogicException? 因為所有的 LogicException都直接到最下層硬是丟出去了,讓開發者一定要處理這個問題。

Control Tools

WS-logo