How to Automatically Generate PHP code with PHP-Parser

PHP:8.0
nikic/PHP-Parser: 4.12.0

在大型專案中,因為架構極其複雜,所以常會看到有許多由抽象類別、介面實作出來同性質的子物件,比如說 Parser、Extractor 等等。而當依據需求實作出大量又有些微客製化的物件後,如果要一次性調整大量的物件程式碼,比方說修改特定 Variable 的值,就會非常耗費人力。

在這種情況下,我們可以透過 nikic/PHP-Parser 套件達成自動產生 PHP code 的效果。

nikic/PHP-Parser 是一套針對 PHP 設計的 Abstract Syntax Tree(抽象語法樹) 解析工具,除了可以將 PHP code 解析成 AST 之外,也可以透過 AST 產生 PHP code。

Installation

nikic/PHP-Parser 透過 Composer 即可安裝。

php composer.phar require nikic/php-parser

Basic Usage

nikic/PHP-Parser 套件可以分成三個重點:

  • Parse PHP code to AST
  • Visit AST
  • Parse AST to PHP code

這裡舉官方提供的範例,透過實作以上三點,達到動態修改 PHP Function 的功能。

Parse PHP code to AST
require 'vendor/autoload.php';

use PhpParser\Error;
use PhpParser\NodeDumper;
use PhpParser\ParserFactory;

// PHP demo function code
// 這裡的 test function 會執行 var_dump($foo)
$code = <<<'CODE'
<?php

function test($foo)
{
    var_dump($foo);
}
CODE;

// 建立 PHP 7 ParserFactory Parser
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
    // 將 PHP code 轉成 AST
    $ast = $parser->parse($code);
} catch (Error $error) {
    echo "Parse error: {$error->getMessage()}\n";
    return;
}

// Dump 出 AST 結構的字串
// 也可以利用 print_r($ast) 去看各節點物件
$dumper = new NodeDumper;
echo $dumper->dump($ast) . PHP_EOL;
Visit AST
require 'vendor/autoload.php';

use PhpParser\Node;
use PhpParser\NodeDumper;
use PhpParser\Node\Stmt\Function_;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;

// 產生走訪 AST 節點的物件
$traverser = new NodeTraverser();
// 實作走訪時的邏輯(代入 Anonymous Class)
$traverser->addVisitor(new class extends NodeVisitorAbstract {
    public function enterNode(Node $node) {
        // 遇到是 Function 的節點,就將 Function 內容清空
        if ($node instanceof Function_) {
            $node->stmts = [];
        }
    }
});

// 代入原 AST 開始走訪,產生新的 AST
$ast = $traverser->traverse($ast);

// Dump 出 AST 結構
$dumper = new NodeDumper;
echo $dumper->dump($ast) . PHP_EOL;
Parse AST to PHP code
require 'vendor/autoload.php';

use PhpParser\PrettyPrinter;

// 產生輸出 PHP code 的物件
$prettyPrinter = new PrettyPrinter\Standard;
// 將 AST 轉換成 PHP code
echo $prettyPrinter->prettyPrintFile($ast) . PHP_EOL;

Practical Usage

這邊再舉一個可能的應用情境,假設 Parser/ 目錄底下有許多實作完成的 Parser 物件,Label01 ~ Label99,而需求是要在特定幾個 Parser 裡的 authorization 加入義大利的區域。

Label Parser 範例

namespace FallZuBallBall\Parser\Label01Parser;

class Label01Parser
{
    protected $labelName = 'Label01';
    protected $authorization = ['Taiwan' => 'enable', 'Japan' => 'disable'];

    public function getMetaByAuth(): array
    {
        foreach ($this->authorization as $region => $status) {
            // Do Something
        }
    }
}

AuthTool
透過 nikic/PHP-Parser 實作一個處理此需求的物件。

require_once 'vendor/autoload.php';

namespace FallZuBallBall;

use PhpParser\Error;
use PhpParser\NodeDumper;
use PhpParser\ParserFactory;
use PhpParser\Node;
use PhpParser\Node\Expr\ArrayItem;
use PhpParser\Node\Scalar\String_;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitorAbstract;
use PhpParser\PrettyPrinter;

class AuthTool
{
    protected $ast;

    // 設定要 Parse 的 PHP code
    public function setFile($file): AuthTool
    {
        $code = file_get_contents($file);
        $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
        try {
            $this->ast = $parser->parse($code);
        } catch (Error $error) {
            throw new Exception('Parse error:' . $error->getMessage());
        }

        return $this;
    }

    // 可以新增 Auth 資訊到 authorization
    public function setAuth($region, $status): AuthTool
    {
        $traverser = new NodeTraverser();
        // 將傳入的變數代入 Anonymous Class
        $traverser->addVisitor(new class($region, $status) extends NodeVisitorAbstract {
            protected $region;
            protected $status;
            public function __construct($region, $status) {
                $this->region = $region;
                $this->status = $status;
            }

            public function enterNode(Node $node) {
                // 尋找到 PropertyProperty 節點
                if ($node instanceof \PhpParser\Node\Stmt\PropertyProperty) {
                    // 判斷是否為 authorization
                    if ($node->name->name === 'authorization') {
                        // 加入新的 Auth
                        $item = new ArrayItem(new String_($this->status), new String_($this->region));
                        $node->default->items[] = $item;
                    }
                }
            }
        });

        $this->ast = $traverser->traverse($this->ast);

        return $this;
    }

    public function dumpCode(): string
    {
        $prettyPrinter = new PrettyPrinter\Standard;
        return $prettyPrinter->prettyPrintFile($this->ast);
    }
}

執行

$obj = new AuthTool;
$code = $obj->setFile('Parser/Label01Parser.php')
    ->setAuth('Italy', 'disable')
    ->dumpCode();

print_r($code);

產生的結果

namespace FallZuBallBall\Parser\Label01Parser;

class Label01Parser
{
    protected $labelName = 'Label01';
    protected $authorization = ['Taiwan' => 'enable', 'Japan' => 'disable', 'Italy' => 'disable'];
    public function getMetaByAuth() : array
    {
        foreach ($this->authorization as $region => $status) {
            // Do Something
        }
    }
}
Categories: PHP