Laravel под капотом: Немного макросов

Источник: «Laravel Under The Hood - A Little Bit of Macros»
Один из способов расширить Laravel с помощью пользовательских методов — макросы и миксины. Давайте покажу как это делается!

Как часто вы желали получить метод, которого нет в коллекциях или строковых хелперах? Начинаете выстраивать цепочки методов, а потом упираетесь в стену, когда оказывается, что один из них отсутствует. Честно говоря, это вполне объяснимо: фреймворки, знаете ли, вещь универсальная. Сам неоднократно оказывался в подобной ситуации. Каждый раз, прежде чем приступить к расширению фреймворка, я проверял, не является ли то, что я хочу расширить, макросом или нет. Но что это означает? Именно это мы и будем исследовать!

Что такое макросы

Предположим, у нас есть JWT:

$jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

И необходимо извлечь заголовки:

str($jwt)
->before('.')
->fromBase64()
->fromJson(); // не существует 😞

// BadMethodCallException Method Illuminate\Support\Stringable::fromJson does not exist.

fromJson() не существует 😔 Конечно, можно было бы просто написать:

json_decode(str($jwt)->before('.')->fromBase64());

Но где же тут веселье? К тому же, это моя статья.

Итак, нужен способ расширить класс Stringable. Есть несколько способов сделать это, но Laravel подумал заранее. Он знал, что разработчики могут захотеть добавить пользовательские методы, поэтому он сделал класс macroable/расширяемым макросами, или, как мне нравится это называть, расширяемым.

Давайте расширим класс. В AppServiceProvider добавьте следующее:

<?php

namespace App\Providers;

use Illuminate\Support\Stringable;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Stringable::macro('fromJson', function (bool $associative = true) {
return json_decode($this->value, $associative);
});
}
}

Теперь перезапустим код:

str($jwt)
->before('.')
->fromBase64()
->fromJson();

// ["alg" => "HS256", "typ" => "JWT"]

Все работает отлично! Но возможно, вам интересно, как это сработало? И что именно представляет собой $this->value? Что происходит?

Раскрывая магию

Мы знаем, что класс Stringable использует трейт Macroable, предоставляющий метод macro(). Давайте подробнее рассмотрим, что он делает:

// src/Illuminate/Macroable/Traits/Macroable.php

/**
* Register a custom macro.
*
* @param string $name
* @param object|callable $macro
*
* @param-closure-this static $macro
*
* @return void
*/

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

Это довольно просто, он сохраняет обратный вызов в статическом массиве macros. Теперь, если проанализировать трейт дальше, мы найдём метод __call, срабатывающий каждый раз, когда вызывается несуществующий метод. В нашем случае это fromJson(). Давайте вникнем в суть:

/**
* 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(sprintf(
'Method %s::%s does not exist.', static::class, $method
));
}

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

if ($macro instanceof Closure) {
$macro = $macro->bindTo($this, static::class);
}

return $macro(...$parameters);
}

Сначала он проверяет, зарегистрирован ли макрос, как в случае с fromJson(), затем извлекает обратный вызов (или объект) из массива макросов. Если макрос является замыканием (как в нашем случае), он вызывает bindTo(), который, по сути, сообщает замыканию, что $this должен ссылаться на то, что передано в качестве первого аргумента. В данном случае это экземпляр Stringable, имеющий атрибут $value.

// $this, здесь - stringable
// $this внутри замыкания теперь ссылается на класс stringable
$macro->bindTo($this, static::class);

Именно поэтому и можно сделать $this->value.

Можно сделать лучше: Миксины

Есть ещё одна вещь, которую я хочу показать! Когда мы расширяем один и тот же класс несколько раз, сервис-провайдер очень быстро может стать запутанным. Можно извлечь все макросы в класс Миксин.

Создадим StringableMixin:

<?php

namespace App\Macros;

use Closure;

class StringableMixin
{
public function fromJson(): Closure
{
return function (bool $associative = true) {
json_decode($this->value, $associative);
};
}

// Добавляйте сюда макросы по мере необходимости
}

Теперь в AppServiceProvider можно зарегистрировать этот миксин:

use App\Macros\StringableMixin;
use Illuminate\Support\Stringable;

class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Stringable::mixin(new StringableMixin);
}
}

Вот и всё! Теперь можно делать:

str($jwt)
->before('.')
->fromBase64()
->fromJson();

Если интересно, как это работает, метод mixin() в трейте Macroable использует Reflection API. Он извлекает все публичные методы из класса Mixin, ожидает, что каждый из них вернёт замыкание, а затем регистрирует замыкание как макрос, как мы видели ранее.

Проблема с IDE

Как вы уже поняли, здесь происходит много магии, и IDE не будет знать о созданных макросах. Если вы работаете в команде, другие разработчики тоже не будут знать об этих макросах, что не очень хорошо. К счастью, есть инструменты, способные в этом помочь. Бесплатный и с открытым исходным кодом вариант — пакет Laravel IDE helper.

Установите пакет, сгенерируйте файл _ide_helper.php, и готово.

И вот всё

Наш пример довольно прост, но вы можете продвинуть макросы гораздо дальше, поскольку большинство распространённых классов, поставляемых с Laravel, являются макросами. Например, можно добавить новый макрос apiResponse() или что-нибудь ещё, что, по вашему мнению, очень часто встречается в логике приложения и повторяется чаще, чем следовало бы. Но не переусердствуйте. Макросы добавляют новый уровень сложности, и при работе в команде они могут сбивать с толку.

Поэтому, когда вы почувствуете, что в вашем приложении чего-то не хватает, но не самого фреймворка, используйте макрос.

Комментарии


Дополнительные материалы

Предыдущая Статья

Новое в Symfony 7.2: Упрощение настройки доверенных прокси

Следующая Статья

Новое в Symfony 7.2: Упрощённые однофайловые приложения Symfony