博客 / 詳情

返回

基於Generator生成器的分離式導出CSV

引言

最近在工作中需要實現一個數據導出功能。由於之前都是使用現成的工具或庫,換了一家公司後,發現需要從零開始構建這個功能。最初我計劃實現一個異步導出功能,但上級認為過於複雜,建議採用同步方式。於是,我開始尋找一種高效的同步導出方案。

在這個過程中,我發現了PHP中的生成器(Generator),這是一個非常強大的工具,特別適合處理大數據場景。本文將詳細介紹生成器的概念、工作原理、優勢以及如何利用生成器實現高效的CSV導出功能。

一、什麼是生成器?

生成器(Generator)是PHP中一個非常重要的概念。它就像一個“節能引擎”,特別適合處理大數據場景。生成器允許你按需生成數據,而不是一次性將所有數據加載到內存中。

傳統數組 vs 生成器對比

// 傳統方式:一次性加載全部數據
function getAllUsers() {
  $users = [/* 10萬條數據 */]; // 瞬間佔用500MB內存
  return $users; 
}

// 生成器:逐條生成數據
function generateUsers() {
  for ($i = 0; $i < 100000; $i++) {
    yield ['id' => $i, 'name' => "User$i"]; //每次只佔 1KB
  }
}

在上面的例子中,傳統方式會一次性加載10萬條數據,佔用大量內存。而生成器則逐條生成數據,內存佔用極低。

二、生成器的工作原理

yield 關鍵字——時空穿梭的魔法

每次執行到 yield 時,函數會暫停並保存當前狀態。下次請求數據時,從暫停的位置繼續執行。

function simpleGenerator() {
  echo "開始執行\n";
  yield '第一塊數據';
  echo "繼續執行\n";
  yield '第二塊數據';
}

$gen = simpleGenerator();
echo $gen->current(); // 輸出:開始執行 | 第一塊數據
$gen->next();
echo $gen->current(); // 輸出:繼續執行 | 第二塊數據

生成器執行流程圖解

0ecc8cb6a8c475de2e3504ea649bb16.png

三、生成器的核心優勢

1. 惰性計算(Lazy Evaluation)

生成器按需生成數據,避免提前計算。例如,讀取10GB的日誌文件時,傳統方式可能會導致內存溢出,而生成器可以逐行獲取數據。

2. 內存友好

處理10萬條數據時,傳統數組方式的內存峯值可能達到500MB+,而生成器僅佔用2MB左右。

3. 可組合性

生成器可以鏈式調用,形成數據處理流水線。

function filter($generator) {
  foreach ($generator as $item) {
    if ($item['age'] > 18) {
      yield $item;
    }  
  }
}

$data = filter(getUserGenerator());

四、生成器使用注意事項

1. 不可逆性

生成器只能向前遍歷,不能 rewind() 重置。

2. 資源釋放

使用完成後及時關閉生成器。

$gen->close(); // 釋放數據庫連接等資源

3. 與數組的轉換

需要時可通過 iterator_to_array() 轉換,但會失去內存優勢。

4. 性能陷阱

避免在生成器內部進行復雜計算,保持輕量。

五、面試常見問題

1. yieldreturn 有什麼區別?

  • return 終結函數執行。
  • yield 暫停函數,保留上下文。

2. 生成器如何實現低內存佔用?

通過維持執行狀態(棧幀),避免一次性加載所有數據。

3. 什麼時候不該用生成器?

  • 需要隨機訪問數據時。
  • 需要多次遍歷數據集時。
  • 數據量較小時(反而增加複雜度)。

六、案例:基於生成器的CSV導出功能

由於在很多地方都需要導出數據,因此我將導出功能封裝成一個公共函數,代碼如下:

/**
 * @param Generator $dataGenerator 數據生成器
 * @param array $headers 表頭
 * @param string $filename 文件名
 * @return void
 * desc: 批量導出CSV文件
 * author: author
 * datetime: 2025/3/3下午3:46
 */
function batch_export_csv(Generator $dataGenerator, array $headers, string $filename = '')
{
    try {
        // 設置運行環境
        set_time_limit(300); // 設置合理的超時時間

        // 清理輸出緩衝區
        if (ob_get_level() > 0) {
            ob_start();
        }

        // 驗證和清理文件名
        $fileName = basename($filename ?: 'export_' . date('YmdHis')) . '.csv';
        $fileName = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $fileName);

        // 設置響應頭
        header('Content-Type: text/csv; charset=UTF-8');
        header('Content-Disposition: attachment; filename="' . $fileName . '"');
        header('Cache-Control: max-age=0');

        // 打開輸出流
        $fp = fopen('php://output', 'w');
        fwrite($fp, chr(0xEF) . chr(0xBB) . chr(0xBF)); // 輸出 UTF-8 頭
        fputcsv($fp, $headers);

        // 分批處理數據
        $batchCount = 0;
        foreach ($dataGenerator as $batch) {
            foreach ($batch as $row) {
                fputcsv($fp, $row);
            }
            $batchCount++;

            // 每處理一定數量的批次刷新一次緩衝區
            if ($batchCount % 10 === 0) {
                ob_flush(); // 刷新輸出緩衝
                flush();    // 刷新系統緩衝
            }
        }

        fclose($fp);
        exit();
    } catch (\Exception $e) {
        // 捕獲所有類型的異常並記錄日誌
        error_log('Export failed: ' . $e->getMessage());
        throw new \Exception('導出失敗: ' . $e->getMessage());
    }
}

接口調用

/**
 * 獲取SKU列表或導出SKU數據
 *
 * 該方法根據請求參數獲取SKU列表,並支持分頁和導出功能。
 * - 如果 `export` 參數為0,則返回分頁的SKU列表。
 * - 如果 `export` 參數為1,則導出SKU數據為CSV文件。
 *
 * @return \think\Response JSON響應或導出CSV文件
 */
public function skuList()
{
    // 輸入驗證與初始化參數
    $limit = intval($this->request->post('limit', 20)); // 每頁顯示條數,默認值為20
    $page = intval($this->request->post('page', 1)); // 頁數,默認值為1
    $export = intval($this->request->post('export', 0)); // 是否導出,默認值為0(不導出)

    // 驗證分頁參數是否合法
    if ($limit <= 0 || $page <= 0) {
        return json(['status' => 400, 'message' => 'Invalid limit or page']); // 返回錯誤信息,提示無效的分頁參數
    }

    // 驗證導出參數是否合法
    if (!in_array($export, [0, 1])) {
        return json(['status' => 400, 'message' => 'Invalid export parameter']); // 返回錯誤信息,提示無效的導出參數
    }

    // 構建查詢條件
    $where = [];
    $memberId = $this->request->post('membe_id', $this->info['membe']['membe_id'], 'intval'); // 獲取成員ID,並進行整數驗證
    if (empty($memberId)) {
        return json(['status' => 400, 'message' => 'Invalid member ID']); // 返回錯誤信息,提示無效的成員ID
    }
    $where['membe_id'] = $memberId;

    // 定義關聯查詢字段
    $withJoin = [
        'inventory' => ['product_sku_id', 'amount', 'shelves_amount'], // 關聯庫存表,選擇特定字段
    ];

    // 顯示字段列表
    $field = 'product_sku_id,company_id,membe_id,sku_No,product_title,product_describe,product_category,hs_code,univalent,weight,specifications,volume,create_time';
    
    // 構建查詢對象
    $query = ProductSku::where(formatWhere($where))
        ->field($field)
        ->withJoin($withJoin, 'left') // 左連接庫存表
        ->order('product_sku_id desc'); // 按產品SKU ID降序排列

    // 判斷是否需要導出數據
    if (!$export) {
       /*列表邏輯*/
    } else {
        ob_clean(); // 清除輸出緩衝區

        // 定義導出數據生成器函數
        $dataGenerator = function ($query, $chunkSize = 2000) {
            $page = 1;
            while (true) {
                try {
                    // 分頁查詢數據
                    $data = $query->page($page, $chunkSize)->select();
                    if ($data->isEmpty()) break; // 如果沒有數據則退出循環

                    // 構建導出數據行
                    $sku_data = [];
                    foreach ($data as $item) {
                        $row = $item->toArray();
                        $sku_data[] = [
                            $row['sku_No'] ?? '', // SKU編號
                            $row['sku_No'] ?? '', // 賣家SKU(重複列)
                            $row['product_title']['zh'] ?? '', // 產品名稱(中文)
                            $row['product_title']['en'] ?? '', // 產品名稱(英文)
                            $row['weight'] ?? '', // 重量
                            $row['volume']['L'] ?? 0, // 長度
                            $row['volume']['W'] ?? 0, // 寬度
                            $row['volume']['H'] ?? 0, // 高度
                            $row['univalent'] ?? 0, // 單價
                            '啓用', // 狀態
                            $row['create_time'] // 添加時間
                        ];
                    }
                    yield $sku_data;

                    // 判斷是否最後一頁
                    if (count($data) < $chunkSize) break;
                    $page++;
                } catch (\Exception $e) {
                    // 記錄日誌並繼續
                    error_log($e->getMessage());
                    continue;
                }
            }
        };

        // 定義CSV頭部
        $headers = ['Fnsku', 'seller_sku', '產品名稱', '產品英文名稱', '重量', '長', '寬', '高', '貨值', '狀態', '添加時間'];

        // 調用批量導出CSV函數
        batch_export_csv(
            $dataGenerator($query),
            $headers,
            'sku_list'
        );
    }
}

執行效果

image.png

點擊發送,在Postman上效果如上圖所示,或者點擊【發送並保存響應】,就能直接看到CSV文件了。

結語

通過本文的介紹,我們瞭解了生成器的基本概念、工作原理及其在大數據處理中的優勢。利用生成器,我們可以實現高效、低內存佔用的CSV導出功能,特別適合處理大數據場景。希望本文對你理解和應用生成器有所幫助。

user avatar xiaoxiaocong_58ab02b3e5d1e 頭像 philip-tellis 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.