使用 PHP 內置目錄函數配合遞歸算法,可實現完整的目錄樹掃描、文件大小統計、人類可讀格式化輸出。本文提供生產級代碼與效能優化建議。
<?php
declare(strict_types=1);
class DirectoryScanner {
private array $stats = [
'files' => 0,
'dirs' => 0,
'total_size' => 0,
'errors' => [],
];
private array $options = [
'follow_links' => false, // 是否跟隨軟鏈接
'max_depth' => -1, // 最大遞歸深度(-1 無限制)
'skip_hidden' => true, // 跳過隱藏文件/目錄
'extensions' => [], // 只統計指定擴展名(空=全部)
'exclude_patterns' => [], // 排除正則模式
];
public function __construct(array $options = []) {
$this->options = array_merge($this->options, $options);
}
// 公開入口
public function scan(string $path): array {
if (!is_dir($path) || !is_readable($path)) {
throw new \InvalidArgumentException("路徑不存在或不可讀: {$path}");
}
$this->stats = ['files' => 0, 'dirs' => 0, 'total_size' => 0, 'errors' => []];
$this->scanDirectory($path, 0);
return [
'path' => realpath($path),
'files' => $this->stats['files'],
'dirs' => $this->stats['dirs'],
'total_size' => $this->stats['total_size'],
'total_size_human' => $this->formatBytes($this->stats['total_size']),
'errors' => $this->stats['errors'],
];
}
// 遞歸核心
private function scanDirectory(string $path, int $depth): void {
// 深度限制
if ($this->options['max_depth'] > 0 && $depth >= $this->options['max_depth']) {
return;
}
$handle = @opendir($path);
if (!$handle) {
$this->stats['errors'][] = "無法打開目錄: {$path}";
return;
}
while (($entry = readdir($handle)) !== false) {
// 跳過 . 和 ..
if ($entry === '.' || $entry === '..') continue;
// 跳過隱藏文件
if ($this->options['skip_hidden'] && str_starts_with($entry, '.')) continue;
$fullPath = $path . DIRECTORY_SEPARATOR . $entry;
// 排除模式匹配
if ($this->matchesExclude($fullPath)) continue;
try {
if (is_link($fullPath)) {
// 軟鏈接處理
if ($this->options['follow_links'] && is_dir($fullPath)) {
$this->stats['dirs']++;
$this->scanDirectory($fullPath, $depth + 1);
}
// 文件鏈接按文件處理
elseif (is_file($fullPath)) {
$this->processFile($fullPath);
}
}
elseif (is_dir($fullPath)) {
$this->stats['dirs']++;
$this->scanDirectory($fullPath, $depth + 1);
}
elseif (is_file($fullPath)) {
$this->processFile($fullPath);
}
} catch (\Throwable $e) {
$this->stats['errors'][] = "處理失敗 {$fullPath}: " . $e->getMessage();
}
}
closedir($handle);
}
private function processFile(string $path): void {
// 擴展名過濾
if ($this->options['extensions']) {
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if (!in_array($ext, $this->options['extensions'], true)) return;
}
$size = @filesize($path);
if ($size !== false) {
$this->stats['files']++;
$this->stats['total_size'] += $size;
}
}
private function matchesExclude(string $path): bool {
foreach ($this->options['exclude_patterns'] as $pattern) {
if (preg_match($pattern, $path)) return true;
}
return false;
}
// 格式化位元組
public function formatBytes(int $bytes, int $precision = 2): string {
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$value = $bytes / (1024 ** $pow);
return round($value, $precision) . ' ' . $units[$pow];
}
}
<?php
require_once 'DirectoryScanner.php';
$scanner = new DirectoryScanner([
'follow_links' => false,
'max_depth' => 10,
'skip_hidden' => true,
'extensions' => ['php', 'js', 'css', 'html', 'vue', 'md'], // 只統計代碼文件
'exclude_patterns' => [
'/vendor/', // 排除 composer 依賴
'/node_modules/', // 排除 npm 依賴
'/\.git/', // 排除 git 目錄
'/storage\/logs/', // 排除日誌
'/\.cache$/', // 排除緩存文件
],
]);
try {
$result = $scanner->scan('/var/www/myproject');
echo "掃描路徑: {$result['path']}\n";
echo "文件數量: {$result['files']}\n";
echo "目錄數量: {$result['dirs']}\n";
echo "總大小: {$result['total_size_human']} ({$result['total_size']} bytes)\n";
if ($result['errors']) {
echo "\n錯誤:\n";
foreach ($result['errors'] as $err) echo " - {$err}\n";
}
} catch (\Throwable $e) {
echo "掃描失敗: " . $e->getMessage();
}
<?php
class DetailedScanner extends DirectoryScanner {
private array $fileList = [];
protected function processFile(string $path): void {
parent::processFile($path);
$stat = stat($path);
$this->fileList[] = [
'path' => $path,
'relative_path' => $this->getRelativePath($path),
'size' => $stat['size'],
'size_human' => $this->formatBytes($stat['size']),
'modified' => date('Y-m-d H:i:s', $stat['mtime']),
'extension' => strtolower(pathinfo($path, PATHINFO_EXTENSION)),
'permissions' => substr(sprintf('%o', $stat['mode']), -4),
];
}
public function getFileList(): array {
return $this->fileList;
}
public function exportCsv(string $filename): void {
$fp = fopen($filename, 'w');
fputcsv($fp, ['路徑', '大小', '修改時間', '擴展名', '權限']);
foreach ($this->fileList as $file) {
fputcsv($fp, [
$file['relative_path'],
$file['size_human'],
$file['modified'],
$file['extension'],
$file['permissions'],
]);
}
fclose($fp);
}
private function getRelativePath(string $path): string {
// 實現相對路徑邏輯
return $path;
}
}
// 使用
$scanner = new DetailedScanner(['extensions' => ['log']]);
$scanner->scan('/var/log/nginx');
$scanner->exportCsv('nginx_logs_report.csv');
<?php
function scanDirectoryIterative(string $rootPath): array {
$stats = ['files' => 0, 'dirs' => 0, 'total_size' => 0];
$stack = [$rootPath];
while ($stack) {
$currentDir = array_pop($stack);
$handle = @opendir($currentDir);
if (!$handle) continue;
while (($entry = readdir($handle)) !== false) {
if ($entry === '.' || $entry === '..') continue;
$fullPath = $currentDir . DIRECTORY_SEPARATOR . $entry;
if (is_dir($fullPath) && !is_link($fullPath)) {
$stats['dirs']++;
$stack[] = $fullPath;
} elseif (is_file($fullPath)) {
$size = @filesize($fullPath);
if ($size !== false) {
$stats['files']++;
$stats['total_size'] += $size;
}
}
}
closedir($handle);
}
return $stats;
}
<?php
function scanWithSpl(string $path, array $extensions = []): array {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
$stats = ['files' => 0, 'dirs' => 0, 'total_size' => 0, 'files_list' => []];
foreach ($iterator as $fileInfo) {
if ($fileInfo->isDir()) {
$stats['dirs']++;
} elseif ($fileInfo->isFile()) {
$ext = strtolower($fileInfo->getExtension());
if ($extensions && !in_array($ext, $extensions, true)) continue;
$size = $fileInfo->getSize();
$stats['files']++;
$stats['total_size'] += $size;
$stats['files_list'][] = [
'path' => $fileInfo->getPathname(),
'size' => $size,
'modified' => $fileInfo->getMTime(),
];
}
}
return $stats;
}
| 方案 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| 遞歸函數 | 代碼簡潔、易理解、易擴展 | 極深目錄可能棧溢出 | 常規項目、深度 < 1000 |
| 手動棧迭代 | 無深度限制、內存可控 | 代碼較複雜 | 超深目錄、大規模掃描 |
| SPL RecursiveIterator | 內置、標準化、支援過濾器 | 性能略低、自定義邏輯繁瑣 | 快速原型、標準化需求 |
exec('du -sh') | 系統級、極快、含磁盤佔用 | 依賴 Linux、需權限、無法過濾 | 服務器盤面統計、CI/CD |
is_readable(),捕獲異常記錄而非中斷max_depth、exclude_patterns 避免掃描 vendor/node_modulesset_time_limit(0);Web 環境改用隊列異步處理follow_links = false根據項目規模與環境選擇合適方案,配合排除規則與異步處理,能高效完成目錄統計任務。