PHP Parser 簡介和應用 - 為你的程式碼自動補全單元測試

Kamicloud發表於2018-12-31

簡介

PHP Parser是由 nikic 開發的一款php抽象語法樹(AST)解析工具。PHP Parser同時兼顧介面易用,結構簡潔,工具鏈完善等諸多優點。在工程上,普遍使用PHP Paser生成模板程式碼,或使用其生成的抽象語法樹進行靜態分析。

抽象語法樹 VS 反射

反射

反射可以在程式執行時動態解析類和方法的結構,基於反射獲得的結構,程式可以動態地訪問類和類的屬性方法,也可以用於建立類例項。相對於抽象語法樹,反射取得的結構能夠更清晰地反映類結構,因而常用於框架實現路由分發等功能。

抽象語法樹

抽象語法樹是程式語言原始碼經過語法分析和詞法分析後獲得的解析結構。除反射能夠獲取到的資訊外,還包含了註釋、方法與函式的邏輯結構,我們可以認為抽象語法樹與原始碼是等價的。

PHP Parser詳解

功能入口

PhpParser\ParserFactory::create(int $kind, Lexer $lexer = null, array $parserOptions = []): PhpParser\Parser

建立解析器,$kind One of ::PREFER_PHP7, ::PREFER_PHP5, ::ONLY_PHP7 or ::ONLY_PHP5

PhpParser\Parser::parse(string $code, ErrorHandler $errorHandler = null): Node\Stmt[]|null

所有解析器都實現了該方法,傳入程式碼並返回抽象語法樹。

PrettyPrinter\Standard::prettyPrintFile($ast): string

用於將抽象語法樹轉化成程式碼。
file

名稱空間

PhpParser\Node

包含抽象語法樹的所有節點,程式碼中的變數宣告、類引用、邏輯表示式都可以用對應的Node表示。

PhpParser\Node\Stmt

包含表示式節點,如表示namespace的Namespace、class的Class、類方法的ClassMethod等。在後面示例中我會演示如何解析和修改表示式。

PhpParser\Builder

該名稱空間下包含生成節點的工廠類,通過getNode方法可以獲得對應的節點。

應用示例

一、解析和生成原始碼

詳見官方示例

二、為程式碼自動新增測試

需求描述

假設我們在Service層使用了一系列公共靜態方法(public static function)提供服務,我們希望確保每一個方法都有單元測試,所以需要查詢Service中存在的方法,並生成測試類和測試方法。當然,由於每個Service中包含很多方法,當增加方法時,我們不希望每次手動把舊的測試轉移過來,所以需要增量新增測試

輸出結果(新建測試)
<?php

namespace Test\Unit;

use Tests\TestCase;
class ArticleServiceTest extends TestCase
{
    public function testGetArticles()
    {
    }
    public function testGetArticle()
    {
    }
    public function testCreateArticle()
    {
    }
}
輸出結果(增量新增)
<?php

namespace Test\Unit;

use Tests\TestCase;
class ArticleServiceTest extends TestCase
{
    public function testGetArticles()
    {
        // 假設這個測試已存在
        // 生成的程式碼將會保留這一段註釋
    }
    public function testGetArticle()
    {
    }
    public function testCreateArticle()
    {
    }
}
程式程式碼
use Illuminate\Console\Command;
use PhpParser\Builder\Method;
use PhpParser\Builder\Use_;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Namespace_;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter;
use Exception;
/** 略 */
    public function handle()
    {
        // 解析需測試檔案
        $namespaces = $this->parseFile(app_path("Http/Services/V1/ArticleService.php"));
        array_walk($namespaces, function ($namespace) {
            [$namespace, $classes] = $namespace;

            // 解析檔案中的類
            foreach ($classes as $class) {
                $className = $class->name->name;
                $testClassName = "{$className}Test";
                $testFilePath = base_path("tests/Unit/{$testClassName}.php");

                $classMethodNames = array_filter(array_map(function ($bodyPart) {
                    if ($bodyPart instanceof ClassMethod && $bodyPart->isPublic() && $bodyPart->isStatic()) {
                        $methodName = $bodyPart->name->name;
                        return 'test' . strtoupper($methodName[0]) . substr($methodName, 1);
                    }
                    return null;
                }, $class->stmts));

                if (file_exists($testFilePath)) {
                    // 讀取已新增的測試
                    [$testNamespace, $testClassMethodNames, $testClass] = $this->getExistsTest($testFilePath);
                    $todoMethodNames = array_diff($classMethodNames, $testClassMethodNames);
                } else {
                    // 建立空的測試類
                    $testNamespace = $this->prepareTestFile('Test\Unit');
                    $todoMethodNames = $classMethodNames;
                    $testClass = new Class_($testClassName);
                    $testClass->extends = new Node\Name('TestCase');
                }

                $testNamespace->stmts = array_filter($testNamespace->stmts, function ($stmt) {
                    return !($stmt instanceof Class_);
                });

                $testClass->stmts = array_merge($testClass->stmts, array_map(function ($methodName) {
                    $method = new Method($methodName);
                    $method->makePublic();

                    return $method->getNode();
                }, $todoMethodNames));

                $testNamespace->stmts[] = $testClass;

                $prettyPrinter = new PrettyPrinter\Standard;
                echo $prettyPrinter->prettyPrintFile([$testNamespace]);
            }
        });
    }

    /**
     * 解析已有的測試
     *
     * @param $testFilePath
     * @return array
     * @throws Exception
     */
    protected function getExistsTest($testFilePath)
    {
        $testClasses = $this->parseFile($testFilePath);
        if (count($testClasses) !== 1) {
            throw new Exception('測試檔案需有且僅有一個PHP片段');
        }
        [$testNamespace, $testClasses] = $testClasses[0];
        if (count($testClasses) !== 1) {
            throw new Exception('測試檔案需有且僅有一個類');
        }
        $testClass = $testClasses[0];

        $testClassMethodNames = array_filter(array_map(function ($bodyPart) {
            if ($bodyPart instanceof ClassMethod && $bodyPart->isPublic()) {
                return $bodyPart->name->name;
            }
            return null;
        }, $testClass->stmts));

        return [$testNamespace, $testClassMethodNames, $testClass];
    }

    protected function prepareTestFile($namespace)
    {
        $namespace = new \PhpParser\Builder\Namespace_($namespace);
        $namespace->addStmt(new Use_('Tests\TestCase', Node\Stmt\Use_::TYPE_NORMAL));

        return $namespace->getNode();
    }

    protected function parseFile($path)
    {
        $code = file_get_contents($path);
        $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
        $ast = $parser->parse($code);
        if (!count($ast)) {
            throw new Exception('未發現php程式碼');
        }
        $namespaces = $this->parsePHPSegments($ast);

        return $namespaces;
    }

    protected function parsePHPSegments($segments)
    {
        $segments = array_filter($segments, function ($segment) {
            return $segment instanceof Namespace_;
        });

        $segments = array_map(function (Namespace_ $segment) {
            return [$segment, $this->parseNamespace($segment)];
        }, $segments);

        return $segments;
    }

    protected function parseNamespace(Namespace_ $namespace)
    {
        $classes = array_values(array_filter($namespace->stmts, function ($class) {
            return $class instanceof Class_;
        }));

        return $classes;
    }

結語

感謝nikic的傑作,我們可以通過PHP Paser便捷地解析和修改PHP程式碼,具備了超程式設計能力。在此基礎上,我們可以實現靜態程式碼分析(SCA)、模板生成程式碼等工具。使用這些工具開發者可以排查潛在BUG、優化專案程式碼,減少重複勞動。

PHP Paser算是作者最喜歡的PHP包,喜歡程度甚至高於Laravel這樣的全棧框架。本文舉了一個簡單的自動建立單元測試的例子,除這以外還可以實現更多更好用的功能;第一次寫教程,文筆不是很好,算是拋磚引玉了。

相關文章