缘起

最近工作上接到一个任务,要把项目代码的控制器中所有没有判断过权限的public方法找出来。我们的控制器里有一个统一的checkAllow方法来进行权限判断。所以任务可以归结为找到所有代码里没有调用过这个方法的public方法及其所在的类。

有了这个思路,我首先想到的就是用php中自带的token_get_all方法获取到代码的分词,然后进行分析。然而这个函数太底层了,什么时候进入方法,什么时候退出方法等细节都需要繁琐的判断,而且还有标点、括号、空格之类无用信息的干扰。比如最简单的<?php echo "Hi", "World";分词结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Array
(
    [0] => Array
        (
            [0] => 379
            [1] => <?php 
            [2] => 1
        )

    [1] => Array
        (
            [0] => 328
            [1] => echo
            [2] => 1
        )

    [2] => Array
        (
            [0] => 382
            [1] =>  
            [2] => 1
        )

    [3] => Array
        (
            [0] => 323
            [1] => 'Hi'
            [2] => 1
        )

    [4] => ,
    [5] => Array
        (
            [0] => 382
            [1] =>  
            [2] => 1
        )

    [6] => Array
        (
            [0] => 323
            [1] => 'World'
            [2] => 1
        )

    [7] => ;
)

看着就头疼了,更别说真实的项目中一个文件会有成百上千行,那分析出来的数组元素得以万为单位计数。

显然,通过自带的方法完成这个任务不太现实。于是,我开始搜索一些第三方的用于代码静态分析的库,最终找到了nikic大神写的PHP-Parser这个库。

原理

本质上,这个库也是调用token_get_all方法来获取代码分词的,但是在此基础之上,生成了抽象语法树(Abstract Syntax Tree)。比如<?php echo "Hi", "World";代码生成的语法树就是这个样子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
array(
    0: Stmt_Echo(
        exprs: array(
            0: Scalar_String(
                value: Hi
            )
            1: Scalar_String(
                value: World
            )
        )
    )
)

即使还没有开始学习如何使用这个库的api,看到这样清晰的结构,心理负担也比面对直接使用token_get_all得到的结果轻松不少。

入门

其实看到语法树的结构,我们的思路就比较清晰了,无非就是遍历找到所有的public方法,再看这个方法的代码里有没有调用过checkAllow方法。所以接下来的思路就是看看这个库到底给我们提供了哪些高端大气的方法去遍历这个树。

我们以一个最简单的接近真实项目文件的例子进行探索。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
// simple.php
namespace App\Controller;
class Test_Controller extends Base_Controller {

    public function __construct() {
        $this->checkAllow('xxx:read');
    }

    /**
     * 有一些注释
     */
    public function test() {
        $this->checkAllow('xxx:write');
        $this->callPrivate();
    }

    private function callPrivate() {
        echo 'in private';
    }
}

相信所有人最先有的冲动就是看看这段代码生成的语法树到底长啥样,我们就一起来看看吧。

composer require nikic/php-parser安装,之后require autoload文件这些基本的东西不就用多说了吧?

我们先来打印一下解析出的语法树:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php
use PhpParser\Error;
use PhpParser\NodeDumper;
use PhpParser\ParserFactory;

$code = file_get_contents('./simple.php');
// 1. 首先创建一个parser对象。这个对象是可以复用解析不同代码的。
$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
// 2. 开始解析代码
try {
    $stmts = $parser->parse($code); // 对代码解析得到语法树的节点数组

    // 3. 打印语法树
    $nodeDumper = new NodeDumper();
    echo $nodeDumper->dump($stmts);
} catch (Error $e) {
    echo "Parse Error: ", $e->getMessage();
}

结果如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
array(
    0: Stmt_Namespace(
        name: Name(
            parts: array(
                0: App
                1: Controller
            )
        )
        stmts: array(
            0: Stmt_Class(
                flags: 0
                name: Identifier(
                    name: Test_Controller
                )
                extends: Name(
                    parts: array(
                        0: Base_Controller
                    )
                )
                implements: array(
                )
                stmts: array(
                    0: Stmt_ClassMethod(
                        flags: MODIFIER_PUBLIC (1)
                        byRef: false
                        name: Identifier(
                            name: __construct
                        )
                        params: array(
                        )
                        returnType: null
                        stmts: array(
                            0: Stmt_Expression(
                                expr: Expr_MethodCall(
                                    var: Expr_Variable(
                                        name: this
                                    )
                                    name: Identifier(
                                        name: checkAllow
                                    )
                                    args: array(
                                        0: Arg(
                                            value: Scalar_String(
                                                value: xxx:read
                                            )
                                            byRef: false
                                            unpack: false
                                        )
                                    )
                                )
                            )
                        )
                    )
                    1: Stmt_ClassMethod(
                        flags: MODIFIER_PUBLIC (1)
                        byRef: false
                        name: Identifier(
                            name: tests
                        )
                        params: array(
                        )
                        returnType: null
                        stmts: array(
                            0: Stmt_Expression(
                                expr: Expr_MethodCall(
                                    var: Expr_Variable(
                                        name: this
                                    )
                                    name: Identifier(
                                        name: checkAllow
                                    )
                                    args: array(
                                        0: Arg(
                                            value: Scalar_String(
                                                value: xxx:write
                                            )
                                            byRef: false
                                            unpack: false
                                        )
                                    )
                                )
                            )
                            1: Stmt_Expression(
                                expr: Expr_MethodCall(
                                    var: Expr_Variable(
                                        name: this
                                    )
                                    name: Identifier(
                                        name: callPrivate
                                    )
                                    args: array(
                                    )
                                )
                            )
                        )
                    )
                    2: Stmt_ClassMethod(
                        flags: MODIFIER_PRIVATE (4)
                        byRef: false
                        name: Identifier(
                            name: callPrivate
                        )
                        params: array(
                        )
                        returnType: null
                        stmts: array(
                            0: Stmt_Echo(
                                exprs: array(
                                    0: Scalar_String(
                                        value: in private
                                    )
                                )
                            )
                        )
                    )
                )
            )
        )
    )
)

看到Stmt_NamespaceStmt_ClassStmt_ClassMethod之类的命名,即使不继续看文档,我们也可以清晰地了解每个节点是干什么的。每个节点是一个对象,类名表示自己的类型,name属性表示自己的名字,stmts属性表示包含的子节点。

PHP中大约有140种不同类型的节点,在库中有各自对应的类。但是这里我们只关心例子中涉及到的这几个节点类型。

了解到这些,我们其实就可以开始搞事情了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
// 注意:这些库里的节点类为了避免与php中原生的类或关键字冲突,加了下划线
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Namespace_;

// 先定义几个分别用来解析不同节点的函数
function parseNamespace(Namespace_ $namespace) {
    echo "enter namespace: {$namespace->name}\n";
    echo "parsing classes\n";
    foreach ($namespace->stmts as $stmt) {
        if ($stmt->getType() === 'Stmt_Class') {
            parseClass($stmt);
        }
    }
    echo "exit namespace: {$namespace->name}\n";
}

function parseClass(Class_ $class) {
    echo "\tenter class: {$class->name}\n";
    echo "\tparsing methods\n";
    foreach ($class->stmts as $stmt) {
        if ($stmt->getType() === 'Stmt_ClassMethod') {
            parseMethod($stmt);
            echo "\n";
        }
    }
    echo "\texit class: {$class->name}\n";
}

function parseMethod(ClassMethod $method) {
    echo "\t\tenter method: {$method->name}\n";
    echo "\t\tmodifier: {$method->flags}\n";
    echo "\t\texit method: {$method->name}\n";
}

// 省略不相关代码
$stmts = $parser->parse($code);
foreach ($stmts as $stmt) {
    if ($stmt->getType() === 'Stmt_Namespace') {
        parseNamespace($stmt);
    }
}

打印结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
enter namespace: App\Controller
parsing classes
    enter class: Test_Controller
    parsing methods
        enter method: __construct
        modifier: 1   // 通过 modifier 可以进而判断这个方法是否是public
        exit method: __construct

        enter method: test
        modifier: 1
        exit method: test

        enter method: callPrivate
        modifier: 4
        exit method: callPrivate

    exit class: Test_Controller
exit namespace: App\Controller

前面的例子中之所以包含了__construct方法,是因为有些类在构造方法中统一检查了读权限,在需要写的public方法中检查写权限。

分析到了方法这一层,接下来就要分析具体的代码语句了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<?php
use PhpParser\Node\Expr\MethodCall;
// 修改前面的函数
function parseMethod(ClassMethod $method) {
    echo "\t\tenter method: {$method->name}\n";
    echo "\t\tmodifier: {$method->flags}\n";

    if ($method->isPublic()) {
        echo "\t\tis public, parse expressions\n";
        foreach ($method->stmts as $stmt) {
            $checked = parseExpression($stmt);
            if ($checked) {
                echo "\t\tBingo!!!!! called checkAllow\n";
                break;
            }
        }
    }

    echo "\t\texit method: {$method->name}\n";
}

function parseExpression(Expression $exp) {
    // Stmt_Expression节点只有一个expr属性表示具体的表达式类型
    // 可想而知表达式有很多种,具体的可以看看PhpParser\Node\Expr下的各种类。
    // 我们这里需要的是方法调用类型,也就是MethodCall这个
    if ($exp->expr instanceof MethodCall) {
        // MethodCall的name属性返回Identifier类
        // Identifier类的name属性才是真正的字符串表示的方法名
        if ($exp->expr->name->name === 'checkAllow') {
            return true;
        }
    }
    return false;
}

打印结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
enter namespace: App\Controller
parsing classes
    enter class: Test_Controller
    parsing methods
        enter method: __construct
        modifier: 1
        is public, parse expressions
        Bingo!!!!! called checkAllow
        exit method: __construct

        enter method: test
        modifier: 1
        is public, parse expressions
        Bingo!!!!! called checkAllow
        exit method: test

        enter method: callPrivate
        modifier: 4
        exit method: callPrivate

    exit class: Test_Controller
exit namespace: App\Controller

至此,我们甚至不需要继续深入研究这个库,就已经可以完成我的任务目标了。

进阶

就算任务可以完成了,也不能刚入门就调头走了呀。所以我们还要继续看看这个库有什么高端大气的功能。

解析出语法树,自然而然就要遍历这个语法树。之前的例子中,我们是以遍历数组的方式进行的,并且我们在遍历之前其实是知道代码结构的。在更广泛的应用当中,我们可能根本不知道代码结构是什么样子。接下来我们看看库里提供的更具抽象性的遍历方法。

1
2
3
4
<?php
use PhpParser\NodeTraverser;
$traverser = new NodeTraverser();   // 新建一个节点遍历器
$traverser->traverse($stmts);       // 遍历所有节点

嗯,就这么简单,我们遍历了所有的节点。然而并没有什么用,我们什么也没做。这是因为只有遍历器是不够的,我们还需要为遍历器添加一个或多个访问者(NodeVisitor)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<?php
// 访问者类必须实现NodeVisitor接口,接口的定义很简单,只有四个方法,作用也很直白。
// 在遍历的过程中,甚至可以替换当前节点,从而生成新的代码。
// 发散一下思维,我们是不是可以用这个特性为代码加入自动打日志之类的功能?
// 所有方法的返回值如果是null,表示不对节点进行替换。其它返回值略有不同。
use PhpParser\NodeVisitor;
Class MyNodeVisitor implements NodeVisitor {
    // 开始遍历时调用,可以做一些初始化的工作,或者替换掉整个节点树
    public function beforeTraverse(array $nodes) {
        echo "before\n"
        return null;
    }
    // 根据返回值不同,可以停止整个遍历过程或跳过子节点的遍历
    public function enterNode(Node $node) {
        return null;
    }
    // 根据返回值不同,可以停止整个遍历过程、删除当前节点或向父级节点添加后续子节点
    public function leaveNode(Node $node) {
        return null;
    }
    // 结束遍历时调用,可以做一些扫尾工作,或者替换掉整个节点树
    public function afterTraverse(array $nodes) {
        echo "after\n";
        return null;
    }
}

根据我们的任务,整理一下思路。我们主要的工作应该都在enterNode方法里,在这里需要根据节点的类型进行不同的操作,如果是public方法我们就继续判断代码中有没有检查权限的方法调用。如果不是public方法我们就可以直接跳过遍历。而像命名空间之类无关的节点我们也不再需要关心。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
<?php
use PhpParser\NodeVisitorAbstract;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Expression;
// 如果不需要在NodeVisitor的4个方法里都执行操作,可以直接继承NodeVisitorAbstract类,只实现自己需要的方法。
Class MyNodeVisitor extends NodeVisitorAbstract {

    private $foundCheckAllow = false; // 是否找到了权限检查方法
    private $inMethod        = false; // 是否进入到了方法中

    public function enterNode(Node $node) {
        // 之所以用if而不是switch,是为了IDE能提示相应类的方法,哈哈
        if ($node instanceof Class_) {
            printf("enter class %s\n", $node->name->name);
        } elseif ($node instanceof ClassMethod) {
            $this->foundCheckAllow = false;
            $this->inMethod        = true;
            printf("\tenter method %s\n", $node->name->name);
            if ($node->isPublic()) {
                printf("\tis public\n");
            } else {
                // 非public方法,直接跳过
                printf("\tnot public, pass\n");
                return NodeTraverser::DONT_TRAVERSE_CHILDREN;
            }
        } else {
            // 如果在方法里已经找到了调用,就不需要再遍历之后的节点了
            // 遗憾的是只能跳过子节点的遍历,而不能跳过当前层级后续节点的遍历
            if ($this->inMethod && $this->foundCheckAllow) {
                printf("\tno need search %s\n", $node->getType());
                return NodeTraverser::DONT_TRAVERSE_CHILDREN;
            }

            if ($node instanceof Expression) {
                if ($node->expr instanceof MethodCall && $node->expr->name->name === 'checkAllow') {
                    $this->foundCheckAllow = true;
                    printf("\tBingo!!!!!!! called checkAllow!!!\n");
                    return NodeTraverser::DONT_TRAVERSE_CHILDREN;
                }
            }
        }

        return null;
    }

    public function leaveNode(Node $node) {
        if ($node instanceof Class_) {
            printf("exit class %s\n", $node->name->name);
        } elseif ($node instanceof ClassMethod) {
            $this->inMethod = false;
            printf("\texit method %s, ", $node->name->name);
            if ($this->foundCheckAllow) {
                printf("this method has checkAllow method\n\n");
            } else {
                printf("this method does't have checkAllow method\n\n");
            }
        }
        return null;
    }
}
$traverser = new NodeTraverser();
$traverser->addVisitor(new MyNodeVisitor());
$traverser->traverse($stmts);

打印结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
enter class Test_Controller
    enter method __construct
    is public
    Bingo!!!!!!! called checkAllow!!!
    exit method __construct, this method has checkAllow method

    enter method test
    is public
    Bingo!!!!!!! called checkAllow!!!
    no need search Stmt_Expression
    exit method test, this method has checkAllow method

    enter method callPrivate
    not public, pass
    exit method callPrivate, this method does't have checkAllow method

exit class Test_Controller

至此,我们用高端大气的遍历器完成了之前需要手写循环的任务。对比一下两种实现方式,可以发现各有优缺点。手写循环虽然繁琐,但是我们可以筛选自己关心的节点,跳过无关节点。而通过遍历器的话,就会一鼓脑地遍历所有节点。所以在enterNode里就要有不少类型的判断。

有没有更简便的方法呢?哈哈,我都感觉到麻烦了,nikic大神能想不到吗?是时候亮出另一个武器了NodeFinder

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php
$nodeFinder = new NodeFinder();
// 找到所有的方法
$methods    = $nodeFinder->findInstanceOf($stmts, ClassMethod::class);
// 我们再手工循环一下,当然也可以用遍历器。我个人觉得手工方便一点。
foreach ($methods as $method) {
    $hasCheck = false;
    if (!$method->isPublic()) {
        continue;
    }
    foreach ($method->stmts as $stmt) {
        if ($stmt instanceof Expression) {
            if ($stmt->expr instanceof MethodCall && $stmt->expr->name->name === 'checkAllow') {
                printf("Bingo!!!!!!! method %s called checkAllow!!!\n", $method->name->name);
                $hasCheck = true;
                break;
            }
        }
    }
    if (!$hasCheck) {
        printf("method %s doesn't call checkAllow!!!\n", $method->name->name);
    }
}

是不是感觉比之前用遍历器省事了很多?哈哈,其实本质上NodeFinder也是利用了遍历器。我们完全可以自己实现这个功能,无非就是在enterNode中判断一下节点类型,放到数组里,遍历完之后返回。

后续

一篇文章肯定无法介绍整个库的方方面面,PHP-Parser还有很多高级的特性,比如命名解析、代码合适化、常量解析、JSON语法树,甚至可以用程序动态生成代码等等。

这些高级的特性我暂时没有适用场景,就先不去深入了。如果你感兴趣的话,直接去啃官方文档吧。

参考

官方文档