一点 AST 研究

前言

Laravel 中的表单验证对于我们开发是十分便利的,使用的方式有很多,最常用的就是定义 Request 类:

class DemoRequest extends FormRequest {

    public function rules(): array
    {
        return [
           'id' => ['required','integer'],
           'name' => ['required']
        ];
    }
}

class DemoController extends Controller {

    public function index(DemoRequest $request){
        $id = $request->input('id');
        $name = $request->input('name');
    }
}

前段时间参与过一些 Java 开发,对代码有了更多的思考,比如在当前的 DemoController 中,DemoRequest 中有哪些元素,是不是就像盲盒一样,想知道 $request->input() 能获取哪些合法参数,还得回过头去看看 rules 方法,能不能在控制器中就能够知道 $request 可以提供哪些合法参数呢。

Attribute + 反射

定义一个实体类,rules 通过 Attribute 定义,最终使用反射完成表单验证,控制器直接拿到实体。


#[Attribute]
class Validate {
    public function __construct(protected array $rules){}

    public function getRules(){
        return $this->rules;
    }
}
abstract class AbstractReuest extends FormRequest {

    public function rules(): array
    {
        $ref = new ReflectionClass($this);

        foreach($ref->getProperties() as $property){
            $attributes = $property->getAttributes(Validate::class);
            if(empty($attributes[0])){
                continue;
            }
            /** @var Validate $validate */
            $validate = $attributes[0]->newInstance();

            $rules[$property->getName()] = $validate->getRules();
        }

        return $rules ?? [];
    }

    /**
     * Laravel 提供的,验证通过之后的钩子函数
     *
     * @return void
     */
    protected function passedValidation()
    {
        foreach($this->validationData() as $key => $value){
            $setMethod = 'set'.ucfirst($key);
            if(method_exists($this,$setMethod)){
                $this->{$setMethod}($value);
            }
        }
    }
}

class DemoRequst extends AbstractRequest{

   #[Validate(rules:['required'])]
   private int $id;

   #[Validate(rules:['required','integer'])]
   private string $name;

    // set 和 get 方法在 IDE 中可以使用快捷键生成
    public function getId(): int
    {
        return $this->id;
    }

    public function setId(int $id): void
    {
        $this->id = $id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function setName(string $name): void
    {
        $this->name = $name;
    }
}

class DemoController extends Controller {

    public function index(DemoRequest $request){
        $id = $request->getId();
        $name = $request->getName();
    }
}

通过以上代码,就可以在控制器中清楚的知道当前的 request 可以获取哪些合法参数了,但是每次请求的时候都需要通过反射去获取,规则都是静态的,能不能通过缓存文件提前生成好 rules 方法呢。

AST

需求分析

讲 AST 之前,先回顾一下一个 class 文件是如何被加载的:通过 composer 生成 classmap 文件,classmap 中定义了命名空间与文件路径的关联关系,我们这里的思路就是,替换 DemoRequst 的文件路径为我们生成好的缓存文件。

之前 DemoRequest 在 classmap 中可能是这样:

"\\App\\Http\\Requests\\DemoRequest" => $baseDir . './app/Http/Requests/DemoRequest.php'

我们需要替换成:

"\\App\\Http\\Requests\\DemoRequest" => $baseDir . './cache/Http_Requests_DemoRequest.cache.php'

app/Http/Requests/DemoRequest.php 中只需要定义 property 和规则的 Attribute:

class DemoRequst extends AbstractRequest {

   #[Validate(rules:['required'])]
   private int $id;

   #[Validate(rules:['required','integer'])]
   private string $name;
}

cache/Http_Requests_DemoRequest.cache.php 文件中,则会生成一个 rules 方法以及 get 和 set 方法:

class DemoRequst extends AbstractRequest {

    public function rules(): array
    {
        return [
           'id' => ['required','integer'],
           'name' => ['required']
        ];
    }

    // property 和 get set 方法省略
}

AST 相关处理类介绍

  • ParserFactory

    将源码解析成 AST 对象

  • Standard

    将 AST 对象转化为代码

  • NodeTraverser

    遍历 AST 节点

  • NodeVisitorAbstract

    节点修改器

通过 AST 生成 cache 类

class MakeRequestCache extends Command
{
    protected $signature = 'app:make-request-cache';

    public function handle()
    {
        // 获取所有自动加载的 classmap,key 为 class ,value 为 class 的文件路径
        // 可查看 vendor/composer/autoload_classmap.php
        $classMap = $this->getClassLoader()->getClassMap();

        $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
        $printer = new Standard();
        foreach ($classMap as $class => $file) {
            if (!str_starts_with($class, 'App\\Http\\Requests\\')) {
                continue;
            }

            // 创建 rules 方法的 AST 对象
            $rulesMethod = new ClassMethod('rules', ['flags' => 1, 'returnType' => 'array']);
            // 定义 rules 方法的 return 值
            $rulesMethod->stmts[] = new Return_($this->buildArrayNode($this->getRules($class)));
            // 将源码转化为 AST 对象
            $ast = $parser->parse(file_get_contents($file));
            // 定义修改器
            // 这里是在进入 Class_ 节点时,给 Class_ 增加 rules 方法节点
            $visitor = new class([$rulesMethod]) extends NodeVisitorAbstract {
                public function __construct(protected array $methods)
                {
                }

                public function enterNode(Node $node): void
                {
                    if ($node instanceof Class_) {
                        $node->stmts = array_merge($node->stmts, $this->methods);
                    }
                }
            };
            // 节点遍历器
            $traverser = new NodeTraverser();
            // 添加修改器
            $traverser->addVisitor($visitor);
            // 遍历源码的 AST,完成修改
            $proxy = $traverser->traverse($ast);
            // 将修改后的 AST 转化为代码
            $proxyCode = $printer->prettyPrintFile($proxy);
            // 将代码写入缓存文件中
            $classFile = str_replace('\\', '_', $class . '.cache.php');
            $cachePath = storage_path($classFile);

            file_put_contents($cachePath, $proxyCode);

            $this->output->info("生成缓存文件:{$cachePath}");
            // 将 classmap 中的关联更新为缓存文件
            $classMap[$class] = $cachePath;
        }
        // 重新刷新 classmap,具体刷新方式也是使用 AST 对 classmap 文件进行修改
        ClassCache::reloadClassMap($classMap);
    }

    private function getRules(string $class): array
    {
        $ref = new \ReflectionClass($class);

        foreach ($ref->getProperties() as $property) {
            $attributes = $property->getAttributes(Validate::class);
            if (empty($attributes[0])) {
                continue;
            }
            /** @var Validate $validate */
            $validate = $attributes[0]->newInstance();

            $rules[$property->getName()] = $validate->getRules();
        }
        return $rules ?? [];
    }

    private function getClassLoader(): ClassLoader
    {
        $loaders = spl_autoload_functions();
        foreach ($loaders as $loader) {
            if (is_array($loader) && $loader[0] instanceof ClassLoader) {
                return $loader[0];
            }
        }

        throw new \Exception('Composer loader not found.');
    }

    public function buildArrayNode(array $array): Array_
    {
        foreach ($array as $key => $value) {
            $arrayItems[] = new ArrayItem($this->cast($value), $this->cast($key));
        }

        return new Array_($arrayItems ?? []);
    }

    private function cast(mixed $value): ?Expr
    {
        return match (gettype($value)) {
            'boolean' => new LNumber(intval($value)),
            'integer' => new LNumber($value),
            'double' => new String_(strval($value)),
            'string' => new String_($value),
            'array' => $this->buildArrayNode($value),
            default => null
        };
    }
}

通过以上命令,就把 classmap 中的关联重新定向到了缓存文件,而缓存文件中就有 rules 方法,后续请求就不用再通过反射来动态创建 rules 方法了。

具体代码实现可查看:github.com/minororange/ast-demo

本作品采用《CC 协议》,转载必须注明作者和本文链接
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 6

只是用来学习的话,倒是可以。如果是实际应用的话,建议考虑 spatie/laravel-data

1年前 评论
minororange (楼主) 1年前
徵羽宫 1年前
陈先生 1年前
Rache1 (作者) 1年前
陈先生 1年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!