現代化 ZipBuilder 類別
<?php
declare(strict_types=1);
class ZipBuilder
{
private ZipArchive $zip;
private string $tempPath;
private array $addedFiles = [];
public function __construct(private string $outputPath)
{
$this->tempPath = $outputPath . '.tmp.' . bin2hex(random_bytes(8));
$this->zip = new ZipArchive();
if ($this->zip->open($this->tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException("無法建立暫存壓縮檔: {$this->tempPath}");
}
}
/**
* 加入單一檔案
* @param string $localPath 伺服器本地路徑
* @param string $zipPath 壓縮包內路徑(相對路徑)
*/
public function addFile(string $localPath, string $zipPath): self
{
if (!is_file($localPath) || !is_readable($localPath)) {
throw new InvalidArgumentException("檔案不存在或不可讀: $localPath");
}
$this->zip->addFile($localPath, $zipPath);
$this->addedFiles[] = $zipPath;
return $this;
}
/**
* 遞歸加入整個資料夾
*/
public function addDirectory(string $localDir, string $zipPrefix = ''): self
{
$localDir = rtrim($localDir, '/\\') . DIRECTORY_SEPARATOR;
$prefix = $zipPrefix ? rtrim($zipPrefix, '/') . '/' : '';
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($localDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($iterator as $item) {
$relativePath = $prefix . substr($item->getPathname(), strlen($localDir));
$relativePath = str_replace('\\', '/', $relativePath); // Zip 內統一用正斜線
if ($item->isDir()) {
$this->zip->addEmptyDir($relativePath);
} else {
$this->zip->addFile($item->getPathname(), $relativePath);
}
$this->addedFiles[] = $relativePath;
}
return $this;
}
/**
* 從字串直接加入內容(無需暫存檔)
*/
public function addFromString(string $zipPath, string $content): self
{
$this->zip->addFromString($zipPath, $content);
$this->addedFiles[] = $zipPath;
return $this;
}
/**
* 完成並移動到最終路徑(原子操作)
*/
public function save(): string
{
if ($this->zip->close() !== true) {
@unlink($this->tempPath);
throw new RuntimeException('壓縮檔關閉失敗');
}
// 原子性重命名(同檔案系統下)
if (!rename($this->tempPath, $this->outputPath)) {
// 跨裝置備援:複製後刪除
copy($this->tempPath, $this->outputPath);
unlink($this->tempPath);
}
return $this->outputPath;
}
public function getFileCount(): int
{
return count($this->addedFiles);
}
public function __destruct()
{
// 防止例外導致暫存檔殘留
if (isset($this->tempPath) && file_exists($this->tempPath)) {
@unlink($this->tempPath);
}
}
}
使用範例
<?php
try {
$builder = new ZipBuilder('/var/www/downloads/order_12345.zip');
// 加入單一檔案
$builder->addFile('/storage/invoices/inv_12345.pdf', 'invoice.pdf');
// 加入圖片資料夾
$builder->addDirectory('/storage/uploads/order_12345', 'attachments');
// 動態產生 CSV 內容
$csv = "ID,Name,Price\n1,Product A,100\n2,Product B,200";
$builder->addFromString('products.csv', $csv);
$finalPath = $builder->save();
echo "壓縮完成: $finalPath (共 {$builder->getFileCount()} 項)";
// 提供下載
// header('Content-Type: application/zip');
// header('Content-Disposition: attachment; filename="order_12345.zip"');
// readfile($finalPath);
} catch (Exception $e) {
error_log('Zip 建立失敗: ' . $e->getMessage());
http_response_code(500);
echo '伺服器錯誤';
}
大檔案/串流輸出(避免佔用磁碟空間)
function streamZip(array $files, string $filename = 'archive.zip'): void
{
// 需安裝 maennchen/zipstream-php (composer require maennchen/zipstream-php)
$zip = new \ZipStream\ZipStream($filename);
foreach ($files as $zipPath => $localPath) {
if (is_file($localPath)) {
$zip->addFileFromPath($zipPath, $localPath);
} elseif (is_string($localPath)) { // 視為字串內容
$zip->addFile($zipPath, $localPath);
}
}
$zip->finish();
// 直接輸出到瀏覽器,不落地
}
// 使用
streamZip([
'large_export.csv' => '/data/exports/huge.csv', // 會分塊讀取串流
'readme.txt' => '這是動態產生的內容',
], 'export.zip');
常見問題排查
- 中文檔名亂碼:ZipArchive 內部使用 UTF-8,確保 $zipPath 為 UTF-8 編碼;舊版 Windows 解壓軟體可能需 GBK,考慮輸出前轉碼或使用
iconv('UTF-8', 'CP950', $name)。
- 權限錯誤:執行 PHP 的使用者需對來源檔案有讀取權、對輸出目錄有寫入權。
- 記憶體不足:大量檔案改用
ZipStream 串流輸出,避免一次載入所有內容。