Laravel Exception結合自定義Log服務的使用

alwayslinger發表於2020-09-23

Laravel Exception結合自定義Log服務的使用

第一部分:laravel關於錯誤和異常的部分原始碼

第二部分:自定義異常的使用(結合serviceprovider monolog elasticsearch)

過程中涉及到的重要函式請自行檢視手冊

error_reporting set_error_handler set_exception_handler register_shutdown_function error_get_last

laravel v6.18.40

原始碼部分

我們來到http kernel檔案,處理請求部分

可以看到執行我們業務邏輯的sendRequestThroughRouter方法被try_catch包裹的嚴嚴實實

public function handle($request)
{
    try {
        $request->enableHttpMethodParameterOverride();

        $response = $this->sendRequestThroughRouter($request);
    } catch (Exception $e) {
        $this->reportException($e);

        $response = $this->renderException($request, $e);
    } catch (Throwable $e) {
        $this->reportException($e = new FatalThrowableError($e));

        $response = $this->renderException($request, $e);
    }

    $this->app['events']->dispatch(
        new RequestHandled($request, $response)
    );

    return $response;
}

捕獲到異常後 框架做了哪些工作呢? reportException 記錄了異常 renderException 響應了異常

/**
 * Report the exception to the exception handler.
 *
 * @param  \Exception  $e
 * @return void
 */
protected function reportException(Exception $e)
{	
    # 從容器中解析ExceptionHandler, 繫結位於bootstrap/app.php中
    # 執行的是App\Exceptions\Handler的report方法
    $this->app[ExceptionHandler::class]->report($e);
}

# 跳轉到App\Exceptions\Handler的report方法
public function report(Exception $exception)
{	
    # 繼續跳轉到父類的report方法
    parent::report($exception);
}

# 只看核心程式碼
Illuminate\Foundation\Exceptions\Handler::report()
/**
 * Report or log an exception.
 *
 * @param  \Exception  $e
 * @return void
 *
 * @throws \Exception
 */
public function report(Exception $e)
{
    if ($this->shouldntReport($e)) {
        return;
    }
	
    # 判斷傳遞進來的異常是否存在report方法,有就執行
    if (is_callable($reportCallable = [$e, 'report'])) {
        // 值得注意的是,此步驟通過容器呼叫 
        // 這意味著我們可以在自定義的異常類中肆無忌憚的向report方法中諸如依賴了!!!
        // 後面自定義異常類的使用會提及
        return $this->container->call($reportCallable);
    }
	
    # 從容器中解析log服務
    # Illuminate\Log\LogManager例項
    try {
        $logger = $this->container->make(LoggerInterface::class);
    } catch (Exception $ex) {
        throw $e;
    }
	
    # 記錄日誌 基於monolog 後面自定義日誌服務會講解monolog的使用
    $logger->error(
        $e->getMessage(),
        array_merge(
            $this->exceptionContext($e),
            $this->context(),
            ['exception' => $e]
        )
    );
}

# renderException方法請自行檢視

通過上面的程式碼,我們自然而然的認為這樣就可以捕獲應用中產生的所有異常了,其實不然

下面我們來看框架引導階段為處理錯誤和異常做的工作

邏輯同樣位於框架的boot階段

下面給出簡要介紹

Illuminate\Foundation\Bootstrap\HandleExceptions::bootstrap()
public function bootstrap(Application $app)
{
    self::$reservedMemory = str_repeat('x', 10240);

    $this->app = $app;

    // 儘可能顯示所有錯誤, 甚至包括將來 PHP 可能加入的新的錯誤級別和常量
    // -1 和 E_ALL | E_STRICT 的區別為是否向後相容
    error_reporting(-1);

    // 設定執行邏輯部分出現錯誤的回撥
    // 預設錯誤級別為 E_ALL | E_STRICT
    // 網上說此函式只有warning和notice級別的錯誤能夠觸發的說法不夠準確
    // 個人拙見:可以觸發回撥的錯誤級別為執行時產生的錯誤
    // 直接中斷指令碼執行的錯誤不能觸發此回撥 因為回撥還未註冊
    // 為了更大範圍的抓取錯誤,需要配合register_shutdown_function 和 error_get_last 處理
    set_error_handler([$this, 'handleError']);

    // 設定捕獲執行邏輯部分未捕獲的異常回撥
    set_exception_handler([$this, 'handleException']);

    // 設定php指令碼結束前最後執行的回撥
    register_shutdown_function([$this, 'handleShutdown']);

    if (! $app->environment('testing')) {
        ini_set('display_errors', 'Off');
    }
}

# 將php錯誤轉化為異常丟擲
/**
 * Convert PHP errors to ErrorException instances.
 * @throws \ErrorException
 */
public function handleError($level, $message, $file = '', $line = 0, $context = [])
{   
    if (error_reporting() & $level) {
        throw new ErrorException($message, 0, $level, $file, $line);
    }
}

# 註釋已經非常清晰 致命錯誤異常不能按照普通異常處理 在此處直接記錄和返回響應
/**
 * Handle an uncaught exception from the application.
 *
 * Note: Most exceptions can be handled via the try / catch block in
 * the HTTP and Console kernels. But, fatal error exceptions must
 * be handled differently since they are not normal exceptions.
 *
 * @param  \Throwable  $e
 * @return void
 */
public function handleException($e)
{   
    if (! $e instanceof Exception) {
        $e = new FatalThrowableError($e);
    }

    try {
        self::$reservedMemory = null;

        $this->getExceptionHandler()->report($e);
    } catch (Exception $e) {
        //
    }

    if ($this->app->runningInConsole()) {
        $this->renderForConsole($e);
    } else {
        $this->renderHttpResponse($e);
    }
}

/**
 * Handle the PHP shutdown event.
 *
 * @return void
 */
public function handleShutdown()
{   
    // 生成的異常類是symfony封裝的異常類
    // 例:可以在任意路由中來上一句不加分號的程式碼 看看測試效果
    if (! is_null($error = error_get_last()) && $this->isFatal($error['type'])) {
        $this->handleException($this->fatalExceptionFromError($error, 0));
    }
}
使用部分

自定義異常的使用

方式一:直接丟擲一個異常 在相應方法中判斷 進行自定義的處理

1 建立一個自定義異常類
php artisan make:exception CustomException
    
2 在業務邏輯中丟擲異常
Route::get('ex', function () {
    throw new CustomException('thrown for caught');
});

3 擴充套件App\Exceptions\Handler類
public function report(Exception $exception)
{
    if ($exception instanceof CustomException) {
        Log::channel('daily')->error($exception->getMessage(), ['type' => 'myEx']);
    }
    parent::report($exception);
}
# 當然你也可以擴充套件render方法

4 訪問路由
# 檢視logs下的檔案

以上方法顯然不夠優雅,當異常變多的時候,需要配合大量的instanceof判斷,並且可能會記錄兩份相同內容的日誌

所以還可以使用第二種方式進行自定義異常的使用,利用框架自動呼叫report和render方法的特性,實現記錄和渲染異常響應

方式二:自定義一個exception 然後讓框架自動呼叫report方法 不進行render

1 建立自定義並編輯自定義異常
php artisan make:exception MyException
class MyException extends Exception
{
    public function report()
    {
        Log::channel('daily')->error($this->getMessage(), array_merge($this->exceptionContext(), $this->context()));
    }
    
    // 其實是不必要的 這兩個方法可以在Handler中進行重寫,請自行檢視Handler的父類,根據需要進行擴充套件重寫
    public function exceptionContext()
    {   
        // return [$this->getTraceAsString()];
        return [];
    }

    // 其實是不必要的 這兩個方法可以在Handler中進行重寫
    public function context()
    {
        // return ['type' => 'myEx'];
        return ['exception' => $this];
    }

    public function render()
    {
        return 'whatever you like';
    }
}

2 丟擲異常
Route::get('myex', function () {
    throw new MyException('thrown for caught');
});

3 執行並檢視日誌檔案 是不是發現和laravel原生的異常記錄長得很像呢

方式三:使用自定義的日誌服務記錄異常

上面提到異常例項的report是通過容器呼叫的,這意味著我們可以注入我們自定義的日誌服務

這裡使用神器monolog,因為laravel的日誌系統基於monolog,框架已經包含了此庫。如果使用其他框架請先確保安裝monolog

這裡使用elasticsearch作為日誌handler之一,所以請確保安裝了composer require elasticsearch/elasticsearch

使用monolog作為自定義日誌服務實現的原因是因為monolog本身具有替換性和通用性,其他框架稍加改動也可以使用

1 建立契約
<?php

namespace App\Contracts;

interface ExceptionLog
{      
    // 記錄異常
    public function recordEx(\Exception $e);
}

2 建立日誌服務 並簡單介紹monolog使用
# 關於monolog的更多使用方法請檢視官方文件 https://github.com/Seldaek/monolog
<?php

namespace App\Services\Logs;

use App\Contracts\ExceptionLog;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\RotatingFileHandler;
use Monolog\Handler\ElasticsearchHandler;
use Elasticsearch\ClientBuilder;

class MonoException implements ExceptionLog
{
    protected $logger;

    public function __construct()
    {
        // 建立本地檔案儲存handlers
        $streamHandler = new StreamHandler(storage_path('logs/exception.log'), Logger::DEBUG);

        // 建立本地日期切分儲存handler
        $rotateHandler = new RotatingFileHandler(storage_path('logs/exception.log'), Logger::DEBUG);

        // 建立es 客戶端 為了減小難度 就不在這裡注入elasticsearch客戶端了 其實是我懶 開心擼碼最重要
        $esClient = ClientBuilder::create()->setHosts(config('es.hosts'))->build();
        // es配置
        $options = [
            'index' => 'my_exception_index',
            'type'  => 'test_exception',
        ];

        // 建立遠端elasticsearch日誌儲存
        $esHandler = new ElasticsearchHandler($esClient, $options);

        // 這裡沒有阻止handlers的堆疊冒泡,一條日誌會逐個經過es、rotate、stream日誌處理器
        // 更多的日誌儲存handler請檢視文件(為了效能考量,monolog甚至為你提供了非同步方式記錄日誌)

        // 建立logger 雖然叫logger但是他並沒有記錄日誌的能力
        // 真正提供記錄日誌能力的是提前建立好的handlers
        // monolog提供非常多開箱即用的handler 請檢視文件
        // 並沒有設定processor等等 更多api請檢視官方文件
        $logger = new Logger('exception', compact('streamHandler', 'rotateHandler', 'esHandler'));
        $this->logger = $logger;
    }

    public function recordEx(\Exception $e)
    {
        $this->logger->error($e->getMessage());
    }
}

3 建立服務提供者 因為我們建立的服務在異常中呼叫 所以使用單例和延遲繫結更加合適
php artisan make:provider LogServiceProvider
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Contracts\ExceptionLog;
use App\Services\Logs\MonoException;
use Illuminate\Contracts\Support\DeferrableProvider;

class LogServiceProvider extends ServiceProvider implements DeferrableProvider
{
    /**
     * Register services.
     *
     * @return void
     */
    public function register()
    {
        $this->app->singleton(ExceptionLog::class, function () { 
            return new MonoException();
        });
    }

    /**
     * Bootstrap services.
     *
     * @return void
     */
    public function boot()
    {
        //
    }

    public function provides()
    {
        return [ExceptionLog::class];
    }
}

4 註冊服務 config/app.php
...
App\Providers\AuthServiceProvider::class,
// App\Providers\BroadcastServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\RouteServiceProvider::class,
// 註冊自定義日誌服務
App\Providers\LogServiceProvider::class,


4 建立自定義異常 並在其中使用自定義的日誌服務
<?php

namespace App\Exceptions;

use Exception;
use App\Contracts\ExceptionLog;

class CustomException extends Exception
{	
    // 注入我們的日誌服務
    public function report(ExceptionLog $logger)
    { 
        $logger->recordEx($this);
    }

    public function render()
    {
        return 'whatever you like';
    }
}

5 測試異常的使用
Route::get('cex', function () { 
    throw new CustomException('thrown for caught!!!');
});

6 檢查es和storage/logs下是否存在我們的日誌吧
# 我們實現的日誌服務可以在應用的任何地方使用,這裡只是使用在了異常記錄的地方,希望大家能夠理解

方式四:有的道友可能吐槽,laravel好好的日誌服務不香嗎?折騰啥啊?

好的,那就通過laravel自帶的日誌服務實現和上面同樣的功能

config/logging.php
<?php

use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Handler\ElasticsearchHandler;
use Elasticsearch\ClientBuilder;
use Monolog\Formatter\ElasticsearchFormatter;

...
'stack' => [
    'driver' => 'stack',
    // 'channels' => ['single'],
    'channels' => ['daily', 'es'],
    'ignore_exceptions' => false,
],

// 自定義es日誌處理器
// 一定要設定es的formatter!!!
'es' => [
    'driver' => 'monolog',
    'level' => 'debug',
    'handler' => ElasticsearchHandler::class,
    'formatter' => ElasticsearchFormatter::class,
    'formatter_with' => [
        'index' => 'lara_exception',
        'type' => 'lara_exception'
    ],
    'handler_with' => [
        'client' => ClientBuilder::create()->setHosts(config('es.hosts'))->build()
    ]
],

# 這樣就新增了es作為日誌記錄的驅動了
# 測試一下吧 比如訪問這個路由 檢視你的本地檔案和es吧
Route::get('cex', function () {
    // laravel會將其轉換成一個symfony的致命異常
    aaa
});

其他方式:想要維持一個高效能的、功能強大的日誌服務的話,可以考慮新增一個非同步的日誌handler,其實monolog也已經提供了開箱即用的handler

更多用法請檢視laravel元件的日誌部分文件,感興趣的道友可以自行檢視laravel log和monolog的原始碼

今天沒有下集預告,發現錯誤歡迎指正,感謝!!!

相關文章