刪除非空目錄是 PHP 文件系統操作的常見需求。雖然 rmdir() 只能刪除空目錄,但通過遞歸遍歷可實現完整刪除。本文提供生產級實現,涵蓋錯誤處理、符號連結保護、權限檢查、大目錄效能優化等關鍵細節。
<?php
function deleteDirRecursive(string $dir): bool {
// 1. 規範化路徑,移除尾部斜槓
$dir = rtrim($dir, '/\\');
// 2. 檢查目錄是否存在且可讀
if (!is_dir($dir) || !is_readable($dir)) {
trigger_error("目錄不存在或不可讀: {$dir}", E_USER_WARNING);
return false;
}
// 3. 打開目錄句柄
$handle = @opendir($dir);
if (!$handle) {
trigger_error("無法打開目錄: {$dir}", E_USER_WARNING);
return false;
}
// 4. 遍歷條目
while (($entry = readdir($handle)) !== false) {
// 跳過 . 和 ..
if ($entry === '.' || $entry === '..') continue;
$path = "{$dir}/{$entry}";
// 5. 遞歸處理子目錄,直接刪除文件
if (is_dir($path)) {
if (!deleteDirRecursive($path)) {
closedir($handle);
return false;
}
} elseif (is_file($path) || is_link($path)) {
// 符號連結用 unlink 刪除連結本身,不跟隨刪除目標
if (!@unlink($path)) {
trigger_error("刪除文件失敗: {$path}", E_USER_WARNING);
closedir($handle);
return false;
}
}
}
closedir($handle);
// 6. 刪除當前空目錄
if (!@rmdir($dir)) {
trigger_error("刪除目錄失敗: {$dir}", E_USER_WARNING);
return false;
}
return true;
}
<?php
/**
* 企業級目錄清理器
* 特性:符號連結安全、權限自動修復、大目錄分批處理、詳細日誌、乾跑模式
*/
class DirectoryCleaner {
private int $deletedFiles = 0;
private int $deletedDirs = 0;
private int $failedItems = 0;
private array $errors = [];
private bool $dryRun = false;
private int $batchSize = 1000; // 大目錄分批處理
private ?callable $progressCallback = null;
public function __construct(array $options = []) {
$this->dryRun = $options['dry_run'] ?? false;
$this->batchSize = $options['batch_size'] ?? 1000;
$this->progressCallback = $options['progress'] ?? null;
}
public function delete(string $path): bool {
$this->resetCounters();
$path = $this->normalizePath($path);
if (!$this->validatePath($path)) return false;
$result = $this->deleteRecursive($path);
if ($this->progressCallback) {
($this->progressCallback)($this->getStats());
}
return $result;
}
public function getStats(): array {
return [
'deleted_files' => $this->deletedFiles,
'deleted_dirs' => $this->deletedDirs,
'failed_items' => $this->failedItems,
'errors' => $this->errors,
'dry_run' => $this->dryRun,
];
}
private function deleteRecursive(string $path): bool {
if (is_link($path)) {
// 符號連結:只刪除連結本身,不遞歸進入目標
return $this->removeItem($path, 'symlink');
}
if (!is_dir($path)) {
return $this->removeItem($path, 'file');
}
// 目錄:先清空內容
$handle = @opendir($path);
if (!$handle) {
$this->recordError($path, '無法打開目錄');
return false;
}
$entries = [];
while (($entry = readdir($handle)) !== false) {
if ($entry !== '.' && $entry !== '..') {
$entries[] = $entry;
}
}
closedir($handle);
// 分批處理大目錄
foreach (array_chunk($entries, $this->batchSize) as $batch) {
foreach ($batch as $entry) {
$fullPath = "{$path}/{$entry}";
if (is_dir($fullPath) && !is_link($fullPath)) {
if (!$this->deleteRecursive($fullPath)) {
// 子目錄刪除失敗,繼續處理其他項目但標記失敗
$this->failedItems++;
}
} else {
$this->removeItem($fullPath, is_link($fullPath) ? 'symlink' : 'file');
}
}
// 進度回調
if ($this->progressCallback) {
($this->progressCallback)($this->getStats());
}
}
// 最後刪除空目錄
return $this->removeItem($path, 'dir');
}
private function removeItem(string $path, string $type): bool {
if ($this->dryRun) {
$this->recordAction($path, $type, 'DRY_RUN');
return true;
}
// 嘗試修復權限(僅針對當前用戶可寫的父目錄下的項目)
$this->ensureWritable($path);
$success = false;
switch ($type) {
case 'file':
case 'symlink':
$success = @unlink($path);
break;
case 'dir':
$success = @rmdir($path);
break;
}
if ($success) {
$this->recordAction($path, $type, 'DELETED');
if ($type === 'dir') $this->deletedDirs++; else $this->deletedFiles++;
} else {
$this->recordError($path, "刪除 {$type} 失敗: " . error_get_last()['message'] ?? '未知錯誤');
$this->failedItems++;
}
return $success;
}
private function ensureWritable(string $path): void {
$perms = @fileperms($path);
if ($perms !== false && !($perms & 0200)) { // 檢查用戶寫權限
@chmod($path, $perms | 0200);
}
// 父目錄也需寫權限才能刪除條目
$parent = dirname($path);
if ($parent !== $path) {
$pPerms = @fileperms($parent);
if ($pPerms !== false && !($pPerms & 0200)) {
@chmod($parent, $pPerms | 0200);
}
}
}
private function validatePath(string $path): bool {
$realPath = realpath($path);
if (!$realPath) {
$this->recordError($path, '路徑不存在');
return false;
}
// 防護:禁止刪除系統關鍵目錄
$forbidden = ['/', '/root', '/home', '/etc', '/var', '/usr', '/bin', '/sbin', '/lib', '/boot', '/opt'];
if (in_array($realPath, $forbidden, true) || in_array(dirname($realPath), $forbidden, true)) {
$this->recordError($path, '禁止刪除系統關鍵目錄');
return false;
}
return true;
}
private function normalizePath(string $path): string {
return rtrim(str_replace('\\', '/', $path), '/');
}
private function recordAction(string $path, string $type, string $status): void {
if ($this->progressCallback) {
// 實時回調由 deleteRecursive 處理
}
}
private function recordError(string $path, string $message): void {
$this->errors[] = ['path' => $path, 'error' => $message, 'time' => date('c')];
}
private function resetCounters(): void {
$this->deletedFiles = 0;
$this->deletedDirs = 0;
$this->failedItems = 0;
$this->errors = [];
}
}
<?php
require 'DirectoryCleaner.php';
$cleaner = new DirectoryCleaner();
$success = $cleaner->delete('/var/www/project/runtime/cache');
$stats = $cleaner->getStats();
echo "刪除完成: 文件 {$stats['deleted_files']}, 目錄 {$stats['deleted_dirs']}, 失敗 {$stats['failed_items']}\n";
if ($stats['errors']) {
print_r($stats['errors']);
}
$cleaner = new DirectoryCleaner(['dry_run' => true]);
$cleaner->delete('/var/www/project/runtime/cache');
// 查看 stats 確認無誤後再實際執行
$cleaner = new DirectoryCleaner([
'batch_size' => 500,
'progress' => function($stats) {
static $last = 0;
$total = $stats['deleted_files'] + $stats['deleted_dirs'];
if ($total - $last >= 100) {
echo "已處理: {$total} 項目\n";
$last = $total;
}
}
]);
$cleaner->delete('/var/www/huge_uploads');
<?php
// app/Console/Commands/CleanDirectory.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Services\DirectoryCleaner;
class CleanDirectory extends Command {
protected $signature = 'directory:clean {path} {--dry-run} {--batch=1000}';
protected $description = '遞歸刪除目錄內容';
public function handle() {
$path = $this->argument('path');
$cleaner = new DirectoryCleaner([
'dry_run' => $this->option('dry-run'),
'batch_size' => $this->option('batch'),
'progress' => fn($s) => $this->info("已刪除: {$s['deleted_files']} 文件, {$s['deleted_dirs']} 目錄")
]);
if ($cleaner->delete($path)) {
$this->info('清理成功');
} else {
$this->error('清理失敗');
foreach ($cleaner->getStats()['errors'] as $err) {
$this->error("{$err['path']}: {$err['error']}");
}
return 1;
}
return 0;
}
}
| 陷阱 | 後果 | 解決方案 |
|---|---|---|
| 不檢查符號連結 | 誤刪連結指向的真實目標文件/目錄 | 使用 is_link() 判斷,僅 unlink() 連結本身 |
| 忽略權限問題 | 刪除失敗、殘留文件 | 刪除前 chmod() 確保用戶寫權限;捕獲 error_get_last() |
| 大目錄一次性讀入記憶體 | 記憶體耗盡 (OOM) | array_chunk() 分批處理;或用 RecursiveDirectoryIterator 流式處理 |
| 路徑遍歷漏洞 | 刪除系統關鍵文件 | realpath() 規範化 + 白名單/黑名單校驗基礎路徑 |
| 無日誌無監控 | 無法審計、排查失敗 | 記錄每次操作、統計計數、錯誤詳情、耗時 |
| 生產環境直接測試 | 不可逆數據丟失 | 必須先 dry_run 預覽;備份關鍵數據;分階段發布 |
<?php
function deleteWithIterator(string $dir): void {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST // 關鍵:先處理子項
);
foreach ($iterator as $item) {
if ($item->isDir()) {
@rmdir($item->getPathname());
} else {
@unlink($item->getPathname());
}
}
@rmdir($dir);
}
優點:代碼簡潔、內建 CHILD_FIRST 遞歸順序、自動跳過 . 和 ..
缺點:難以注入自定義邏輯(權限修復、進度回調、乾跑模式)、錯誤處理較粗糙。
遞歸刪除目錄看似簡單,生產環境需考慮符號連結安全、權限修復、大目錄效能、路徑遍歷防護、可觀測性等工程化細節。建議封裝為可複用的 DirectoryCleaner 類,配合乾跑模式、進度回調、詳細審計日誌,實現安全、可控、可審計的目錄清理操作。