LaravelのMacroについて

しょうちゃん
2019-06-18
しょうちゃん
2019-06-18

Laravelはフレームワークに組み込まれたクラスを
継承することなく拡張するためにMacroという機構を提供しています。

今回、業務でResponseを拡張するにあたり内部の処理を含めて調べてみました。

例として、日付クラスのCarbonに和暦を計算する機能を実装してみます。
私の記憶が確かなら2019年5月1日以降は、「令和」それ以前は「平成」です。

Carbonにマクロを登録

マクロで拡張出来るクラスにはmacroメソッドが実装されています。
第一引数はメソッド名、第二引数に登録したい関数を指定して呼ぶことで、
クラスのメソッド/スタティックメソッドとして登録されます。

```php

\Illuminate\Support\Carbon::macro("japaneseEra", function () {
  if ($this->lt(new \Illuminate\Support\Carbon("2019-05-01"))) {
    return"reiwa";
  }
  return"heisei";
});

$now = \Illuminate\Support\Carbon::now()

// 年号を取得する
echo $now->japaneseEra();

> reiwa

```

どうしてこんなことができるの…

MacroableというTraitにマクロの登録と実行を行う処理が実装されていて、
マクロで拡張できるクラスはMacroable traitを追加しているようです。

\Illuminate\Support\CarbonもMacroableを追加しているためマクロの登録ができるのです。

変な嵌りポイントにならないようにLaravelのプロジェクトで使うときはCarbon\Carbonではなく\Illuminate\Support\Carbonを使ったほうが良さそうですね…

あっ、あと、Laravelに含まれているクラスも全てがMacroableを実装しているわけでは無いようです。

Macroable trait


MacroableトレイトはLaravelフレームワークのsrc/Illuminate/Support/Traits/Macroable.phpファイルに存在します。
下記のようなシグネチャですね。

```php

trait Macroable {
    protected static $macros = [];

    public static function macro($name, $macro);
    public static function mixin($mixin);
    public static function hasMacro($name);

    public static function __callStatic($method, $parameters);
    public function __call($method, $parameters);
}

```

みただけで処理が類推できますね…

macroメソッドでマクロを登録すると、$macrosフィールドに無名関数が保存され、
マジックメソッドの __call, __callstaticが呼ばれたタイミング(これらは存在しないメソッドが呼ばれた時に呼ばれる)で
$macrosの中の無名関数をオブジェクトのスコープで実行していそうです。



実際、macro, __callメソッドは下記の通りの実装でした。(日本語でコメントを補足しています。)

```php

    /**
     * Register a custom macro.
     *
     * @param string $name
     * @param object|callable $macro
     *
     * @return void
     */
    public static function macro($name, $macro) 
    {
        static::$macros[$name] = $macro;
    }

    /**
     * 存在しないメソッドが呼ばれた時に__callマジックメソッドが呼ばれます。
     * https://www.php.net/manual/ja/language.oop5.overloading.php#object.call
     *
     * Dynamically handle calls to the class.
     *
     * @param string $method
     * @param array $parameters
     * @return mixed
     *
     * @throws \BadMethodCallException
     */
    public function __call($method, $parameters)
    {
        /* マクロが見つからない場合は例外出して終了*/
        if (! static::hasMacro($method)) {
            throw new BadMethodCallException("Method {$method} does not exist.");
        }

        /* 呼び出されたメソッドの名前からマクロを取得 */
        $macro = static::$macros[$method];

        // 関数をmacroメソッドで登録していた場合、$macroはclosure型です。
        // phpは関数を変数として使うと内部で自動的に関数をClosure型に変換します。
        // https://www.php.net/manual/ja/functions.anonymous.php

        if ($macro instanceof Closure) {
            /* closureのbindToでtraitをuseしたクラス、オブジェクトにバインドして呼び出す */
            return call_user_func_array($macro->bindTo($this, static::class), $parameters);
        }

        /* 関数名を文字列で渡した場合はこちらに来そうです。 */
        return call_user_func_array($macro, $parameters);
    }

```

自分で用意したクラスをマクロで拡張してみる

Laravelが提供しているいないに関係なく、
Macroable traitをuseするだけでマクロで拡張できるクラスを作成できます。

```php

class Calculator {
    use \Illuminate\Support\Traits\Macroable;
}

Calculator::macro("calc", function ($adder, $addent) {
    return$adder + $addent;
});

Calculator::calc(1, 2);

```

メリットデメリットを考える。

ここまで使い方と実装の抜粋をを見てきましたがMacroを使うメリットとデメリットも考えてみます。。

メリット
  • 継承ツリーを弄らずに機能を追加できる。
    → フレームワークが提供しているクラスを継承したくなった時は検討したいですね。
  • フレームワークのメソッドがマクロで拡張したオブジェクトを返すとき拡張したメソッドを持っている。
    → デコレーターでラップしたり、ヘルパ関数を呼び出して強引に解決してる場合は検討したいです

デメリット

  • マクロで登録されたコードが追いづらいかも?
    → ServiceProviderで登録するなどして登録方法を統一したい。
  • 実行中に動的にコードを変えると、割と凶悪な挙動が実現できそうです。呼ぶたびに別の処理を登録するとか。
  • mixinしたオブジェクト同士が同名のフィールドを使っていた場合は悲しみが発生するかも  

いかがだったでしょうかー???

今回仕事でresponseマクロを使ったタイミングでMacroableのコードリーディングをするまで、
Laravelがフレームワーク内のクラスを継承なしで拡張する方法を用意しているとは知りませんでした。

Laravelにはまだ使ったことのない便利なクラスが存在するはずですので、今後も見つけ次第紹介していけたらと思います。

では、さよなら。