Rector 自动化重构规则编写

解读

在国内一线互联网公司的 PHP 技术面试中,Rector 已从“加分项”变成“必会项”。面试官抛出“写一条 Rector 规则”并不是让你背诵文档,而是考察三件事:

  1. 是否真在项目中用 Rector 解决过“代码腐化”问题;
  2. 是否理解 AST(抽象语法树)的节点类型与遍历机制;
  3. 能否把业务约束(如公司编码规范、安全红线)翻译成可自动执行的规则。

因此,答题时必须给出“可落地、可复现、可集成到 CI”的完整代码,并解释规则背后的设计思路。规则难度要适中:太简单(如把 array() 改成 [])会被认为“没深度”;太复杂(如跨文件静态分析)在 10 分钟面试里讲不清。下面以“强制把 date('Y-m-d') 换成 Carbon::now()->toDateString()”为例,展示国内面试官最想听到的答题结构。

知识点

  1. Rector 运行流程:FileProcessor → Parser → NodeTraverser → Rule → Printer。
  2. 核心接口:Rector\Core\Contract\Rector\RectorInterface,实际继承 AbstractRector 即可。
  3. 节点类型:PhpParser\Node\Expr\FuncCall 代表函数调用,Node\Name 区分函数名。
  4. 节点替换:return 新节点即完成替换,Rector 会自动写回文件。
  5. 规则测试:Rector 自带 AbstractRectorTestCase,放在 tests/Rector 目录,GitHub CI 可直接跑。
  6. 国内落地要点:
    • 规则必须加 @see 指向公司 Wiki 地址,方便审计;
    • 规则 class 名以 *Rector 结尾,符合 PSR-4;
    • 禁止直接 new 类,用 NodeFactory 创建,避免语法版本兼容问题;
    • 上线前要在 rector.php 里配置 skip,防止老模块爆炸。

答案

<?php
declare(strict_types=1);

namespace App\Rector;

use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use Rector\Core\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

/**
 * 强制把 date('Y-m-d') 替换为 Carbon::now()->toDateString()
 * 符合公司《PHP 安全开发规范 V5.2》第 3.4 条:时间操作统一用 Carbon。
 *
 * @see https://wiki.company.com/php-standards#time
 */
final class DateToCarbonRector extends AbstractRector
{
    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition(
            'Replace date("Y-m-d") with Carbon::now()->toDateString()',
            [
                new CodeSample(
                    <<<'CODE_SAMPLE'
$date = date('Y-m-d');
CODE_SAMPLE
                    ,
                    <<<'CODE_SAMPLE'
$date = \Carbon\Carbon::now()->toDateString();
CODE_SAMPLE
                ),
            ]
        );
    }

    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        return [FuncCall::class];
    }

    /**
     * @param FuncCall $node
     */
    public function refactor(Node $node): ?Node
    {
        if (! $this->isName($node->name, 'date')) {
            return null;
        }

        // 只处理单参数且为 'Y-m-d' 的情况
        if (count($node->args) !== 1) {
            return null;
        }

        $firstArg = $node->args[0];
        if (! $firstArg->value instanceof Node\Scalar\String_) {
            return null;
        }

        if ($firstArg->value->value !== 'Y-m-d') {
            return null;
        }

        // 构建 \Carbon\Carbon::now()->toDateString()
        $staticCall = new StaticCall(
            new FullyQualified('Carbon\Carbon'),
            'now'
        );
        $methodCall = new Node\Expr\MethodCall($staticCall, 'toDateString');

        return $methodCall;
    }
}

配套测试 tests/Rector/DateToCarbonRector/DateToCarbonRectorTest.php

<?php
declare(strict_types=1);

namespace Tests\Rector\DateToCarbonRector;

use App\Rector\DateToCarbonRector;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;

final class DateToCarbonRectorTest extends AbstractRectorTestCase
{
    /**
     * @dataProvider provideData()
     */
    public function test(string $file): void
    {
        $this->doTestFile($file);
    }

    public function provideData(): \Iterator
    {
        return $this->yieldFilesFromDirectory(__DIR__ . '/Fixture');
    }

    public function provideConfigFilePath(): string
    {
        return __DIR__ . '/config.php';
    }
}

Fixture 文件 Fixture/simple.php.inc

<?php
namespace RectorPrefix2025;

class SomeController
{
    public function run()
    {
        $a = date('Y-m-d');
    }
}
?>
-----
<?php
namespace RectorPrefix2025;

class SomeController
{
    public function run()
    {
        $a = \Carbon\Carbon::now()->toDateString();
    }
}
?>

CI 集成(.github/workflows/rector.yml 片段):

- name: Run Rector
  run: |
    vendor/bin/rector process --dry-run --ansi

至此,一条可合并到主干、可自动回归测试的 Rector 规则就写完了。面试时把这段代码粘到 IDE,现场跑 vendor/bin/rector process demo.php --dry-run,能看到 diff 红绿对比,说服力瞬间拉满。

拓展思考

  1. 规则灰度:如何在 monorepo 里只对指定微服务生效?
    答:在 rector.php 里用 Skipper 配置路径正则,如 '/^packages\/(?!order)/',实现“订单服务先行,其余观望”。

  2. 性能调优:国内某头部电商 200 万行 PHP 遗产代码,一次 Rector 全量扫描要 45 分钟,如何把耗时降到 5 分钟?
    答:

    • 开启 opcache.enable_cli=1
    • rector-cache 把序列化的 AST 缓存到 Redis;
    • 按 Git diff 只跑变更文件,CI 里用 vendor/bin/rector process $(git diff --name-only origin/main...HEAD)
  3. 安全规则:如何写一条“强制把 $_GET 直接取值”替换成 filter_input(INPUT_GET, ...) 的规则?
    提示:监听 Node\Expr\ArrayDimFetch,判断 var 是否为 Node\Expr\Variable 且 name 等于 _GET,再构造 filter_input 函数调用即可。该规则可直接堵住大量 XSS 入口,是安全团队最爱的扫描项。

  4. 与 PHPStan、EasyCodingStandard 的协同:
    国内很多团队把 Rector、PHPStan、ECS 串成“代码质量三叉戟”:

    • Rector 负责“改”,PHPStan 负责“检”,ECS 负责“格”;
    • 在 pre-push 钩子中先 rector process --dry-run,再 phpstan analyse,最后 ecs check,三道闸门全部通过才允许推送。

掌握以上思路,候选人就能把“写一条 Rector 规则”这个小问题,讲成“百万级代码库自动化治理”的大故事,稳稳拿到 PHP 高工 offer。