什麼是 Macroable trait
Macroable trait 是可在不修改類別的情況下,事後動態新增方法的機制。Laravel 許多核心類別皆使用此 trait,因此可以在不修改核心程式碼的前提下擴充功能。
trait 的實體位於 Illuminate\Support\Traits\Macroable。內部將已註冊的 macro 儲存於靜態屬性 $macros,並透過 __call / __callStatic 魔術方法呼叫。
使用 Macroable 的類別
Laravel 中有許多支援 Macroable 的類別。| 類別 | 用途 |
|---|---|
Illuminate\Support\Collection | Collection 操作 |
Illuminate\Support\Str | 字串操作 |
Illuminate\Support\Arr | 陣列操作 |
Illuminate\Http\Request | HTTP 請求 |
Illuminate\Http\Response | HTTP 回應 |
Illuminate\Routing\Router | Router |
Illuminate\Routing\ResponseFactory | Response Factory |
Illuminate\Database\Schema\Blueprint | Schema Builder |
Illuminate\Pipeline\Pipeline | Pipeline |
Illuminate\Testing\TestResponse | Test Response |
macro() — 新增方法
macro() 的第 1 個引數為方法名稱,第 2 個引數為 closure。
use Illuminate\Support\Collection;
Collection::macro('toSentence', function (string $separator = '、') {
/** @var Collection $this */
return $this->implode($separator);
});
$result = collect(['蘋果', '橘子', '葡萄'])->toSentence();
// '蘋果、橘子、葡萄'
$this 會被綁定至呼叫該 macro 的實例。因此可以直接存取類別的屬性或方法。
mixin() — 一次新增多個方法
若要一次註冊多個 macro,可使用mixin()。Mixin 類別的 public / protected 方法都會被註冊為 macro。
namespace App\Mixins;
class CollectionMixin
{
public function toCsv(): Closure
{
return function (string $separator = ',') {
/** @var \Illuminate\Support\Collection $this */
return $this->map(function ($item) use ($separator) {
return is_array($item) ? implode($separator, $item) : $item;
})->implode("\n");
};
}
public function filterEmpty(): Closure
{
return function () {
/** @var \Illuminate\Support\Collection $this */
return $this->filter(fn ($item) => ! empty($item))->values();
};
}
public function groupByFirst(): Closure
{
return function (string $key) {
/** @var \Illuminate\Support\Collection $this */
return $this->groupBy(fn ($item) => $item[$key][0] ?? '');
};
}
}
mixin() 的方法必須回傳將被註冊為 macro 的 closure。方法自身的回傳值即為 macro 的實作。於服務提供者註冊
Macro 需於應用程式啟動時註冊。AppServiceProvider 的 boot() 方法為合適之處。
namespace App\Providers;
use App\Mixins\CollectionMixin;
use Illuminate\Support\Collection;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// 註冊單一 macro
Collection::macro('toSentence', function (string $separator = '、') {
return $this->implode($separator);
});
// 一次註冊 mixin
Collection::mixin(new CollectionMixin);
// 對 Str 加入 macro
Str::macro('initials', function (string $name) {
return collect(explode(' ', $name))
->map(fn ($word) => strtoupper($word[0]))
->implode('');
});
}
}
實務使用情境
擴充 Collection
為 Collection 新增自訂方法是最常見的使用案例。// 數值 Collection 的統計方法
Collection::macro('median', function () {
$sorted = $this->sort()->values();
$count = $sorted->count();
if ($count === 0) {
return null;
}
$middle = (int) floor($count / 2);
if ($count % 2 === 0) {
return ($sorted->get($middle - 1) + $sorted->get($middle)) / 2;
}
return $sorted->get($middle);
});
$median = collect([3, 1, 4, 1, 5, 9, 2, 6])->median();
// 3.5
// 回傳附有分頁資訊的 Collection 的 macro
Collection::macro('paginateArray', function (int $perPage = 15, int $page = 1) {
return $this->slice(($page - 1) * $perPage, $perPage)->values();
});
擴充 Str 類別
// 中文字元計數(支援多位元組)
Str::macro('mbLength', function (string $value) {
return mb_strlen($value, 'UTF-8');
});
// 將 snake case 轉為點記法
Str::macro('toDotNotation', function (string $value) {
return str_replace('_', '.', $value);
});
$length = Str::mbLength('你好'); // 2
$dot = Str::toDotNotation('user_profile_name'); // 'user.profile.name'
擴充 Request 類別
use Illuminate\Http\Request;
Request::macro('isFromMobile', function () {
/** @var Request $this */
$userAgent = $this->userAgent() ?? '';
return preg_match('/Mobile|Android|iPhone|iPad/i', $userAgent) === 1;
});
Request::macro('preferredLocale', function (array $available = ['zh-TW', 'en']) {
/** @var Request $this */
foreach ($this->getLanguages() as $lang) {
$short = substr($lang, 0, 2);
if (in_array($short, $available)) {
return $short;
}
}
return $available[0] ?? 'en';
});
// 於 Controller 使用
public function index(Request $request)
{
if ($request->isFromMobile()) {
return response()->json($this->getMobileData());
}
$locale = $request->preferredLocale(['zh-TW', 'en', 'ja']);
// ...
}
擴充 Blueprint(Migration)
若將 Schema 的欄位定義集中 macro 化,可保持一致的 DB 設計。use Illuminate\Database\Schema\Blueprint;
Blueprint::macro('addTimestampsWithTimezone', function () {
/** @var Blueprint $this */
$this->timestampsTz();
$this->softDeletesTz();
});
Blueprint::macro('addUserTracking', function () {
/** @var Blueprint $this */
$this->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$this->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
});
// 於 Migration 使用
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->addTimestampsWithTimezone();
$table->addUserTracking();
});
擴充測試 Response
可新增測試專用的斷言方法。use Illuminate\Testing\TestResponse;
TestResponse::macro('assertPaginated', function () {
/** @var TestResponse $this */
return $this->assertJsonStructure([
'data',
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
'links' => ['first', 'last', 'prev', 'next'],
]);
});
TestResponse::macro('assertApiSuccess', function () {
/** @var TestResponse $this */
return $this->assertOk()->assertJsonPath('success', true);
});
// 於測試使用
$this->getJson('/api/posts')->assertPaginated();
$this->postJson('/api/orders', $data)->assertApiSuccess();
hasMacro() — 檢查 macro 是否存在
use Illuminate\Support\Collection;
if (Collection::hasMacro('toSentence')) {
$result = collect(['a', 'b'])->toSentence();
}
flushMacros() — 重設 macro
於測試中希望重設 macro 時使用。use Illuminate\Support\Collection;
// 於測試內重設
Collection::flushMacros();
flushMacros() 會刪除該類別的所有 macro。為維持測試間的獨立性有時會於 tearDown() 呼叫,但也會使其他測試中註冊的 macro 消失,需注意。靜態 macro
Macro 不僅能作為實例方法運作,也能作為靜態方法運作。由__callStatic 處理。
Str::macro('randomHex', function (int $length = 8) {
return substr(bin2hex(random_bytes($length)), 0, $length);
});
// 靜態呼叫
$hex = Str::randomHex(16);
於自訂類別使用 Macroable trait
也可以將Macroable 組入自己的類別。
namespace App\Services;
use Illuminate\Support\Traits\Macroable;
class ReportBuilder
{
use Macroable;
protected array $sections = [];
public function addSection(string $name, callable $content): static
{
$this->sections[$name] = $content;
return $this;
}
public function build(): array
{
return array_map(fn ($fn) => $fn(), $this->sections);
}
}
// 於服務提供者擴充
ReportBuilder::macro('withSummary', function (string $title) {
/** @var ReportBuilder $this */
return $this->addSection('summary', fn () => [
'title' => $title,
'generated_at' => now()->toIso8601String(),
]);
});
// 使用範例
$report = app(ReportBuilder::class)
->withSummary('月報')
->addSection('data', fn () => ['rows' => 42])
->build();
內部實作細節
// __call 的實作(實例方法呼叫)
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];
if ($macro instanceof Closure) {
// 以 bindTo 將 $this 綁定至實例
$macro = $macro->bindTo($this, static::class);
}
return $macro(...$parameters);
}
Closure::bindTo() 綁定至實例。如此 $this 便指向呼叫 macro 的物件。若為非 closure(如 invokable 物件),則不會被綁定。
若要獲得 IDE 支援,可以用
@mixin Doc Block 定義 macro 的註解,或使用 Laravel IdeHelper 套件自動生成 helper 檔案。下一步
Pipeline 模式
學習如何使用 Pipeline 模式將多個處理步驟串聯組合。