分析 Marcoable

原本預定要看 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 神奇設計的其中之一。