Dcat Admin 使用 Laravel Octane 時匯出功能無法使用的原因及修復方法

微波爐發表於2022-07-13

在使用 Dcat admin 框架的時候,發現匯出功能使用 Octane 時會出現直接列印檔案內容的情況,並報 swoole exit 異常,檢視原始碼後才知道 Dcat admin 原生的匯出類 的export() 方法是強制傳送了響應,返回響應後是由 Octane 再次轉發到 Swoole/RoadRunner 伺服器的(而不是返回一個響應物件由 Octane 捕獲然後轉發),所以 Octane 在讀取響應內容的時候會直接讀到檔案內容,並丟失了響應頭,故直接進行列印了。另外原生的 export() 方法裡面也使用了 exit,也導致了 swoole exit 的異常。

Octane 請求處理響應部分原始碼:
Laravel\Octane\Worker

...
ob_start();

$response = $gateway->handle($request);

$output = ob_get_contents();

ob_end_clean();

// Here we will actually hand the incoming request to the Laravel application so
// it can generate a response. We'll send this response back to the client so
// it can be returned to a browser. This gateway will also dispatch events.
$this->client->respond(
  $context,
  $octaneResponse = new OctaneResponse($response, $output),
);
...

Dcat Admin 原生 Easy Excel 匯出原始碼
Dcat\Admin\Grid\Exporters\ExcelExporter

public function export()
{
    $filename = $this->getFilename().'.'.$this->extension;

    $exporter = Excel::export();

    if ($this->scope === Grid\Exporter::SCOPE_ALL) {
        $exporter->chunk(function (int $times) {
            return $this->buildData($times);
        });
    } else {
        $exporter->data($this->buildData() ?: [[]]);
    }
    // 此處直接傳送了響應
    return $exporter->headings($this->titles())->download($filename);
}

在不修改原始碼的前提下,可以使用異常丟擲與捕獲的方法解決

新建一個 Exporter 類繼承 \Dcat\Admin\Grid\Exporters\AbstractExporter,重寫 export() 方法,在 export() 方法中丟擲一個 ExporterException 異常,然後由 App\Exceptions\Handler 捕獲並返回下載響應即可。

因為使用了 Xlswriter ,所以我這裡是跳轉到了下載檔案的地址,也可以使用流式傳輸來下載 EasyExcel 的匯出。

App\Exception\Handler

public function render($request, Throwable $e)
{
    if ($e instanceof ExporterException) {
        return response()->redirectTo(admin_route('export', [$e->getMessage()]));
    }
    return parent::render($request, $e);
}

本以為已經解決了該問題,但是當進行生產環境的測試時,發現異常直接由 Dcat Admin 捕獲了,導致沒有執行我們定義在 Handler 中的方法。
通過檢視原始碼發現,當 .env 檔案中 APP_DEBUG 設定為 false 的時候,Dcat Admin 就會捕獲異常,並自己返回。響應

Dcat Admin 使用 Laravel Octane 時匯出功能無法使用的原因及修復方法

Dcat\Exception\Handler

    /**
     * 顯示異常資訊.
     *
     * @param  \Throwable  $exception
     * @return array|string|void
     *
     * @throws \Throwable
     */
    public function render(\Throwable $exception)
    {
        if (config('app.debug')) {
            throw $exception;
        }

        if (Helper::isAjaxRequest()) {
            return;
        }

        $error = new MessageBag([
            'type'    => get_class($exception),
            'message' => $exception->getMessage(),
            'file'    => $exception->getFile(),
            'line'    => $exception->getLine(),
            'trace'   => $this->replaceBasePath($exception->getTraceAsString()),
        ]);

        $errors = new ViewErrorBag();
        $errors->put('exception', $error);

        return view('admin::partials.exception', compact('errors'))->render();
    }

幸好 Dcat Admin 提供了異常處理類的配置,我們只需要繼承該類重寫 render() 邏輯,然後再在 config/admin.php 檔案中修改異常捕獲類就可以了。

config/admin.php

/*
|--------------------------------------------------------------------------
| The exception handler class
|--------------------------------------------------------------------------
|
*/
'exception_handler' => \App\Admin\Exceptions\Handler::class,

App\Admin\Exception\Handler

<?php

namespace App\Admin\Exceptions;

class Handler extends \Dcat\Admin\Exception\Handler
{
    public function render(\Throwable $exception)
    {
        if ($exception instanceof ExporterException) {
            throw $exception;
        }
        return parent::render($exception);
    }
}

我們不必在 App\Exceptions\Handler 中用 if 判斷異常,這使得我們的程式碼過於分散。其實 Laravel 可以在捕獲異常時呼叫異常的 render 方法,於是我們最終的 ExportException 程式碼如下:

<?php

namespace App\Admin\Exceptions;

use Throwable;
use function admin_route;
use function response;

/**
 * 此異常類用於跳過 Dcat Admin 框架的響應機制,直接返回下載響應
 * 在丟擲該異常時,我們編寫的 Handler 類會直接將此異常拋由 Laravel 處理
 * @see Handler
 * 然後 Laravel 會自動呼叫異常類的 render 方法來返回一個 HTTP 響應
 */
class ExporterException extends \Exception
{

    public string $name;

    public string $filename;


    public function __construct(string $filename = "", string $name = "", int $code = 0, ?Throwable $previous = null)
    {
        parent::__construct($filename, $code, $previous);
        $this->filename = $filename;
        $this->name = $name;
    }

    /**
     * @return string
     */
    public function getFilename(): string
    {
        return $this->filename;
    }

    /**
     * @param string $filename
     */
    public function setFilename(string $filename): void
    {
        $this->filename = $filename;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }

    /**
     * @param string $name
     */
    public function setName(string $name): void
    {
        $this->name = $name;
    }


    public function render($request)
    {
        return response()->redirectTo(admin_route('export', [$this->getFilename(), $this->getName()]));
    }
}

如果幫到了你,不妨點個贊給我一點反饋

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章