專案遷移、編碼統一、關鍵字替換、版權年份更新等場景常需批量修改目錄下所有檔案。本文提供從簡單字串替換到正則替換、編碼轉換、BOM 移除的完整工具類別,支援乾跑模式、備份與進度回呼。
<?php
class BatchFileReplacer {
private $rootDir;
private $extensions = ['php', 'html', 'htm', 'js', 'css', 'txt', 'xml', 'json', 'md', 'sql'];
private $excludeDirs = ['.git', '.svn', 'node_modules', 'vendor', 'storage', 'cache', 'logs'];
private $dryRun = false;
private $backup = true;
private $callback = null;
private $stats = ['files' => 0, 'replaced' => 0, 'errors' => 0, 'skipped' => 0];
public function __construct(string $rootDir) {
$this->rootDir = rtrim($rootDir, '/\\');
}
// 設定要處理的副檔名
public function setExtensions(array $exts) { $this->extensions = $exts; return $this; }
// 設定排除目錄
public function setExcludeDirs(array $dirs) { $this->excludeDirs = $dirs; return $this; }
// 乾跑模式 (不實際寫入)
public function setDryRun(bool $dry) { $this->dryRun = $dry; return $this; }
// 是否備份原檔 (.bak)
public function setBackup(bool $backup) { $this->backup = $backup; return $this; }
// 進度回呼: function(string $file, int $replacements, string $action)
public function setProgressCallback(callable $cb) { $this->callback = $cb; return $this; }
// 簡單字串替換
public function replaceString(string $search, string $replace): array {
return $this->process(function($content) use ($search, $replace) {
$count = 0;
$newContent = str_replace($search, $replace, $content, $count);
return [$newContent, $count];
});
}
// 正則替換
public function replaceRegex(string $pattern, string $replace): array {
return $this->process(function($content) use ($pattern, $replace) {
$newContent = preg_replace($pattern, $replace, $content, -1, $count);
return [$newContent, $count];
});
}
// 多組替換 (陣列 ['search'=>'replace', ...])
public function replaceMultiple(array $pairs): array {
return $this->process(function($content) use ($pairs) {
$total = 0;
foreach ($pairs as $search => $replace) {
$content = str_replace($search, $replace, $content, $count);
$total += $count;
}
return [$content, $total];
});
}
// 編碼轉換 (如 GB2312 -> UTF-8)
public function convertEncoding(string $from, string $to): array {
return $this->process(function($content) use ($from, $to) {
$newContent = @iconv($from, $to . '//IGNORE', $content);
$changed = ($newContent !== false && $newContent !== $content);
return [$newContent ?: $content, $changed ? 1 : 0];
});
}
// 移除 UTF-8 BOM
public function removeBom(): array {
return $this->process(function($content) {
$bom = "\xEF\xBB\xBF";
if (strncmp($content, $bom, 3) === 0) {
return [substr($content, 3), 1];
}
return [$content, 0];
});
}
// 核心處理邏輯
private function process(callable $transformer): array {
$this->stats = ['files' => 0, 'replaced' => 0, 'errors' => 0, 'skipped' => 0];
$this->traverse($this->rootDir);
return $this->stats;
}
private function traverse(string $dir) {
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if (!$file->isFile()) continue;
// 檢查排除目錄
$path = $file->getPathname();
$relPath = substr($path, strlen($this->rootDir) + 1);
$skip = false;
foreach ($this->excludeDirs as $exDir) {
if (strpos($relPath, $exDir . DIRECTORY_SEPARATOR) === 0) {
$skip = true; break;
}
}
if ($skip) { $this->stats['skipped']++; continue; }
// 檢查副檔名
$ext = strtolower($file->getExtension());
if (!in_array($ext, $this->extensions)) { $this->stats['skipped']++; continue; }
$this->stats['files']++;
$content = @file_get_contents($path);
if ($content === false) { $this->stats['errors']++; continue; }
// 執行轉換
list($newContent, $count) = $transformer($content);
if ($count > 0) {
$this->stats['replaced'] += $count;
if (!$this->dryRun) {
// 備份
if ($this->backup) {
@copy($path, $path . '.bak');
}
// 寫入 (獨佔鎖定)
$fp = @fopen($path, 'wb');
if ($fp) {
flock($fp, LOCK_EX);
fwrite($fp, $newContent);
fclose($fp);
} else {
$this->stats['errors']++;
}
}
if ($this->callback) $this->callback($path, $count, $this->dryRun ? 'DRY-RUN' : 'REPLACED');
} else {
if ($this->callback) $this->callback($path, 0, 'UNCHANGED');
}
}
}
public function getStats(): array { return $this->stats; }
}
?>
<?php
require_once 'BatchFileReplacer.php';
$replacer = new BatchFileReplacer(__DIR__ . '/src')
->setExtensions(['php', 'html', 'js', 'css', 'env'])
->setExcludeDirs(['.git', 'vendor', 'storage'])
->setDryRun(true) // 先乾跑確認
->setProgressCallback(function($file, $count, $action) {
if ($count > 0) echo "[$action] $file ($count 次)\n";
});
$stats = $replacer->replaceMultiple([
'old-domain.com' => 'new-domain.com',
'https://old-domain.com' => 'https://new-domain.com',
'OLD_API_KEY' => 'NEW_API_KEY',
]);
echo "乾跑完成: " . json_encode($stats) . "\n";
// 確認無誤後執行真實替換
$replacer->setDryRun(false);
$stats = $replacer->replaceMultiple([
'old-domain.com' => 'new-domain.com',
// ...
]);
echo "替換完成: " . json_encode($stats) . "\n";
?>
<?php
$replacer = new BatchFileReplacer(__DIR__)
->setExtensions(['php', 'html', 'css', 'js'])
->setProgressCallback(function($f, $c, $a) { if($c) echo "$a: $f\n"; });
// 先移除 BOM
$replacer->removeBom();
// 再轉換編碼 (針對已知為 GB2312 的檔案)
// 注意:iconv 可能誤判編碼,建議針對特定目錄執行
$replacer->convertEncoding('GB2312', 'UTF-8');
?>
<?php
$replacer = new BatchFileReplacer(__DIR__ . '/config')
->setExtensions(['php'])
->setDryRun(false);
$stats = $replacer->replaceRegex(
'/mysql_connect\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/',
'new PDO("mysql:host=$1;dbname=$3", "$2", "$3")'
);
// 將舊式 mysql_connect 替換為 PDO (需人工檢查)
?>
<?php
// batch_replace.php
#!/usr/bin/env php
<?php
require_once 'BatchFileReplacer.php';
$shortopts = "d:s:r:e:x:bnh";
$longopts = ["dir:", "search:", "replace:", "ext:", "exclude:", "backup", "dry-run", "no-backup", "help"];
$options = getopt($shortopts, $longopts);
if (isset($options['h']) || isset($options['help']) || !isset($options['d'], $options['s'], $options['r'])) {
echo "用法: php batch_replace.php -d <目錄> -s <搜尋> -r <替換> [-e php,html] [-x vendor,logs] [--dry-run] [--no-backup]\n";
exit(1);
}
$replacer = new BatchFileReplacer($options['d'])
->setExtensions(explode(',', $options['e'] ?? 'php,html,js,css,txt'))
->setExcludeDirs(explode(',', $options['x'] ?? '.git,vendor,node_modules,storage,cache'))
->setDryRun(isset($options['dry-run']))
->setBackup(!isset($options['no-backup']))
->setProgressCallback(function($f, $c, $a) { if($c) echo "[$a] $f ($c)\n"; });
$stats = $replacer->replaceString($options['s'], $options['r']);
echo "\n統計: " . json_encode($stats, JSON_UNESCAPED_UNICODE) . "\n";
?>
使用:php batch_replace.php -d /var/www/project -s "old.com" -r "new.com" --dry-run
fopen/fgets) 避免記憶體耗盡。isBinary() 檢測 (檢查 NULL 字元、高位元組比例) 自動跳過圖片、編譯檔等。git status 確認工作區乾淨,執行後 git diff 檢視變更。pcntl_fork 或 parallel 擴充多進程處理。