Начиная с PHP 8 мы сможем использовать атрибуты. Задача этих атрибутов, во многих других языках известных как аннотации, заключается в структурированном добавлении метаданных к классам, методам, переменным и всему остальному.

Сама концепция атрибутов отнюдь не нова: мы годами имитировали их поведение с помощью docblock'ов. Однако с появлением атрибутов мы получили первоклассную поддержку таких метаданных на уровне самого языка, и нам больше не нужно вручную парсить docblock'и.

Так как же они выглядят? Как создавать собственные атрибуты? Есть ли какие-то нюансы? На эти вопросы мы и ответим в этой статье. Поехали!

Краткий обзор

Первым делом посмотрим, как атрибут выглядит на практике:

use \Support\Attributes\ListensTo;

class ProductSubscriber
{
    #[ListensTo(ProductCreated::class)]
    public function onProductCreated(ProductCreated $event) { /* … */ }

    #[ListensTo(ProductDeleted::class)]
    public function onProductDeleted(ProductDeleted $event) { /* … */ }
}

Дальше в статье я покажу и другие примеры, но мне кажется, что пример с подписчиками на события отлично подходит для первого знакомства с атрибутами.

И да, я знаю: синтаксис может оказаться не совсем тем, на что вы рассчитывали. Возможно, вам больше нравился @ , @: , docblock'и или что-то ещё... Но этот синтаксис с нами надолго, так что лучше научиться с ним работать. Единственное, о чём стоит упомянуть по поводу синтаксиса: обсуждались вообще все возможные варианты, и для выбора именно этого были очень веские причины. Всю дискуссию по RFC можно почитать в рассылке internals.

С этим разобрались, теперь давайте сосредоточимся на самом интересном: как же этот ListensTo  работает под капотом?

Прежде всего, кастомные атрибуты — это обычные классы, которые сами помечены атрибутом #[Attribute] ; в исходном RFC этот базовый Attribute  назывался PhpAttribute , но затем его изменили в следующем RFC.

Вот как это будет выглядеть:

#[Attribute]
class ListensTo
{
    public string $event;

    public function __construct(string $event)
    {
        $this->event = $event;
    }
}

Вот и всё — предельно просто, не так ли? Помните о назначении атрибутов: они нужны только для добавления метаданных к классам и методам, не более того. Их нельзя — и не получится — использовать, например, для валидации входных аргументов. Иными словами: из атрибутов у вас не будет доступа к параметрам, переданным в метод. В одном из предыдущих RFC такое поведение допускалось, но в этом RFC всё намеренно сделали проще.

Вернёмся к примеру с подписчиком на события: нам всё ещё нужно прочитать метаданные и где-то зарегистрировать наших подписчиков. Поскольку я пришёл из мира Laravel, для этой задачи я бы выбрал сервис-провайдер, но вы можете использовать любые другие решения.

Вот скучный шаблонный код для контекста:

class EventServiceProvider extends ServiceProvider
{
    // In real life scenarios, 
    //  we'd automatically resolve and cache all subscribers
    //  instead of using a manual array.
    private array $subscribers = [
        ProductSubscriber::class,
    ];

    public function register(): void
    {
        // The event dispatcher is resolved from the container
        $eventDispatcher = $this->app->make(EventDispatcher::class);

        foreach ($this->subscribers as $subscriber) {
            // We'll resolve all listeners registered 
            //  in the subscriber class,
            //  and add them to the dispatcher.
            foreach (
                $this->resolveListeners($subscriber) 
                as [$event, $listener]
            ) {
                $eventDispatcher->listen($event, $listener);
            }       
        }       
    }
}

Обратите внимание: если синтаксис [$event, $listener]  вам незнаком, вы можете быстро во всём разобраться с помощью моей статьи о деструктуризации массивов.

Теперь давайте взглянем на resolveListeners  — именно здесь происходит вся магия.

private function resolveListeners(string $subscriberClass): array
{
    $reflectionClass = new ReflectionClass($subscriberClass);

    $listeners = [];

    foreach ($reflectionClass->getMethods() as $method) {
        $attributes = $method->getAttributes(ListensTo::class);
        
        foreach ($attributes as $attribute) {
            $listener = $attribute->newInstance();
            
            $listeners[] = [
                // The event that's configured on the attribute
                $listener->event,
    
                // The listener for this event 
                [$subscriberClass, $method->getName()],
            ];
        }
    }

    return $listeners;
}

Как видите, читать метаданные таким способом гораздо проще, чем парсить строки docblock'ов. Однако здесь есть две тонкости, на которые стоит обратить внимание.

Во-первых, вызов $attribute->newInstance() . Именно в этот момент создаётся экземпляр нашего кастомного класса атрибута. Он берёт параметры, указанные в объявлении атрибута внутри класса подписчика, и передаёт их в конструктор.

Это означает, что технически вам даже необязательно инстанциировать кастомный атрибут. Вы могли бы напрямую вызвать $attribute->getArguments() . Кроме того, создание экземпляра класса даёт гибкость конструктора для разбора входных данных любым удобным способом. В целом, я бы рекомендовал всегда создавать экземпляр атрибута с помощью newInstance() .

Второй важный момент — использование ReflectionMethod::getAttributes() , функции, которая возвращает все атрибуты метода. В неё можно передать два аргумента для фильтрации результатов.

Чтобы понять, как работает эта фильтрация, нужно знать ещё кое-что об атрибутах. Возможно, для вас это очевидно, но всё же упомяну: к одному и тому же методу, классу, свойству или константе можно добавлять несколько атрибутов.

Например, можно сделать так:

#[
    Route(Http::POST, '/products/create'),
    Autowire,
]
class ProductsCreateController
{
    public function __invoke() { /* … */ }
}

Учитывая это, понятно, почему Reflection*::getAttributes()  возвращает массив. Теперь давайте посмотрим, как можно отфильтровать его вывод.

Допустим, вы парсите маршруты контроллера и вас интересует только атрибут Route . Вы можете просто передать этот класс в качестве фильтра:

$attributes = $reflectionClass->getAttributes(Route::class);

Второй параметр меняет логику фильтрации. Вы можете передать ReflectionAttribute::IS_INSTANCEOF , и тогда вернутся все атрибуты, реализующие указанный интерфейс.

Например, если вы разбираете определения в контейнере, опирающиеся на несколько разных атрибутов, можно сделать так:

$attributes = $reflectionClass->getAttributes(
    ContainerAttribute::class, 
    ReflectionAttribute::IS_INSTANCEOF
);

Это удобное сокращение, встроенное прямо в ядро.

 Присоединяйтесь к более чем 14 тысячам подписчиков моей рассылки: я пишу о PHP, разработке и делюсь новостями этого блога. Подписаться можно, отправив письмо на brendt@stitcher.io. 

Немного теории

Теперь, когда вы представляете, как атрибуты работают на практике, пришло время разобрать теорию, чтобы закрепить понимание. Прежде всего, как я уже вскользь упоминал, атрибуты можно применять в самых разных местах.

В классах, а также в анонимных классах;

#[ClassAttribute]
class MyClass { /* … */ }

$object = new #[ObjectAttribute] class () { /* … */ };

У свойств и констант;

#[PropertyAttribute]
public int $foo;

#[ConstAttribute]
public const BAR = 1;

У методов и функций;

#[MethodAttribute]
public function doSomething(): void { /* … */ }

#[FunctionAttribute]
function foo() { /* … */ }

А также у замыканий;

$closure = #[ClosureAttribute] fn() => /* … */;

И у параметров методов и функций;

function foo(#[ArgumentAttribute] $bar) { /* … */ }

Их можно объявлять до или после docblock'ов;

/** @return void */
#[MethodAttribute]
public function doSomething(): void { /* … */ }

И они могут принимать ноль, один или несколько аргументов, которые определяются конструктором атрибута:

#[Listens(ProductCreatedEvent::class)]
#[Autowire]
#[Route(Http::POST, '/products/create')]

Что касается аргументов, которые можно передавать в атрибут: вы уже видели, что разрешены константы классов, имена ::class  и скалярные типы. Но тут есть важное уточнение: атрибуты принимают в качестве входных аргументов только константные выражения.

Это означает, что разрешены скалярные выражения — даже побитовые сдвиги, — а также ::class , константы, массивы и распаковка массивов, логические выражения и оператор объединения с null. Полный список того, что считается константным выражением, можно найти в исходном коде.

#[AttributeWithScalarExpression(1 + 1)]
#[AttributeWithClassNameAndConstants(PDO::class, PHP_VERSION_ID)]
#[AttributeWithClassConstant(Http::POST)]
#[AttributeWithBitShift(4 >> 1, 4 << 1)]

Настройка атрибутов

По умолчанию атрибуты можно добавлять во всех перечисленных выше местах. Однако их можно настроить так, чтобы они использовались только в определённых контекстах. Например, можно сделать так, чтобы ClassAttribute  можно было применять исключительно к классам и нигде больше. Это настраивается передачей флага в атрибут Attribute  самого класса атрибута.

Выглядит это следующим образом:

#[Attribute(Attribute::TARGET_CLASS)]
class ClassAttribute
{
}

Доступны следующие флаги:

Attribute::TARGET_CLASS
Attribute::TARGET_FUNCTION
Attribute::TARGET_METHOD
Attribute::TARGET_PROPERTY
Attribute::TARGET_CLASS_CONSTANT
Attribute::TARGET_PARAMETER
Attribute::TARGET_ALL

Это битовые флаги, поэтому их можно комбинировать с помощью побитовой операции OR.

#[Attribute(Attribute::TARGET_METHOD|Attribute::TARGET_FUNCTION)]
class ClassAttribute
{
}

Ещё один флаг конфигурации отвечает за повторяемость. По умолчанию один и тот же атрибут нельзя применить дважды, если только он явно не помечен как повторяемый. Это настраивается точно так же, как и цели применения — с помощью битового флага.

#[Attribute(Attribute::IS_REPEATABLE)]
class ClassAttribute
{
}

Обратите внимание, что все эти флаги проверяются только при вызове $attribute->newInstance() , не раньше.

Встроенные атрибуты

После принятия базового RFC появилась возможность добавлять встроенные атрибуты прямо в ядро. Один из таких примеров — атрибут #[Deprecated] , а также популярным примером стал атрибут #[Jit]  — если не знаете, о чём речь, можете почитать мою статью о том, что такое JIT.

Уверен, в будущем мы увидим всё больше и больше встроенных атрибутов.

И напоследок для тех, кто переживает о дженериках: этот синтаксис не будет с ними конфликтовать, если их когда-нибудь всё-таки добавят в PHP, так что беспокоиться не о чем!