重寫Laravel異常處理類

itbsl發表於2020-12-18

現在開發前後端分離變得越來越流行了,後端只提供介面返回json格式的資料,即使是錯誤資訊也要以json格式來返回,然而目前無論是Laravel框架還是ThinkPHP框架,都只提供了返回json資料的方法,對異常的處理並不是以json格式來返回給我們,所以這裡就需要我們自己來改寫。

首先我們在app/Exceptions目錄新建一個ExceptionHandler.php繼承自Handler.php

namespace App\Exceptions;


class ExceptionHandler extends Handler
{

}

然後我們在bootstrap/app.php中,使用我們自定義的異常處理類ExceptionHandler替換掉預設的Handler類

//改為我們自定義的ExceptionHandler類
$app->singleton(
    Illuminate\Contracts\Debug\ExceptionHandler::class,
    App\Exceptions\ExceptionHandler::class
);

接下來我們就開始重寫渲染方法

在render方法裡,我們根據.env檔案中的APP_DEBUG來判斷,如果是除錯模式,我們還是按照預設方式來渲染錯誤,如果是非除錯模式,我們就返回JSON格式的資訊

namespace App\Exceptions;

use Exception;

class ExceptionHandler extends Handler
{
    public function render($request, Exception $exception)
    {
        if (env('APP_DEBUG')) {
            return parent::render($request, $exception);
        }
        return response()->json([
            'code' => $exception->getCode(),
            'msg'  => $exception->getMessage()
        ]);
    }
}

這樣我們就可以根據APP_DEBUG的值設定是否返回JSON格式的資料了,現在我們把.env的APP_DEBUG的值設為false來測試一下,然後我們故意把程式碼寫錯,通過postman或瀏覽器來訪問介面

Route::get('/', function () {
    //這是一段缺少了分號的程式碼,會報異常
    echo 'Hello World!'
});

在APP_DEBUG=true的情況下還仍然是預設渲染,方便我們查詢錯誤排錯

異常類預設會把異常以日誌的形式記錄在storage/logs目錄下,並且以laravel-日期(YYYY-MM-DD)命名的形式,.log為字尾儲存錯誤日誌

我們開啟這個日誌檔案檢視記錄的錯誤資訊,我們可以發現錯誤資訊記錄的非常詳細,除了錯誤說明之外,還記錄了呼叫棧,如下圖所示

基本上紅框裡的資訊就夠我們排錯了,不需要像現在這樣記錄的這麼詳細,所以要想不記錄呼叫棧,我們可以重寫report方法

首先我們看一下框架的report方法,程式碼在(src/Illuminate/Foundation/Exceptions/Handler.php),我用紅框框起來的程式碼就是呼叫棧資訊,我們在重寫這個方法時只需要完全拷貝這個方法裡的所有程式碼到我們自定義的report方法裡,然後把紅框裡的程式碼去掉即可

我們在我們自定義的異常處理類ExceptionHandler.php中重寫report方法

public function report(Exception $exception)
{
    if ($this->shouldntReport($exception)) {
        return;
    }

    if (Reflector::isCallable($reportCallable = [$exception, 'report'])) {
        return $this->container->call($reportCallable);
    }

    try {
        $logger = $this->container->make(LoggerInterface::class);
    } catch (Exception $ex) {
        throw $exception;
    }

    $logger->error(
        $exception->getMessage()
    );
}

然後我們再重新請求一下介面再去檢視錯誤日誌的記錄,可以發現確實沒有記錄呼叫棧資訊了,但是下面的資訊還是不夠,我們沒法根據下面的資訊判斷錯誤發生在哪一個檔案和哪一行,如果能在記錄錯誤資訊的時候同時記錄發生錯誤的檔案和行就更好了,所以藉著修改report方法

public function report(Exception $exception)
{
    if ($this->shouldntReport($exception)) {
        return;
    }

    if (Reflector::isCallable($reportCallable = [$exception, 'report'])) {
        return $this->container->call($reportCallable);
    }

    try {
        $logger = $this->container->make(LoggerInterface::class);
    } catch (Exception $ex) {
        throw $exception;
    }

    $logger->error(
        $exception->getMessage()." at ".$exception->getFile().":".$exception->getLine()
    );
}

在程式碼裡我通過exception的getFile()、getLine()方法加上了檔案和行數,儲存程式碼再次訪問介面,檢視錯誤日誌檔案我們可以看到發生錯誤的檔案和行數已經記錄下來了,有了這些資訊基本我們就可以找到錯誤

截止到這裡實現最初的需求我們的ExceptionHandler.php只需要有這些程式碼

namespace App\Exceptions;


use Exception;
use Illuminate\Support\Reflector;
use Psr\Log\LoggerInterface;

class ExceptionHandler extends Handler
{

    public function render($request, Exception $exception)
    {
        if (env('APP_DEBUG')) {
            return parent::render($request, $exception);
        }
        return response()->json([
            'code' => $exception->getCode(),
            'msg'  => $exception->getMessage()
        ]);
    }

    public function report(Exception $exception)
    {
        if ($this->shouldntReport($exception)) {
            return;
        }

        if (Reflector::isCallable($reportCallable = [$exception, 'report'])) {
            return $this->container->call($reportCallable);
        }

        try {
            $logger = $this->container->make(LoggerInterface::class);
        } catch (Exception $ex) {
            throw $exception;
        }

        $logger->error(
            $exception->getMessage()." at ".$exception->getFile().":".$exception->getLine()
        );
    }
}

然後還不夠,我們發現剛剛我們把伺服器端的錯誤資訊以JSON格式返回給客戶端了,這是不允許的,我們應該只把一些客戶端錯誤返回給客戶端,比如密碼不足六位、身份證不合法諸如此類,而服務端出現錯誤時我們只返回給客戶端一個模糊的資訊即可,比如“伺服器錯誤”,把真實的伺服器錯誤資訊記錄在日誌裡面方便開發人員排查錯誤

所以我們需要定義一個客戶端異常專門使用者返回客戶端錯誤,使用如下命令在app/Exceptions目錄下生成一個ClientException.php檔案

php artisan make:exception ClientException

修改為構造方法為如下程式碼

namespace App\Exceptions;

use Exception;

class ClientException extends Exception
{
    public function __construct($code, $msg)
    {
        parent::__construct($msg, $code);
    }
}

接著我們繼續修改ExceptionHandler.php

namespace App\Exceptions;


use Exception;
use Illuminate\Support\Reflector;
use Psr\Log\LoggerInterface;

class ExceptionHandler extends Handler
{
    /**
     * @var int 錯誤碼
     */
    protected $code;
    /**
     * @var string 錯誤資訊
     */
    protected $message;

    protected $dontReport = [
        ClientException::class
    ];

    public function render($request, Exception $exception)
    {
        if ($exception instanceof ClientException) {
            $this->code = $exception->getCode();
            $this->message = $exception->getMessage();
        } else {
            if (env('APP_DEBUG')) {
                return parent::render($request, $exception);
            }
            
            $this->code = 500;
            $this->message = '伺服器錯誤';
        }
        
        return response()->json([
            'code' => $this->code,
            'msg'  => $this->message
        ]);
    }

    public function report(Exception $exception)
    {
        if ($this->shouldntReport($exception)) {
            return;
        }

        if (Reflector::isCallable($reportCallable = [$exception, 'report'])) {
            return $this->container->call($reportCallable);
        }

        try {
            $logger = $this->container->make(LoggerInterface::class);
        } catch (Exception $ex) {
            throw $exception;
        }

        $logger->error(
            $exception->getMessage()." at ".$exception->getFile().":".$exception->getLine()
        );
    }
}

對於上面的修改做一下說明,laravel的$dontReport屬性的異常類都不會被上報,因為客戶端錯誤資訊我們不需要記錄,所以將其新增到$dontReport屬性裡,並且在render方法裡把異常大概分為了兩大類,一大類就是客戶端異常,另一大類就是伺服器異常,我們把伺服器異常統一code為500,錯誤資訊為伺服器錯誤,將真實的錯誤資訊記錄在了錯誤日誌裡,避免把伺服器資訊暴露給了客戶端。

現在我們來測試我們重寫異常的結果

假如我們想返回客戶端異常,比如沒有許可權,這類客戶端異常在錯誤日誌裡都不會產生記錄,我們本身也不需要記錄

Route::get('/', function () {
    throw new \App\Exceptions\ClientException(403, '你沒有許可權');
});

對於伺服器端的錯誤,如少些了分號,客戶端就只會知道伺服器的某個介面出了問題,但是不清楚具體問題是什麼

Route::get('/', function () {
    echo 'Hello World!'
});

但是真實的錯誤資訊會記錄在錯誤日誌裡,我們仍舊可以通過錯誤日誌來修改我們服務端的錯誤

我們還可以在render方法中加入告警程式碼,如果是服務端錯誤就給管理員傳送郵件。

至此,我們的重寫Laravel異常處理類就算完成啦,希望對正在準備使用Laravel做前後端分離專案的你有所幫助。

相關文章