基于 PHP-Parser 的 debug 日志批量清理脚本
在项目开发阶段,我们经常会插入大量 logs()->debug(...) 调用用于排查问题。
虽然可以调整logger日志级别,在线上不运行debug,但是终究会有性能损耗,如果能像其他语言先行编译去除无用代码和注释,效率是最佳的,基于此,进行了本篇博文的测试。
- 第一版本使用了全量的AST解析和移除代码,会导致原有代码格式全部变动到格式,不利于做git diff观察是否有bug
- 第二版本使用AST解析调用定位 + 移除行的方式 可正常达到预期
本文介绍一个利用 AST(抽象语法树)精准定位并批量删除这类调用的 PHP 脚本。
脚本功能概述
脚本的核心目标:扫描指定目录下的所有 PHP 文件,找到所有 logs()->debug(...) 调用语句,并将其对应的行删除,同时保持文件其他内容的格式完全不变。
关键特性:
- 基于 AST 解析,不是正则匹配,语义精准,不会误删相似但不同的代码
- 支持多行调用(
start~end范围删除) - 跳过
vendor、runtime、config等无关目录 - 文件写回时不改变任何缩进、换行风格
依赖
脚本依赖 nikic/php-parser,通过 Composer 安装:
composer require nikic/php-parser
核心流程
整个脚本分为三个主要步骤:
扫描目录中的 PHP 文件
↓
对每个文件进行 AST 解析,定位 logs()->debug(...) 调用的行范围
↓
按行范围删除对应行,写回文件
代码详解
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use PhpParser\Error;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Stmt\Expression;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\ParserFactory;
// ------------------- 配置 -------------------
$scanDir = __DIR__ . '/app'; // 要扫描的目录
$skipDirs = ['vendor', 'runtime', 'config']; // 跳过的目录
// ------------------- 核心:查找目标行号 -------------------
/** @return array<array{start: int, end: int}> */
function findDebugLogLines(string $code): array
{
$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $e) {
return [];
}
$traverser = new NodeTraverser();
$visitor = new class extends NodeVisitorAbstract {
/** @var array<array{start: int, end: int}> */
public array $ranges = [];
public function leaveNode(Node $node)
{
if (! $node instanceof Expression) {
return;
}
$expr = $node->expr;
if (! $expr instanceof MethodCall || $expr->name->toString() !== 'debug') {
return;
}
$caller = $expr->var;
if (! $caller instanceof FuncCall) {
return;
}
if ($caller->name instanceof Node\Name
&& $caller->name->getLast() === 'logs'
) {
$this->ranges[] = [
'start' => $node->getStartLine(),
'end' => $node->getEndLine(),
];
}
}
};
$traverser->addVisitor($visitor);
$traverser->traverse($ast);
return $visitor->ranges;
}
// ------------------- 按范围删除(不改变任何格式) -------------------
function removeLinesByRanges(string $filePath, array $ranges): void
{
if (empty($ranges)) {
return;
}
// 将所有范围展开为要删除的行号集合
$removeLines = [];
foreach ($ranges as $range) {
for ($i = $range['start']; $i <= $range['end']; ++$i) {
$removeLines[$i] = true;
}
}
$lines = file($filePath, FILE_IGNORE_NEW_LINES);
$newLines = [];
foreach ($lines as $num => $line) {
if (! isset($removeLines[$num + 1])) {
$newLines[] = $line;
}
}
file_put_contents($filePath, implode("\n", $newLines));
}
// ------------------- 批量扫描目录 -------------------
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($scanDir)
);
foreach ($iterator as $file) {
$path = $file->getPathname();
if ($file->getExtension() !== 'php') {
continue;
}
foreach ($skipDirs as $dir) {
if (str_contains($path, "/{$dir}/")) {
continue 2;
}
}
$code = file_get_contents($path);
$ranges = findDebugLogLines($code);
if (! empty($ranges)) {
removeLinesByRanges($path, $ranges);
$rangeDesc = implode(', ', array_map(fn ($r) => "{$r['start']}-{$r['end']}", $ranges));
echo "✅ 清理: {$path} (行范围:{$rangeDesc})\n";
}
}
echo "\n🎉 全部完成!格式 100% 不变,仅删除 debug 调用行\n";
第一步:AST 解析定位目标行号
function findDebugLogLines(string $code): array
{
$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);
$traverser = new NodeTraverser();
$visitor = new class extends NodeVisitorAbstract {
public array $ranges = [];
public function leaveNode(Node $node)
{
if (!$node instanceof Expression) return;
$expr = $node->expr;
if (!$expr instanceof MethodCall || $expr->name->toString() !== 'debug') return;
$caller = $expr->var;
if (!$caller instanceof FuncCall) return;
if ($caller->name instanceof Node\Name
&& $caller->name->getLast() === 'logs'
) {
$this->ranges[] = [
'start' => $node->getStartLine(),
'end' => $node->getEndLine(),
];
}
}
};
$traverser->addVisitor($visitor);
$traverser->traverse($ast);
return $visitor->ranges;
}
这段代码做了什么?
- 使用
ParserFactory将 PHP 源码解析成 AST - 创建一个
NodeVisitor,在leaveNode钩子中逐节点分析 - 判断条件(层层递进):
- 节点必须是
Expression(语句级别的表达式) - 表达式必须是
MethodCall,且方法名为debug - 调用者(
$expr->var)必须是FuncCall(函数调用) - 函数名的最后一段必须是
logss
- 节点必须是
- 满足条件则记录该节点的起止行号
为什么不用正则?
正则无法正确处理多行调用,例如:
logs()->debug(
'message',
['key' => 'value']
);
AST 解析后可以获取 getStartLine() 和 getEndLine(),精准覆盖整个多行语句,而正则只能匹配单行。
第二步:按行范围删除
function removeLinesByRanges(string $filePath, array $ranges): void
{
if (empty($ranges)) return;
$removeLines = [];
foreach ($ranges as $range) {
for ($i = $range['start']; $i <= $range['end']; ++$i) {
$removeLines[$i] = true;
}
}
$lines = file($filePath, FILE_IGNORE_NEW_LINES);
$newLines = [];
foreach ($lines as $num => $line) {
if (!isset($removeLines[$num + 1])) {
$newLines[] = $line;
}
}
file_put_contents($filePath, implode("\n", $newLines));
}
关键细节:
FILE_IGNORE_NEW_LINES:读取文件时去掉每行末尾的换行符,最后统一用"\n"拼回,保证换行风格一致- 行号从
1开始(PHP-Parser 的约定),而数组下标从0开始,所以判断时用$num + 1 - 只删除目标行,其余行原样保留,不做任何格式改动
第三步:递归扫描目录
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($scanDir)
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') continue;
foreach ($skipDirs as $dir) {
if (str_contains($path, "/{$dir}/")) continue 2;
}
$code = file_get_contents($path);
$ranges = findDebugLogLines($code);
if (!empty($ranges)) {
removeLinesByRanges($path, $ranges);
echo "✅ 清理: {$path}\n";
}
}
使用 PHP 标准库的 RecursiveIteratorIterator + RecursiveDirectoryIterator 遍历目录,跳过非 PHP 文件以及配置中排除的目录。
配置项
脚本顶部提供了两个配置项,按需修改即可:
$scanDir = __DIR__ . '/app'; // 要扫描的根目录
$skipDirs = ['vendor', 'runtime', 'config']; // 跳过的目录名
使用方式
将脚本保存为 clean_debug.php,在项目根目录执行:
php clean_debug.php
执行后会输出每个被清理的文件及其删除的行范围,最终打印完成提示:
✅ 清理: /path/to/app/Services/UserService.php (行范围:42-42)
✅ 清理: /path/to/app/Http/Controllers/OrderController.php (行范围:88-91, 120-120)
🎉 全部完成!格式 100% 不变,仅删除 debug 调用行
适用场景
| 场景 | 说明 |
|---|---|
| 上线前清理调试日志 | 批量删除开发阶段留下的 logs()->debug(...) |
| CI/CD 流水线检查 | 将脚本集成到 pipeline,检测是否存在未清理的 debug 调用 |
| 代码审查辅助 | 快速统计哪些文件存在 debug 调用 |
小结
这个脚本的价值在于:
- 语义正确:基于 AST 而非字符串匹配,理解代码结构
- 格式无损:只删除目标行,其余内容字节级别不变
- 多行支持:能正确处理跨多行的 debug 调用
- 可配置:目录和排除规则均可按需调整
如果项目中的日志函数名不是 logs,只需修改 findDebugLogLines 函数中的判断条件即可复用此脚本。
扫描二维码,分享此文章