Siam博客

基于 PHP-Parser 的 debug 日志批量清理脚本

2026-04-08

基于 PHP-Parser 的 debug 日志批量清理脚本

在项目开发阶段,我们经常会插入大量 logs()->debug(...) 调用用于排查问题。

虽然可以调整logger日志级别,在线上不运行debug,但是终究会有性能损耗,如果能像其他语言先行编译去除无用代码和注释,效率是最佳的,基于此,进行了本篇博文的测试。

  • 第一版本使用了全量的AST解析和移除代码,会导致原有代码格式全部变动到格式,不利于做git diff观察是否有bug
  • 第二版本使用AST解析调用定位 + 移除行的方式 可正常达到预期

本文介绍一个利用 AST(抽象语法树)精准定位并批量删除这类调用的 PHP 脚本。

脚本功能概述

脚本的核心目标:扫描指定目录下的所有 PHP 文件,找到所有 logs()->debug(...) 调用语句,并将其对应的行删除,同时保持文件其他内容的格式完全不变

关键特性:

  • 基于 AST 解析,不是正则匹配,语义精准,不会误删相似但不同的代码
  • 支持多行调用(start ~ end 范围删除)
  • 跳过 vendorruntimeconfig 等无关目录
  • 文件写回时不改变任何缩进、换行风格

依赖

脚本依赖 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;
}

这段代码做了什么?

  1. 使用 ParserFactory 将 PHP 源码解析成 AST
  2. 创建一个 NodeVisitor,在 leaveNode 钩子中逐节点分析
  3. 判断条件(层层递进):
    • 节点必须是 Expression(语句级别的表达式)
    • 表达式必须是 MethodCall,且方法名为 debug
    • 调用者($expr->var)必须是 FuncCall(函数调用)
    • 函数名的最后一段必须是 logss
  4. 满足条件则记录该节点的起止行号

为什么不用正则?

正则无法正确处理多行调用,例如:

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 函数中的判断条件即可复用此脚本。

本文链接:
版权声明: 本文由 Siam原创发布,转载请遵循《署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)》许可协议授权

扫描二维码,分享此文章