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()
или что-нибудь ещё, что, по вашему мнению, очень часто встречается в логике приложения и повторяется чаще, чем следовало бы. Но не переусердствуйте. Макросы добавляют новый уровень сложности, и при работе в команде они могут сбивать с толку.
Поэтому, когда вы почувствуете, что в вашем приложении чего-то не хватает, но не самого фреймворка, используйте макрос.