iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 19
0

原本預定要看 middleware,但因為發生忘了帶充電器的蠢事,沒辦法用自己習慣的筆電,所以換講比較簡單的 Marcoable

如何擴展既有類別的功能

先來個大哉問。一般最先想到的就是繼承(extends),Carbon 正是一個非常好的例子。再來可能就會從設計著手,比方說使用 strategy pattern 或 pluggable adapter pattern。

繼承確實能做到擴展,但它有兩個限制,第一:它是靜態的;第二:不支援多重繼承。平常使用並不會有太大問題,但假使想引用第三方擴展套件時,如果第三方使用繼承擴展功能,因為這兩個限制,使得開發者必須改繼承第三方套件,才可實作自己的擴展,這是非常不便的。

Laravel 實作了一套動態擴展功能的機制,讓開發者跟第三方套件都可以動態為既有類別加功能,下面是一個簡單的範例:

class Foo
{
    use Marcoable;

    private $value = 'something';

    public function setValue($v)
    {
        $this->value = $v;
    }
}

Foo::macro('hello', function () {
    return 'world';
});

Foo::macro('getValue', function () {
    return $this->value;
});

Foo::hello(); // world
(new Foo())->getValue(); // something

今天就來分析這個神奇的功能吧。

分析 marco()

marco() 的定義其實很單純,就是設定個值而已:

public static function macro($name, $macro)
{
    static::$macros[$name] = $macro;
}

關鍵是在魔術方法 __call()__callStatic() 的實作:

public static function __callStatic($method, $parameters)
{
    // 找不到就丟例外
    if (! static::hasMacro($method)) {
        throw new BadMethodCallException(sprintf(
            'Method %s::%s does not exist.', static::class, $method
        ));
    }
    
    // 如果是 Closure 就 bind static class 給它,讓它能存取得到靜態屬性
    if (static::$macros[$method] instanceof Closure) {
        return call_user_func_array(Closure::bind(static::$macros[$method], null, static::class), $parameters);
    }
    
    return call_user_func_array(static::$macros[$method], $parameters);
}

public function __call($method, $parameters)
{
    // 找不到就丟例外
    if (! static::hasMacro($method)) {
        throw new BadMethodCallException(sprintf(
            'Method %s::%s does not exist.', static::class, $method
        ));
    }

    $macro = static::$macros[$method];

    // 如果是 Closure 就 bind 目前的實例給它,讓它能存取得到實例的屬性
    if ($macro instanceof Closure) {
        return call_user_func_array($macro->bindTo($this, static::class), $parameters);
    }

    return call_user_func_array($macro, $parameters);
}

Macroable 其實就這麼單純,而 Laravel 在設計上,因為有的物件有它自己 __call() 的方法,如 Router,為了避免衝突,它會這樣寫:

// 換個方法名稱
use Macroable {
    __call as macroCall;
}

public function __call($method, $parameters)
{
    // 如果有設定 marco,會優先呼叫 marco
    if (static::hasMacro($method)) {
        return $this->macroCall($method, $parameters);
    }

    // 處理自己的 __call() 邏輯
}

在目前追過的程式碼中,都是會以 marco 優先,然後才處理自己的 __call()

分析 mixin()

直接來看原始碼,再來看如何使用:

public static function mixin($mixin)
{
    // 透過反射,取得反射方法的實例,主要是取得 public 與 protected 方法
    $methods = (new ReflectionClass($mixin))->getMethods(
        ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED
    );

    // 批次把所有要混入的方法使用 macro 加入
    foreach ($methods as $method) {
        // 先改成可以存取
        $method->setAccessible(true);
        // 參數 name 會是方法名稱,參數 macro 則是取得方法執行過後的結果。
        static::macro($method->name, $method->invoke($mixin));
    }
}

從原始碼分析可以得知,如果想把一開始的使用範例改用 mixin() 的話,寫法如下:

class Foo
{
    use Marcoable;

    private $value = 'something';

    public function setValue($v)
    {
        $this->value = $v;
    }
}

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

    public function getValue()
    {
        return function () {
            return $this->value;
        };
    }
}

Foo::mixin(new Bar());

Foo::hello(); // world
(new Foo())->getValue(); // something

如果想要為多個 Marcoable 的物件,加入一樣的實作時,使用 mixin() 會是更加簡單的方法。

因為整個過程是動態加入方法,而不是靜態的定義,所以這樣的事就有辦法達成:有兩個第三方套件會為 Router 加入自定義的實作,而應用程式也有為 Router 加入不一樣的實作,並覆寫第三方套件的實作。

這也是筆者認為 Laravel 神奇設計的其中之一。


上一篇
分析 Routing(7)
下一篇
解析 Middleware 的實作細節
系列文
Laravel 原始碼分析46

尚未有邦友留言

立即登入留言