Aop 設計 - 使用 PHP-parser 重寫 PHP 類

李銘昕發表於2018-07-14

最近一直在研究Swoft框架,框架核心當然是Aop切面程式設計,所以想把這部分的心得記下來,以供後期查閱。

Swoft新版的Aop設計建立在PHP Parser上面。所以這片文章,主要介紹一下PHP Parser在Aop程式設計中的使用。

簡單的來講,我們想在某些類的方法上進行埋點,比如下面的Test類。

class Test {
    public function get() {
            // do something
        }
}

我們想讓它的get方法變成以下的樣子

class Test {
    public function get() {
            // do something before
            // do something
                // do something after
        }
}

最簡單的設計就是,我們使用parser生成對應的語法樹,然後主動修改方法體內的邏輯。

接下來,我們就是用PHP Parser來搞定這件事。

首先我們先定一個ProxyVisitor。Visitor有四個方法,其中

  1. beforeTraverse()方法用於遍歷之前,通常用來在遍歷前對值進行重置。
  2. afterTraverse()方法和(1)相同,唯一不同的地方是遍歷之後才觸發。
  3. enterNode()和leaveNode()方法在對每個節點訪問時觸發。
<?php
namespace App\Aop;

use PhpParser\NodeVisitorAbstract;
use PhpParser\Node;

class ProxyVisitor extends NodeVisitorAbstract
{
    public function leaveNode(Node $node)
    {
    }

    public function afterTraverse(array $nodes)
    {
    }
}

我們要做的就是重寫leaveNode,讓我們遍歷語法樹的時候,把類方法裡的邏輯重置掉。另外就是重寫afterTraverse方法,讓我們遍歷結束之後,把我們的AopTrait扔到類裡。AopTrait就是我們賦予給類的,切面程式設計的能力。

首先,我們先建立一個測試類,來看看parser生成的語法樹是什麼樣子的

namespace App;

class Test
{
    public function show()
    {
        return 'hello world';
    }
}

use PhpParser\ParserFactory;
use PhpParser\NodeDumper;

$file = APP_PATH . '/Test.php';
$code = file_get_contents($file);

$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);

$dumper = new NodeDumper();
echo $dumper->dump($ast) . "\n";

結果樹如下

array(
    0: Stmt_Namespace(
        name: Name(
            parts: array(
                0: App
            )
        )
        stmts: array(
            0: Stmt_Class(
                flags: 0
                name: Identifier(
                    name: Test
                )
                extends: null
                implements: array(
                )
                stmts: array(
                    0: Stmt_ClassMethod(
                        flags: MODIFIER_PUBLIC (1)
                        byRef: false
                        name: Identifier(
                            name: show
                        )
                        params: array(
                        )
                        returnType: null
                        stmts: array(
                            0: Stmt_Return(
                                expr: Scalar_String(
                                    value: hello world
                                )
                            )
                        )
                    )
                )
            )
        )
    )
)

語法樹的具體含義,我就不贅述了,感興趣的同學直接去看一下PHP Parser的文件吧。(其實我也沒全都看完。。。大體知道而已,哈哈哈)

接下來重寫我們的ProxyVisitor

<?php
namespace App\Aop;

use PhpParser\NodeVisitorAbstract;
use PhpParser\Node;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Name;
use PhpParser\Node\Param;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Return_;
use PhpParser\Node\Stmt\TraitUse;
use PhpParser\NodeFinder;

class ProxyVisitor extends NodeVisitorAbstract
{
    protected $className;

    protected $proxyId;

    public function __construct($className, $proxyId)
    {
        $this->className = $className;
        $this->proxyId = $proxyId;
    }

    public function getProxyClassName(): string
    {
        return \basename(str_replace('\\', '/', $this->className)) . '_' . $this->proxyId;
    }

    public function getClassName()
    {
        return '\\' . $this->className . '_' . $this->proxyId;
    }

    /**
     * @return \PhpParser\Node\Stmt\TraitUse
     */
    private function getAopTraitUseNode(): TraitUse
    {
        // Use AopTrait trait use node
        return new TraitUse([new Name('\App\Aop\AopTrait')]);
    }

    public function leaveNode(Node $node)
    {
        // Proxy Class
        if ($node instanceof Class_) {
            // Create proxy class base on parent class
            return new Class_($this->getProxyClassName(), [
                'flags' => $node->flags,
                'stmts' => $node->stmts,
                'extends' => new Name('\\' . $this->className),
            ]);
        }
        // Rewrite public and protected methods, without static methods
        if ($node instanceof ClassMethod && !$node->isStatic() && ($node->isPublic() || $node->isProtected())) {
            $methodName = $node->name->toString();
            // Rebuild closure uses, only variable
            $uses = [];
            foreach ($node->params as $key => $param) {
                if ($param instanceof Param) {
                    $uses[$key] = new Param($param->var, null, null, true);
                }
            }
            $params = [
                // Add method to an closure
                new Closure([
                    'static' => $node->isStatic(),
                    'uses' => $uses,
                    'stmts' => $node->stmts,
                ]),
                new String_($methodName),
                new FuncCall(new Name('func_get_args')),
            ];
            $stmts = [
                new Return_(new MethodCall(new Variable('this'), '__proxyCall', $params))
            ];
            $returnType = $node->getReturnType();
            if ($returnType instanceof Name && $returnType->toString() === 'self') {
                $returnType = new Name('\\' . $this->className);
            }
            return new ClassMethod($methodName, [
                'flags' => $node->flags,
                'byRef' => $node->byRef,
                'params' => $node->params,
                'returnType' => $returnType,
                'stmts' => $stmts,
            ]);
        }
    }

    public function afterTraverse(array $nodes)
    {
        $addEnhancementMethods = true;
        $nodeFinder = new NodeFinder();
        $nodeFinder->find($nodes, function (Node $node) use (
            &$addEnhancementMethods
        ) {
            if ($node instanceof TraitUse) {
                foreach ($node->traits as $trait) {
                    // Did AopTrait trait use ?
                    if ($trait instanceof Name && $trait->toString() === '\App\Aop\AopTrait') {
                        $addEnhancementMethods = false;
                        break;
                    }
                }
            }
        });
        // Find Class Node and then Add Aop Enhancement Methods nodes and getOriginalClassName() method
        $classNode = $nodeFinder->findFirstInstanceOf($nodes, Class_::class);
        $addEnhancementMethods && array_unshift($classNode->stmts, $this->getAopTraitUseNode());
        return $nodes;
    }
}

trait AopTrait
{
    /**
     * AOP proxy call method
     *
     * @param \Closure $closure
     * @param string   $method
     * @param array    $params
     * @return mixed|null
     * @throws \Throwable
     */
    public function __proxyCall(\Closure $closure, string $method, array $params)
    {
        return $closure(...$params);
    }
}

當我們拿到節點是類時,我們重置這個類,讓新建的類繼承這個類。
當我們拿到的節點是類方法時,我們使用proxyCall來重寫方法。
當遍歷完成之後,給類加上我們定義好的AopTrait。

接下來,讓我們執行以下第二個DEMO

use PhpParser\ParserFactory;
use PhpParser\NodeTraverser;
use App\Aop\ProxyVisitor;
use PhpParser\PrettyPrinter\Standard;

$file = APP_PATH . '/Test.php';
$code = file_get_contents($file);

$parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse($code);

$traverser = new NodeTraverser();
$className = 'App\\Test';
$proxyId = uniqid();
$visitor = new ProxyVisitor($className, $proxyId);
$traverser->addVisitor($visitor);
$proxyAst = $traverser->traverse($ast);
if (!$proxyAst) {
    throw new \Exception(sprintf('Class %s AST optimize failure', $className));
}
$printer = new Standard();
$proxyCode = $printer->prettyPrint($proxyAst);

echo $proxyCode;

結果如下

namespace App;

class Test_5b495d7565933 extends \App\Test
{
    use \App\Aop\AopTrait;
    public function show()
    {
        return $this->__proxyCall(function () {
            return 'hello world';
        }, 'show', func_get_args());
    }
}

這樣就很有趣了,我們可以賦予新建的類一個新的方法,比如getOriginClassName。然後我們在proxyCall中,就可以根據getOriginClassName和$method拿到方法的精確ID,在這基礎之上,我們可以做很多東西,比如實現一個方法快取。

我這裡呢,只給出一個最簡單的示例,就是當返回值為string的時候,加上個歎號。

修改一下我們的程式碼

namespace App\Aop;

trait AopTrait
{
    /**
     * AOP proxy call method
     *
     * @param \Closure $closure
     * @param string   $method
     * @param array    $params
     * @return mixed|null
     * @throws \Throwable
     */
    public function __proxyCall(\Closure $closure, string $method, array $params)
    {
        $res = $closure(...$params);
        if (is_string($res)) {
            $res .= '!';
        }
        return $res;
    }
}

以及在我們的呼叫程式碼後面加上以下程式碼

eval($proxyCode);

$class = $visitor->getClassName();
$bean = new $class();

echo $bean->show();

結果當然和我們預想的那樣,列印出了

hello world!

以上設計來自Swoft開發組 swoft-component,我只是個懶惰的搬運工,有興趣的可以去看一下。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

相關文章