CSV 看似簡單,實際踩坑無數:BOM、編碼、換行符、分隔符衝突、大文件 OOM。本類解決所有痛點。
<?php
declare(strict_types=1);
class CsvHandler {
public function __construct(
private string $delimiter = ',',
private string $enclosure = '"',
private string $escape = '\\',
private string $inputEncoding = 'UTF-8',
private string $outputEncoding = 'GBK' // Excel 中文默認
) {}
/**
* 導出:資料陣列 -> CSV 文件下載
* @param iterable $rows 可迭代對象(陣列/Generator),每行為關聯陣列或索引陣列
* @param array $headers 欄位標題(鍵名=>顯示名),若為空則用首行鍵
* @param string $filename 下載文件名
*/
public function export(iterable $rows, array $headers = [], string $filename = 'export.csv'): void {
header('Content-Type: text/csv; charset=' . $this->outputEncoding);
header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"');
header('Pragma: no-cache');
header('Expires: 0');
$out = fopen('php://output', 'w');
// 寫入 BOM (UTF-8) 或直接輸出 GBK
if ($this->outputEncoding === 'UTF-8') {
fwrite($out, "\xEF\xBB\xBF");
}
$first = true;
foreach ($rows as $row) {
if ($first) {
if ($headers) {
fputcsv($out, array_values($headers), $this->delimiter, $this->enclosure, $this->escape);
} elseif (is_array($row) && array_is_list($row) === false) {
fputcsv($out, array_keys($row), $this->delimiter, $this->enclosure, $this->escape);
}
$first = false;
}
$line = is_array($row) && array_is_list($row) === false ? array_values($row) : $row;
// 編碼轉換
if ($this->inputEncoding !== $this->outputEncoding) {
$line = array_map(
fn($v) => is_scalar($v) ? iconv($this->inputEncoding, $this->outputEncoding . '//IGNORE', (string)$v) : $v,
$line
);
}
fputcsv($out, $line, $this->delimiter, $this->enclosure, $this->escape);
}
fclose($out);
exit;
}
/**
* 導入:CSV 文件 -> Generator 逐行產出關聯陣列
* @param string $filePath
* @param array $options ['headers'=>bool, 'map'=>callable, 'limit'=>int]
* @return Generator
*/
public function import(string $filePath, array $options = []): Generator {
$headers = $options['headers'] ?? true;
$map = $options['map'] ?? null;
$limit = $options['limit'] ?? PHP_INT_MAX;
$fp = fopen($filePath, 'r');
if (!$fp) throw new RuntimeException("Cannot open $filePath");
$headerRow = null;
$count = 0;
while (($row = fgetcsv($fp, 0, $this->delimiter, $this->enclosure, $this->escape)) !== false) {
if ($count >= $limit) break;
if ($headerRow === null) {
if ($headers) {
$headerRow = $row;
$count++;
continue;
} else {
$headerRow = array_keys($row);
}
}
$data = array_combine($headerRow, $row);
// 編碼轉換
if ($this->inputEncoding !== 'UTF-8') {
$data = array_map(
fn($v) => is_scalar($v) ? iconv($this->inputEncoding, 'UTF-8//IGNORE', (string)$v) : $v,
$data
);
}
// 自定義映射(如日期解析、類型轉換)
if ($map) $data = $map($data);
yield $data;
$count++;
}
fclose($fp);
}
}
// ===== 使用示例 =====
$csv = new CsvHandler();
// 1. 導出下載
$users = [
['id'=>1, 'name'=>'張三', 'email'=>'zhang@example.com', 'created_at'=>'2024-01-15'],
['id'=>2, 'name'=>'李四', 'email'=>'li@example.com', 'created_at'=>'2024-02-20'],
];
$csv->export($users, ['id'=>'ID', 'name'=>'姓名', 'email'=>'郵箱', 'created_at'=>'註冊日期'], 'users.csv');
// 2. 導入處理(大文件逐行,不佔記憶體)
foreach ($csv->import('users.csv', [
'map' => fn($row) => [
'id' => (int)$row['ID'],
'name' => $row['姓名'],
'email' => $row['郵箱'],
'created_at' => DateTime::createFromFormat('Y-m-d', $row['註冊日期']),
]
]) as $user) {
// User::create($user); // 入庫
echo "Imported: {$user['name']}\n";
}
| 問題 | 解決方案 |
|---|---|
| Excel 打開 CSV 亂碼 | 輸出 GBK + BOM,或 UTF-8 with BOM |
| 欄位包含逗號/換行/引號 | fputcsv 自動加雙引號並轉義內部引號 |
| 大文件導入 OOM | Generator + fgetcsv 逐行,峰值記憶體 < 2MB |
| 科學計數法/前導零丟失 | 導入時視為字符串,業務層自行轉型 |
| 跨平台換行符 | fgetcsv 自動識別 \r\n/\n/\r |