在專案維護或內容遷移時,常需批量修改多個檔案中的特定字串(如更新版權年份、修正舊域名、統一術語)。早期 PHP 開發者常呼叫系統指令(如 grep、sed)來達成,但這帶來安全性與可攜性風險。本文將解析經典做法的缺失,並提供現代 PHP 原生的最佳解決方案。
早期常見寫法使用 exec() 呼叫 grep -r 遞迴搜尋,再逐檔 fopen/fread/str_replace/fwrite 替換:
<?php
$oldword = 'test';
$newword = 'text';
$rootpath = '/path/to/dir';
exec("/bin/grep -r '$oldword' $rootpath", $results, $errorCode);
if ($errorCode) {
if ($errorCode == 1) {
echo "Possibly no files were found with $oldword in them<br>\n";
}
echo "OS Error: $errorCode<br>\n";
echo "Check 'man errno' and count down<br>\n";
echo "Usually paths/permissions<br>\n";
}
while (list(, $path) = each($results)) {
$parts = explode(':', $path);
$path = $parts[0];
$fp = fopen($path, 'r') or print("Cannot read $path<br>\n");
if ($fp) {
$data = fread($fp, filesize($path));
fclose($fp);
$newdata = str_replace($oldword, $newword, $data);
$fp = fopen($path, 'w') or print("Cannot write $path<br>\n");
if ($fp) {
fwrite($fp, $newdata);
fclose($fp);
echo $path, "<br>\n";
}
}
}
?>
$oldword、$rootpath 若未嚴格過濾,攻擊者可注入任意指令(如 ; rm -rf /)。grep,Windows 環境無法直接執行。fread 讀取整個檔案可能耗盡記憶體;若檔案為圖片/二進位,str_replace 會破壞檔案結構。使用 RecursiveDirectoryIterator + RecursiveIteratorIterator 遞迴遍歷,搭配 file_get_contents/file_put_contents(支援串流與鎖定),並內建二進位檢測與備份機制。
<?php
declare(strict_types=1);
function searchAndReplace(string $directory, string $search, string $replace, array $options = []): array {
$defaults = [
'extensions' => ['php', 'html', 'css', 'js', 'txt', 'md', 'json', 'xml', 'yml', 'yaml'],
'excludeDirs' => ['.git', 'node_modules', 'vendor', 'storage', 'cache', 'logs'],
'dryRun' => false,
'backup' => true,
'caseSensitive' => true,
'maxFileSize' => 10 * 1024 * 1024, // 10MB
];
$opts = array_merge($defaults, $options);
$results = ['modified' => [], 'errors' => [], 'skipped' => []];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
// 排除目錄
$pathParts = explode(DIRECTORY_SEPARATOR, $file->getPath());
if (array_intersect($pathParts, $opts['excludeDirs'])) {
continue;
}
// 檢查副檔名
if (!in_array(strtolower($file->getExtension()), $opts['extensions'])) {
$results['skipped'][] = ['file' => $file->getPathname(), 'reason' => 'extension_not_allowed'];
continue;
}
// 檢查檔案大小
if ($file->getSize() > $opts['maxFileSize']) {
$results['skipped'][] = ['file' => $file->getPathname(), 'reason' => 'file_too_large'];
continue;
}
$pathname = $file->getPathname();
try {
$content = file_get_contents($pathname);
if ($content === false) {
throw new RuntimeException("Unable to read file");
}
// 簡易二進位檢測:避免破壞圖片等檔案
if (preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $content)) {
$results['skipped'][] = ['file' => $pathname, 'reason' => 'binary_file_detected'];
continue;
}
$newContent = $opts['caseSensitive']
? str_replace($search, $replace, $content)
: str_ireplace($search, $replace, $content);
if ($newContent !== $content) {
if (!$opts['dryRun']) {
// 備份
if ($opts['backup']) {
copy($pathname, $pathname . '.bak.' . date('YmdHis'));
}
// 寫入(LOCK_EX 避免競態)
if (file_put_contents($pathname, $newContent, LOCK_EX) === false) {
throw new RuntimeException("Failed to write file");
}
}
$results['modified'][] = $pathname;
}
} catch (Throwable $e) {
$results['errors'][] = ['file' => $pathname, 'message' => $e->getMessage()];
}
}
return $results;
}
// 使用範例
$report = searchAndReplace(
'/var/www/html/project',
'old_domain.com',
'new_domain.com',
['dryRun' => true, 'caseSensitive' => false] // 先跑 dryRun 確認
);
echo "Modified: " . count($report['modified']) . "\n";
echo "Errors: " . count($report['errors']) . "\n";
echo "Skipped: " . count($report['skipped']) . "\n";
foreach ($report['modified'] as $f) { echo " - $f\n"; }
git diff 檢視變更。preg_replace 並注意效能與 catastrophic backtracking。maxFileSize 時,改用 SplFileObject 逐行讀寫至暫存檔,最後 rename 覆蓋(原子操作)。parallel extension 或 pcntl_fork 多進程處理大型專案。摒棄 exec() 呼叫系統指令,改用 PHP 內建的 SPL 迭代器與檔案操作函式,不僅消除命令注入風險,更獲得跨平台相容性、更細緻的錯誤控制與可測試性。記得永遠先以 dryRun = true 執行預演,確認無誤後再正式寫入。