PHP如何通过AST抽象语法树实现代码静态分析工具开发

各位好,欢迎来到今天的讲座,主题是《PHP代码的X光机:如何用AST把你的代码大卸八块》。

说实话,在座的各位,除了我自己,谁没经历过被代码审查员(Code Reviewer)灵魂拷问的时刻?

“你为什么要写这个if判断?它是多余的。”
“这里为什么用了var_dump,是准备庆祝代码通过吗?”
“这个函数为什么没注释?哦,这行代码本身就很清楚吗?”

是的,人类是很健忘的,也是很容易犯懒的。我们的眼睛就像一只刚睡醒的猫,容易错过那些逻辑上的bug。而IDE(集成开发环境)呢?它像个只会背书的差生,只能告诉你语法对不对,却不知道你是不是在埋雷。

为了拯救世界,也为了拯救我们要秃的头发,我们得学会使用“抽象语法树”。别被这个词吓到了,AST就是代码的解剖图,是代码的DNA序列。今天,我就手把手教你,怎么用PHP这门语言,把你的PHP文件变成一台X光机,让所有隐藏的bug、重复代码、甚至是对外的API泄露,都无所遁形。

准备好了吗?让我们先把代码这块大石头敲碎。

第一部分:AST是个啥?它是代码的整容医生

想象一下,你写了一行代码:

$age = 18 + 20;

在PHP引擎眼里,这行代码经过词法分析和语法分析后,就不再是字符串了,而是一个结构。它变成了一个树。

  • 根节点:这行代码本身。
  • 子节点:左边是变量 $age,右边是 18 + 20
  • 孙节点18 是一个常量,20 也是一个常量,+ 是一个运算符。

这就是AST。它把代码拆解成了一个个节点,每个节点都有类型、有属性、有子节点。它不看代码长什么样(比如空格、换行),只看代码的结构。

在PHP的世界里,最著名的实现就是 nikic/php-parser(PHP Parser)。它就是那个拿着解剖刀的医生,能把你的源码文件(*.php)变成AST数组。

第二部分:搭建你的第一台“扫描仪”

别废话了,直接上代码。我们要建一个简单的命令行工具。你需要安装PHP Parser:

composer require nikic/php-parser

然后,创建一个 Scanner.php 文件。我们的目标是:读取文件,生成AST,然后大喊一声:“这是什么鬼!”

<?php

require 'vendor/autoload.php';

use PhpParserError;
use PhpParserNodeDumper;
use PhpParserParserFactory;

// 1. 拿到解析器工厂
$parserFactory = new ParserFactory();
// 我们想要那个比较新、比较标准的解析器
$parser = $parserFactory->create(ParserFactory::PREFER_PHP7);

// 2. 读取你的“受害者”代码文件
$code = file_get_contents('your_code.php');

try {
    // 3. 解析!这是最关键的一步,像是在嚼烂一块牛排
    $ast = $parser->parse($code);

    // 4. 展示成果
    $dumper = new NodeDumper();
    echo $dumper->dump($ast);

} catch (Error $e) {
    echo 'Parse error: ', $e->getMessage();
}

当你运行这个脚本时,你会看到一堆乱七八糟但极其详细的信息。比如 Stmt_Class(类声明)、Stmt_Function(函数声明)、Expr_Assign(赋值操作)。

这堆信息就是你的财富。通过遍历这个数组,你可以做任何事情:比如,找到所有的函数,检查它们有没有返回值;比如,找到所有的 echo,检查它们是不是在没有日志系统的情况下直接输出了敏感信息。

第三部分:学会了遍历,你就拥有了上帝视角

光把树dump出来有什么用?那是给人类看的,人类看不懂,但机器看得懂。我们需要的是“访问者模式”。

PHP Parser 提供了一个 NodeVisitor 接口。你只要继承它,重写几个方法,当解析器走到某个节点时,你就可以“截胡”了。

来,我们来写个插件,专门抓“魔术数字”。

什么是魔术数字? 就是代码里出现了 42100 这种没头没尾的数字,既没有变量名,也没有注释,全靠程序员猜它代表什么意思。这通常意味着这个数字应该被定义成一个常量,或者写成 Time::DINNER_TIME

我们的插件逻辑是:如果遇到了一个数字,并且这个数字不在常量定义里(为了简化,我们暂时只检查当前文件内的常量),我们就报警。

<?php

require 'vendor/autoload.php';

use PhpParserNode;
use PhpParserNodeVisitorAbstract;

class MagicNumberDetector extends NodeVisitorAbstract
{
    // 记录文件里定义的所有常量
    private $constants = [];

    // 当访问到 ConstFetch 节点(即使用常量的地方,比如 SOME_CONST)时触发
    public function enterNode(Node $node)
    {
        // 1. 首先,我们得知道常量是谁定义的
        if ($node instanceof NodeStmtConst_) {
            foreach ($node->consts as $const) {
                $this->constants[$const->name->toString()] = true;
            }
            return;
        }

        // 2. 如果是数字字面量(比如 42)
        if ($node instanceof NodeExprConstFetch) {
            // 这里有个坑,如果访问的是像 3.14 这种,其实也是 ConstFetch,但是名字是 "M_PI" 这种系统常量
            // 但我们这里只关注用户定义的常量,比如 $name->toString() 
            // 注意:为了演示方便,我们忽略系统常量判断
            return;
        }

        // 3. 如果是整型或浮点型字面量
        if ($node instanceof NodeScalarLNumber || $node instanceof NodeScalarDNumber) {
            $value = $node->value;

            // 4. 如果这个数字不在我们的常量黑名单里,那就报警
            // 注意:这里有个边界情况,比如 0 和 1 通常是可以接受的,我们简单过滤一下
            if (!in_array($value, [0, 1], true) && !isset($this->constants[$value])) {
                echo "警告:发现魔术数字 {$value},请给它起个好名字!n";
                echo "   文件: {$node->getStartLine()} 行n";
            }
        }
    }
}

// ... (省略前面的 Parser 初始化代码)

// 5. 实例化你的插件
$visitor = new MagicNumberDetector();

// 6. 创建节点遍历器
$traverser = new PhpParserNodeTraverser();
$traverser->addVisitor($visitor);

// 7. 执行扫描
$traverser->traverse($ast);

看看,这就像是在代码里装了传感器。只要出现那个该死的 42,你的控制台就会像红灯一样闪烁。这就是AST的魅力,它让你能像黑客一样,潜入代码的内部,把那些隐藏的逻辑关系一个个揪出来。

第四部分:进阶技能——数据流分析(追踪变量)

上面的魔术数字太简单了。真正的静态分析神器,是“数据流分析”。这是很多商业IDE(如PHPStorm)的核心技术。

比如,我们要做一个SQL注入检测器。

场景是这样的:用户从表单里提交了一个ID($_GET['id']),然后我们把它拼接到SQL语句里去查询数据库。这可是高风险操作!

AST怎么帮我们?

  1. 我们得找到所有调用数据库函数的地方,比如 mysqli_query($conn, $sql)
  2. 我们得看看 $sql 这个变量是怎么来的。
  3. 如果 $sql 是一个字符串拼接($sql = 'SELECT * FROM users WHERE id = ' . $_GET['id']),并且这个变量来自外部输入($_GET),那就触发警报!

这听起来很复杂,但在AST的世界里,这就像玩连连看一样简单。

首先,我们需要一个变量历史记录器。

class SqlInjectionDetector extends NodeVisitorAbstract
{
    // 这是一个大杀器:记录变量名和它的来源
    // key: 变量名, value: 产生这个变量的节点类型
    private $variableSources = [];

    public function enterNode(Node $node)
    {
        // 1. 捕获外部输入
        if ($node instanceof NodeExprVariable && is_string($node->name)) {
            // 检查是不是 $_GET, $_POST, $_REQUEST, $_COOKIE
            $varName = $node->name;
            if (in_array($varName, ['_GET', '_POST', '_REQUEST', '_COOKIE'], true)) {
                // 如果是超全局变量,标记它为不可信
                $this->variableSources[$varName] = 'user_input';
            }
        }

        // 2. 捕获变量赋值
        if ($node instanceof NodeExprAssign) {
            // 比如说 $id = $_GET['id']
            // 左边是变量名,右边是值
            if ($node->var instanceof NodeExprVariable) {
                $varName = $node->var->name;
                // 如果右边的值里有外部输入,那就记录下来
                // 注意:这里为了简化,只判断直接赋值。实际上需要递归分析右边。
                // 假设右边有 $varName,我们要去递归找它
            }
        }

        // 3. 核心逻辑:字符串拼接检测
        if ($node instanceof NodeExprBinaryOpConcat) {
            // 左边是字符串,右边也是字符串(或者变量)
            // 如果是变量,检查这个变量是否来自外部输入
            $left = $node->left;
            $right = $node->right;

            $isDangerous = false;

            // 检查左边
            if ($left instanceof NodeExprVariable && isset($this->variableSources[$left->name])) {
                $isDangerous = true;
            }

            // 检查右边
            if ($right instanceof NodeExprVariable && isset($this->variableSources[$right->name])) {
                $isDangerous = true;
            }

            // 如果发现了拼接,且源头不可信,直接报警!
            if ($isDangerous) {
                echo "致命错误:SQL注入风险!变量 {$node->right->name} 被拼接到SQL语句中。n";
            }
        }
    }
}

这代码写得虽然简陋,但核心思想已经出来了。AST让我们能够追溯变量的“身世”。我们不再只是看代码表面写了什么,而是看代码实际上“做了什么”。这在PHP这种弱类型语言里尤其重要,因为你很容易把一个整数传进去,结果在后面被当成字符串处理,引发诡异的问题。

第五部分:实战演练——构建一个“重复代码”探测器

程序员都有个毛病,就是喜欢复制粘贴。写着写着,发现两个函数长得一模一样,改一个地方忘改另一个地方。这就是代码腐化。

AST可以帮助我们检测“代码重复”。

原理很简单:把函数体转换成哈希值(比如MD5),然后统计出现频率。如果同一个哈希值出现了两次,恭喜你,你找到了重复代码。

这需要一点技巧。因为函数体里可能有变量名、行号这些随机的属性,我们不能直接用serialize。我们需要忽略这些,只关注逻辑结构。

class DuplicateCodeDetector extends NodeVisitorAbstract
{
    private $functions = []; // 存储所有函数
    private $duplicates = []; // 存储重复项

    public function enterNode(Node $node)
    {
        if ($node instanceof NodeStmtFunction_) {
            // 提取函数体节点
            $body = $node->stmts;

            // 生成唯一指纹
            // 这里我们用一个非常粗暴的方法:节点序列化
            // 在生产环境中,你需要写一个专门的Hasher来忽略变量名等无关紧要的东西
            $hash = md5(serialize($body));

            if (isset($this->functions[$hash])) {
                $this->duplicates[$hash][] = $node;
            } else {
                $this->functions[$hash] = [];
            }
        }
    }

    public function leaveNode(Node $node)
    {
        // 扫描完一轮后,检查是否有重复
        if ($node instanceof NodeStmtNamespace_) {
            foreach ($this->duplicates as $list) {
                if (count($list) > 1) {
                    echo "发现重复代码块!n";
                    foreach ($list as $func) {
                        echo "- 在函数 {$func->name} 中发现n";
                    }
                }
            }
        }
    }
}

通过这种方式,你甚至可以检测更高级的东西,比如“未使用的参数”。AST能精准地告诉你,某个函数声明了 $x,但整个函数里 echo $x; 都没有出现,这就是垃圾代码。

第六部分:如何把这东西变成一个真正的工具?

光有一个类是不够的。你想要一个像 phpcs(PHP Code Sniffer)或者 phpstan(PHP静态分析工具)那样的工具,你还得解决以下问题:

  1. 配置文件:用户不想天天改代码,他们希望配置一个 ruleset.xml.phpstan.neon,然后运行一个命令就能检查整个项目。
  2. 插件系统:不同的团队有不同的规矩。有的团队不允许 echo,有的团队要求所有函数必须有返回值。你需要一个机制让用户轻松添加自己的 Visitor
  3. 报告生成:分析完了不能只打印到控制台。你得生成一个漂亮的HTML报告,或者JSON格式的结果,方便CI/CD(持续集成/持续部署)流水线去调用。

这里简单给个架构图,画在脑子里就行:

  • CLI入口:接收文件路径。
  • Loader:读取配置文件,加载所有定义的 Visitor 插件。
  • Parser:把PHP文件转成AST。
  • Traverser:把AST塞进所有 Visitor 里跑一圈。
  • Reporter:收集所有报错,生成HTML。

举个CI/CD的集成例子,在你的 composer.json 里写个脚本:

"scripts": {
    "analyze": "php bin/runner.php --config .analyzer.json"
}

然后,当开发人员提交代码时,CI服务器自动运行这个命令。如果有错误,构建就失败。这比写完代码再找别人Review要靠谱得多,因为这是机器在24小时不间断地盯着你的代码。

第七部分:避坑指南——不要试图搞太复杂

我知道你们这群资深程序员,脑子里已经开始想构建一个“全能PHP操作系统”了。但是,听我一句劝:

AST静态分析是有局限性的。

  1. 控制流分析太难了:代码里有 ifswitchtry-catch,变量在不同的分支里可能完全不一样。AST只是静态的树,它不懂执行顺序。如果你想做到100%准确的类型推断,难度堪比造火箭。
  2. 反射与运行时:如果你的代码里用了 call_user_func_array 动态调用方法,或者使用了Trait,AST有时候会晕头转向。
  3. 性能:如果你写了一个特别复杂的遍历器,把整个项目遍历几千次,那你的电脑风扇得转出交响乐来。

所以,不要试图用AST去干所有事。用它来发现那些显而易见的逻辑错误、安全隐患、代码坏味道,这已经足够强大了。至于复杂的业务逻辑推断,还是留给运行时的PHP解释器吧。

结语:代码洁癖患者的福音

好了,今天的讲座就到这里。

我们讲了什么是AST,如何解析它,如何遍历它,以及如何用它来抓“魔术数字”、防“SQL注入”、找“重复代码”。

你会发现,当你真正掌握了AST,你就不再是一个只会写代码的码农,而是一个架构师。你开始思考代码的结构,思考数据的流向,思考如何让机器替你干活。

AST就是你的瑞士军刀。当你面对一团乱麻的旧代码时,不要慌,拿出这把刀,轻轻划开,你会发现里面的逻辑其实很清晰。

愿你们的代码里没有魔术数字,没有SQL注入,没有重复代码,只有优雅、健壮、且经得起AST拷打的完美实现。

现在,去把你的第一个Scanner写出来吧,别让Bug跑进生产环境!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注