核心讀寫函數
<?php
/**
* 將陣列資料寫入 Tab 分隔檔案
* @param string $filepath 檔案路徑
* @param array $data 二維陣列或關聯陣列
* @param bool $saveKeys 是否寫入鍵名作為第一欄
* @return bool 成功與否
*/
function writeTabbedFile($filepath, array $data, $saveKeys = false) {
$content = '';
foreach ($data as $key => $row) {
// 清理欄位中的 Tab 與換行
$cleanKey = str_replace(["\t", "\n", "\r"], ' ', $key);
$cleanRow = array_map(fn($v) => str_replace(["\t", "\n", "\r"], ' ', $v), $row);
if ($saveKeys) {
$content .= $cleanKey . "\t";
}
$content .= is_array($cleanRow) ? implode("\t", $cleanRow) : $cleanRow;
$content .= "\n";
}
// 原子寫入:先寫暫存檔再重命名
$tempFile = $filepath . '.tmp';
if (file_put_contents($tempFile, $content) === false) {
return false;
}
return rename($tempFile, $filepath);
}
/**
* 讀取 Tab 分隔檔案為陣列
* @param string $filepath 檔案路徑
* @param bool $loadKeys 第一欄是否為鍵名
* @return array
*/
function loadTabbedFile($filepath, $loadKeys = false) {
$data = [];
if (!file_exists($filepath) || !is_readable($filepath)) {
return $data;
}
$handle = fopen($filepath, 'r');
if (!$handle) return $data;
while (($line = fgets($handle)) !== false) {
$line = rtrim($line, "\n\r");
if ($line === '') continue;
$fields = explode("\t", $line);
if ($loadKeys) {
$key = array_shift($fields);
$data[$key] = $fields;
} else {
$data[] = $fields;
}
}
fclose($handle);
return $data;
}
?>
使用範例
<?php
// 測試資料
$records = [
'user_001' => ['張三', '25', 'engineer', '台北'],
'user_002' => ['李四', '30', 'designer', '台中'],
'user_003' => ['王五', '28', 'manager', '高雄'],
'config' => ['site_name' => 'MySite', 'debug' => 'false'], // 扁平資料也支援
];
// 寫入
writeTabbedFile('data.tsv', $records, true);
/* data.tsv 內容:
user_001 張三 25 engineer 台北
user_002 李四 30 designer 台中
user_003 王五 28 manager 高雄
config site_name debug
*/
// 讀取
$loaded = loadTabbedFile('data.tsv', true);
print_r($loaded);
/* 輸出:
Array
(
[user_001] => Array ( [0] => 張三 [1] => 25 [2] => engineer [3] => 台北 )
[user_002] => Array ( [0] => 李四 [1] => 30 [2] => designer [3] => 台中 )
[user_003] => Array ( [0] => 王五 [1] => 28 [2] => manager [3] => 高雄 )
[config] => Array ( [0] => site_name [1] => debug )
)
*/
?>
進階版:支援標題列、型別轉換、大檔案串流
<?php
class TabbedFileHandler {
private $filepath;
private $delimiter = "\t";
private $enclosure = ''; // TSV 通常不包圍字符
private $hasHeader = false;
private $header = [];
public function __construct($filepath, array $options = []) {
$this->filepath = $filepath;
$this->delimiter = $options['delimiter'] ?? "\t";
$this->enclosure = $options['enclosure'] ?? '';
$this->hasHeader = $options['has_header'] ?? false;
}
/** 串流讀取(記憶體友善) */
public function readStream(callable $callback) {
$handle = fopen($this->filepath, 'r');
if (!$handle) throw new RuntimeException("無法開啟檔案:{$this->filepath}");
$rowNum = 0;
while (($row = fgetcsv($handle, 0, $this->delimiter, $this->enclosure)) !== false) {
if ($this->hasHeader && $rowNum === 0) {
$this->header = $row;
} else {
$data = $this->hasHeader ? array_combine($this->header, $row) : $row;
$callback($data, $rowNum);
}
$rowNum++;
}
fclose($handle);
}
/** 串流寫入 */
public function writeStream(iterable $dataRows, array $header = []) {
$tempFile = $this->filepath . '.tmp';
$handle = fopen($tempFile, 'w');
if (!$handle) throw new RuntimeException("無法建立暫存檔");
if ($header) {
fputcsv($handle, $header, $this->delimiter, $this->enclosure);
}
foreach ($dataRows as $row) {
fputcsv($handle, $row, $this->delimiter, $this->enclosure);
}
fclose($handle);
rename($tempFile, $this->filepath);
}
/** 型別轉換器 */
public function withTypeCasting(array $castMap) {
return new class($this, $castMap) extends TabbedFileHandler {
private $castMap;
public function __construct($parent, $castMap) {
parent::__construct($parent->filepath);
$this->castMap = $castMap;
}
public function readStream(callable $callback) {
parent::readStream(function($row, $num) use ($callback) {
foreach ($this->castMap as $col => $type) {
if (isset($row[$col])) {
$row[$col] = $this->castValue($row[$col], $type);
}
}
$callback($row, $num);
});
}
private function castValue($value, $type) {
switch ($type) {
case 'int': return (int)$value;
case 'float': return (float)$value;
case 'bool': return filter_var($value, FILTER_VALIDATE_BOOLEAN);
case 'json': return json_decode($value, true);
default: return $value;
}
}
};
}
}
// 使用:處理大檔案,只讀取需要的欄位並轉型
$handler = new TabbedFileHandler('big_data.tsv', ['has_header' => true]);
$handler->withTypeCasting(['age' => 'int', 'salary' => 'float', 'active' => 'bool'])
->readStream(function($row) {
if ($row['age'] > 30 && $row['active']) {
echo "高薪資深員工:{$row['name']} - {$row['salary']}\n";
}
});
?>
TSV vs CSV 選擇指南
| 特性 | TSV (Tab) | CSV (Comma) |
| 欄位含逗號 | 無需轉義 | 需雙引號包圍 |
| 欄位含 Tab | 需轉義/替換 | 無問題 |
| 人類可讀性 | 對齊較好 | 標準試算軟體支援佳 |
| 資料庫匯入/匯出 | MySQL LOAD DATA 預設 Tab | Excel/Google Sheets 原生 |
| 建議場景 | 程式間交換、日誌、大量數據 | 人工編輯、報表匯出 |