以前にリファクタリングを自動化できるツール、Rectorを紹介しました。前回の記事ではRector側で用意された変換ルールに則ってリファクタする方法をお伝えしました。しかし、Rectorの真骨頂はむしろ自分で変換ルールを作成して各々の環境に合わせて自由にカスタマイズ出来る点にあります。直近の実務でそちらに触れる機会がありましたのでご紹介致します。

Rectorのインストール(おさらい)

前回の記事からだいぶ時間が経ってしまったのでプロジェクトにrectorをセットアップする手順からおさらいします。まず、以下でrectorをプロジェクトにinstallして下さい。

$ composer require rector/rector --dev

2024/08/03時点でversion 1.2.1がinstallされました。次に、初回実行して設定ファイルを作成します。

$ vendor/bin/rector

 No "rector.php" config found. Should we generate it for you? [yes]:
 > yes

rector.phpがルートディレクトリに作成されました。そちらの編集については後ほど解説致します。

自作ルールでリファクタリングを自動化

例えば、直近で私が携わった案件ではそれまで定数で管理していた値をPHP8.1の導入と共にEnumへ切り替えるリファクタをRectorで自作ルールを作成して行いました。今回はそちらのケースを例として紹介します。

定数をEnumに変換する

以下の様なモデルクラスがあったとします。


namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Order extends Model
{
    use HasFactory;

    // order status constants
    const ORDER_STATUS_ORDERED = 0;
    const ORDER_STATUS_SHIPPED = 1;
    const ORDER_STATUS_CANCELED = 2;

    protected $table = 'order';

    protected $primaryKey = 'order_id';

    protected $guarded = ['order_id', 'created_at', 'updated_at'];

    // キャンセルされた?
    public function isCanceled()
    {
        return $this->status === self::ORDER_STATUS_CANCELED;
    }

    // 売上として計上された注文
    public function scopeAsRevenue($query)
    {
        return $query->whereIn('status', [self::ORDER_STATUS_ORDERED, self::ORDER_STATUS_SHIPPED]);
    }

}

このクラスではorder_statusというプロパティの値を定数で管理しています。そして、それらを以下のOrderStatusというEnumに切り替えたいとします。


namespace App\Enums;

enum OrderStatus: int
{
    case ORDERED = 1;
    case SHIPPED = 2;
    case CANCELED = 3;
}

具体的にはコードを以下の様に書き換えます。

self::ORDER_STATUS_CANCELED
↓
OrderStatus::CANCELED->value

カスタムルール作成

こちらの公式ドキュメントを参考に進めます。まず、カスタムルールを定義するクラスを作成します。

$ vendor/bin/rector custom-rule

// 作成するカスタムルール名を指定(ReplaceConstantToEnumValueとしました)
What is the name of the rule class (e.g. "LegacyCallToDbalMethodCall")?:
> ReplaceConstantToEnumValue

Generated files
===============

* utils/rector/tests/Rector/ReplaceConstantToEnumValueRector/Fixture/some_class.php.inc
* utils/rector/tests/Rector/ReplaceConstantToEnumValueRector/config/configured_rule.php
* utils/rector/src/Rector/ReplaceConstantToEnumValueRector.php
* utils/rector/tests/Rector/ReplaceConstantToEnumValueRector/ReplaceConstantToEnumValueRectorTest.php

[OK] Base for the "ReplaceConstantToEnumValueRector" rule was created. Now you can fill the missing parts

[OK] We also update composer.json autoload-dev, to load Rector rules. Now run "composer
dump-autoload" to update paths

[OK] We also update /***/rector/phpunit.xml, to add a rector test suite.
You can run the rector tests by running: phpunit --testsuite rector

実行が完了するとルートディレクトリにutilsディレクトリが作成されます。utilsディレクトリの構造は以下の様になっているはずです。

$ tree utils

utils
└── rector
    ├── src
    │   └── Rector
    │       └── ReplaceConstantToEnumValueRector.php
    └── tests
        └── Rector
            └── ReplaceConstantToEnumValueRector
                ├── ReplaceConstantToEnumValueRectorTest.php
                ├── Fixture
                │   └── some_class.php.inc
                └── config
                    └── configured_rule.php

composer.jsonにutils配下のクラスをオートロードする設定が追加されていますので反映させておきましょう。

$ composer dump-autoload

カスタムルールを定義

作成されたReplaceConstantToEnumValueRector.phpを編集してカスタムルールを定義していきましょう。このクラスにはデフォルトで3つのメソッドが定義されています。

  1. getRuleDefinition()
  2. getNodeTypes()
  3. refactor()

1つずつ解説します。

getRuleDefinition()

こちらのメソッドではリファクタの前後でコードがどの様に変更されるか、を定義します。こちらのメソッドは他の開発者がルールを理解しやすくする為です。また、ドキュメントを作成する際に使える様ですが、そちらはまだ深掘りしていないので今回は割愛します。getRuleDefinition()は以下の様に実装しました。

...
    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition('Replace Constant to Enum', [
            new CodeSample(
                // BEFORE
                <<<'CODE_SAMPLE'
ORDER_STATUS_ORDERED
CODE_SAMPLE
                ,
                // AFTER
                <<<'CODE_SAMPLE'
OrderStatus::ORDERED->value
CODE_SAMPLE
            ),
        ]);
...

getNodeTypes()

こちらはリファクタの対象となるnodeを指定するメソッドです。nodeとはソースコードの構成要素の事で、クラスやメソッド、変数、条件式などです。Rectorはソースコードをnodeから成るツリー構造(AST)として解析し、nodeを操作する事でコードを変換しています。指定可能なnodeはこちらにまとめられています。例えば、今回は定数を操作したいのでClassConstFetch::classを指定します。

...
    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        // select node type
        return [ClassConstFetch::class];
    }
...

今回はClassConstFetchのみですが、複数指定する事も可能です。ここで指定したnodeがfetchされ次に解説するrefactor()の引数に渡されます。

refactor()

fetchしたnodeに変更を加えてコードをリファクタします。まず、引数で渡された$nodeがどういうものなのか見てみましょう。refactor()内にeval(\Psy\sh())を挿入してrectorを実行してみると以下の様に出力されました。(rectorの実行方法については後で解説します。)

From utils/rector/src/Rector/ReplaceConstantToEnumValueRector.php:48:
    46:     public function refactor(Node $node): ?Node
    47:     {
  > 48:         eval(\Psy\sh());
    49: 
    50:         $constName = $this->getName($node->name);
> $node
= PhpParser\Node\Expr\ClassConstFetch {#24984
    +class: PhpParser\Node\Name {#24985
      +parts: [
        "self",
      ],
    },
    +name: PhpParser\Node\Identifier {#24986
      +name: "ORDER_STATUS_CANCELED",
    },
  }

getNodeTypes()で指定した通り、ClassConstFetchクラスのインスタンスが代入されていますね。ここでfetchしたnodeはself::ORDER_STATUS_CANCELEDに当たる部分なので、クラス名部分がself、変数名部分がORDER_STATUS_CANCELEDとしてそれぞれclass, nameプロパティから辿れます。それによって、更にリファクタしたい対象を絞り込む事ができます、以下の様に。

...
    /**
     * @param ClassConstFetch $node
     */
    public function refactor(Node $node): ?Node
    {
        $constName = $this->getName($node->name);

        // ORDER_STATUS_* の場合のみリファクタ
        if (Str::startsWith($constName, 'ORDER_STATUS_')) {
            // リファクタ処理
        }

        return $node;
    }
...

$this->getName($node->name)の部分で変数名部分を取得し、それがORDER_STATUS_から始まるならリファクタ処理を実行、としました。

次にリファクタ処理を実装します。リファクタ処理はOrderStatus::XXX->valueを表現するnodeを新たに作成して返却します。nodeの作成は$this->nodeFactoryのメソッドをcallして行います。(実行可能なメソッドはRector\PhpParser\Node\NodeFactoryをご覧ください。)以下の様に実装しました。

...
    /**
     * @param ClassConstFetch $node
     */
    public function refactor(Node $node): ?Node
    {
        $constName = $this->getName($node->name);

        // ORDER_STATUS_*
        if (Str::startsWith($constName, 'ORDER_STATUS_')) {
            // ORDER_STATUS_以降を取得
            $case = Str::after($constName, 'ORDER_STATUS_');

            // OrderStatus::XXX のnodeを作成
            $constFetch = $this->nodeFactory->createClassConstFetch('App\\Enums\\OrderStatus', $case);

            // OrderStatus::XXX にvalueプロパティを参照させる(OrderStatus::XXX->value)
            return $this->nodeFactory->createPropertyFetch($constFetch, 'value');
        }

        return $node;
    }
...

これでカスタムルールの作成が完了です。

リファクタ実行

最後にrector.phpを設定してリファクタを実行してみましょう。rector.phpは以下の様に設定しました。


declare(strict_types=1);

use Rector\Config\RectorConfig;
use Utils\Rector\Rector\ReplaceConstantToEnumValueRector;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/app/Models/Order.php',
    ])
    ->withRules([
        ReplaceConstantToEnumValueRector::class,
    ])
    ->withImportNames();

withPaths()にてリファクタを適用するファイルを指定、withRules()にて適用するルールを指定しています。また、OrderStatusが完全修飾名となっているのでwithImportNames()を追加して名前空間をインポート(use App\Enums\OrderStatus;を追加)するようにしています。これでrectorを実行してみます。実際にリファクタを適用する前にコードがどの様に変化するのか、dry runで確認してみましょう。

$ vendor/bin/rector --dry-run        
 1/1 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%
1 file with changes
===================

1) app/Models/Order.php:1

    ---------- begin diff ----------
@@ @@

 namespace App\Models;

+use App\Enums\OrderStatus;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Factories\HasFactory;

@@ @@
     // キャンセルされた?
     public function isCanceled()
     {
-        return $this->status === self::ORDER_STATUS_CANCELED;
+        return $this->status === OrderStatus::CANCELED->value;
     }

     // 売上として計上された注文
     public function scopeAsRevenue($query)
     {
-        return $query->whereIn('status', [self::ORDER_STATUS_ORDERED, self::ORDER_STATUS_SHIPPED]);
+        return $query->whereIn('status', [OrderStatus::ORDERED->value, OrderStatus::SHIPPED->value]);
     }

 }
    ----------- end diff -----------

Applied rules:
 * ReplaceConstantToEnumValueRector
                                                                                                                        
 [OK] 1 file would have been changed (dry-run) by Rector   

差分を見ての通り、self::ORDER_STATUS_XXXだった箇所がOrderStatus::XXX->valueに変換されていますね。問題なさそうなのでdry runオプションを外して実行します。

$ vendor/bin/rector

以下が変換後のOrder.phpです。


namespace App\Models;

use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;

class Order extends Model
{
    use HasFactory;

    // order status constants
    const ORDER_STATUS_ORDERED = 0;
    const ORDER_STATUS_SHIPPED = 1;
    const ORDER_STATUS_CANCELED = 2;

    protected $table = 'order';

    protected $primaryKey = 'order_id';

    protected $guarded = ['order_id', 'created_at', 'updated_at'];

    // キャンセルされた?
    public function isCanceled()
    {
        return $this->status === OrderStatus::CANCELED->value;
    }

    // 売上として計上された注文
    public function scopeAsRevenue($query)
    {
        return $query->whereIn('status', [OrderStatus::ORDERED->value, OrderStatus::SHIPPED->value]);
    }

}

変換後のファイルを見ると定数の定義部分が残っていますね。こちらもまた別のカスタムルールを作成して削除できます。こうして1つずつ作業を棚卸ししてRectorのカスタムルールを作成していく事で膨大なリファクタ作業を大幅に短縮させる事が可能です。是非試してみて下さい。

参照

Rectorでリファクタリングを自動化 その1
Rectorでリファクタリングを自動化 その2

メルマガ購読の申し込みはこちらから。

By hikaru