首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 遞歸刪除目錄的正確姿勢與陷阱避坑指南

     編輯:PHP 相關知識

前言

刪除非空目錄是 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;
}

生產級增強版:DirectoryCleaner 類

<?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 = [];
    }
}

使用示例

1. 基礎用法

<?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']);
}

2. 乾跑模式(預覽將刪除內容)

$cleaner = new DirectoryCleaner(['dry_run' => true]);
$cleaner->delete('/var/www/project/runtime/cache');
// 查看 stats 確認無誤後再實際執行

3. 進度監控(大目錄適用)

$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');

4. Laravel Artisan 命令整合

<?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 預覽;備份關鍵數據;分階段發布

現代替代方案:RecursiveDirectoryIterator

<?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 類,配合乾跑模式、進度回調、詳細審計日誌,實現安全、可控、可審計的目錄清理操作。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved