大檔案的產生與下載
Published: 2023-09-01 03:03:46

---
title: 大檔案的產生與下載
published: true
description: 這些年在處理大型檔案的產生與下載時，經常面臨多種問題與挑戰，包括但不限於記憶體限制、長時間處理和用戶體驗問題。本文記錄自己如何使用 Laravel Queue 和 Livewire Polling 來解決這樣的狀況。
tags: 
# cover_image: https://direct_url_to_image.jpg
# Use a ratio of 100:42 for best results.
# published_at: 2023-09-01 03:00 +0000
---

這些年在處理大型檔案的產生與下載時，經常面臨多種問題與挑戰，包括但不限於記憶體限制、長時間處理和用戶體驗問題。本文記錄自己如何使用 Laravel Queue 和 Livewire Polling 來解決這樣的狀況。

---

這類的報表產生與下載功能，一開始想的很簡單：

1. 前端頁面上點擊下載連結
2. 後端收到請求後，開始從資料庫取資料
3. 將資料整理成特定格式後回傳

於是，建立一個 `Livewire Component`：

```php
class ExportStocks extends Component
{
    public $params;

    public function download()
    {
        return Excel::download(
            new StockSummaryExport($this->params),
           'stock-summary.xlsx'
        );
    }
}
```
並設定 [Livewire loading states](https://laravel-livewire.com/docs/2.x/loading-states)，告知使正用者正在執行中：

```html
<div>
    <button type="button" wire:click="download" wire:loading.attr="disabled">
        匯出查詢結果 <span wire:loading.delay wire:target="download"> ... 資料匯出中，請勿關閉或是重新整理畫面</span>
    </button>
</div>
```

於是，當收到產生報表的需求時，`StockSummaryExport` 會負責整理資料的工作，並在完成後轉換成 `Excel` 格式，並回傳下載給使用者。當資料量小時，這樣的操作沒有什麼問題。但是當資料量多時，就會讓處理的流程一直停留在等待 `StockSummaryExport` 的執行結果，才能夠轉換成 `Excel` 下載，於是就很容易收到這樣的回報：

1. 網頁跑很久後，出現一片黑
2. 網頁跑很久，還沒有出現下載的檔案，而且整個系統變得超慢的
3. 網頁跑很久後，出現 `Internal server error` 的英文字

這些狀況，其實就是發生了以下的問題：

1. Maximum execution time of 30 seconds exceeded
2. 504 Gateway Time-out
3. Allowed memory size of xxxxxx bytes exhausted (tried to allocate yyyyyy bytes)

### # 想法

參考了 [Mohamed Said](https://twitter.com/themsaid) 所寫的 [Laravel Queues in Action](https://learn-laravel-queues.com) 後，想試試透過 [Laravel Queue](https://laravel.com/docs/master/queues) 來改善：

1. 把工作交給 `Queue` 處理，就可以立刻回應頁面的操作，不會讓頁面一直停留在載入的狀態
2. 透過 `Queue` 操作，可以把產生報表的工作交給另一台機器來處理
3. 等到 `Queue` 完成工作後，再通知前端頁面，將檔案回傳，或是自動下載
4. 也可以讓檔案產生後，產生對應的連結，並在特定的頁面顯示，讓使用者可以去下載，不用一直在原頁面等待完成。

### # Laravel Queue

透過 `Queue` 改寫 `download` 方法：

```php
public function download()
{
    ProcessDownloadRequestJob::dispatch(
        processor: new StockSummaryExport($this->params),
        filename: 'stocks.xlsx'
    );

    $this->dispatchBrowserEvent('download-processing-event');
}
```

當使用者點擊下載連結後，將產生報表的工作透過 `ProcessDownloadRequestJob` 發送到 `Queue` 裡，接著發出 `Event` 告知前端頁面正在處理這個請求，並顯示適當的提示文字告訴使用者：

```html
<div x-data="{ show: false }">
    <button type="button" wire:click="download" wire:loading.attr="disabled">匯出查詢結果</button>
    <div x-on:download-processing-event.window="show=true">資料匯出中，請勿關閉或是重新整理畫面 ...</div>
</div>
```

因為已經將實際產生報表的 `StockSummaryExport` 傳入 `Job`，因此就可以在 `Job` 裡執行它：

```php
class ProcessDownloadRequestJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public Processor $stockSummaryExport,
        public string $filename,
    ) {}

    public function handle(): void
    {
        Excel::store($this->stockSummaryExport, $this->filename, 's3');
    }
}
```
當 `stockSummaryExport` 完成工作後，就會透過 `Excel::store` 儲存在 `S3` 上。如果產生報表資料的時間如果太長，或是需要更多的記憶體來執行，都不會影嚮到前端頁面的等待與執行。

### # Livewire Polling

最後，透過使用 [Livewire Polling](https://laravel-livewire.com/docs/2.x/polling) 機制，設定前端頁面固定時間向 `Server` 詢問是否已經完成報表的準備，如果已經完成，那麼就自動下載給使用者：

```php
// Laravel component
public function checkFile()
{
    if (Storage::disk('s3')->exists($this->file) && Storage::disk('s3')->size($this->file) > 0) {
        $this->dispatchBrowserEvent('download-completed-event');
        return Storage::disk('s3')->download($this->file);
    }
}

// Blade
<button type="button" wire:click="download" wire:loading.attr="disabled"> 匯出查詢結果
    <div wire:poll.visible.keep-alive="checkFile" x-show="show"
           x-on:download-processing-event.window="show=true"
           x-on:download-completed-event.window="show=false" class="ml-5">資料匯出中，請勿關閉或是重新整理畫面 ...</div>
</button>
```