Продвинутое

Пакеты

Пакет — обычная Composer-зависимость, чей код участвует в жизни приложения: даёт контроллеры, команды, бины, сущности, задачи планировщика. От библиотеки он отличается только этим участием, и включается одной строкой — #[Import].

Подключение #[Import]Границы по арностиПриоритет приложение последнее

Что это и зачем

Проблема. Часть кода хочется вынести и переиспользовать: биллинг, авторизацию, админку, набор сущностей. Composer это умеет — но обычная зависимость для фреймворка невидима. Её контроллеры не станут маршрутами, её #[Bean] не попадут в контейнер, её #[Scheduled] никогда не запустятся: фреймворк просто не знает, что в этот каталог надо заглянуть.

Решение. Приложение называет пакет явно, и с этого момента его код сканируется наравне со своим.

bootstrap.php
use Flytachi\Winter\Kernel\App\Attribute\Import;

#[Import('acme/billing', '/billing')]   // сканируется + контроллеры под /billing
#[Import('acme/toolkit')]               // сканируется, маршрутов не монтирует
#[EnableWeb]
final class Application extends WinterApplication {  }

Почему явно, а не всё подряд

Сканер не просто читает файлы — он делает require_once каждому, где нашёл объявление класса. Пройтись по всему vendor/ означало бы выполнить произвольный код из каждой зависимости на старте.

Поэтому подключение именное. В Spring @ComponentScan требует явные пакеты по той же причине.

Принцип

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

Аддитивное складывается. Единственное принадлежит приложению.

Маршрутов, бинов, каналов логов, правил CORS, health-индикаторов, миграций, задач может быть много — пакет вправе добавлять. Сервер один, набор процессов один — это приложение.

Приложение применяется последним.

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


Справочник

#[Import]

php
#[Import('acme/billing', '/billing')]
#[Import('acme/toolkit')]
#[Import('acme/optional', required: false)]
Аргумент Тип По умолчанию Назначение
$package string Имя пакета в Composer
$prefix ?string null URL-префикс для его контроллеров; null — маршруты не монтируются. Пустой и '/' отвергаются
$required bool true Падать, если пакет не установлен

Атрибут повторяемый, объявляется на классе приложения. Порядок объявления — это порядок применения.

Префикс необязателен

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

php
#[Import('acme/toolkit')]   // код в скане, маршрутов нет

«Сканируй пакет» и «примонтируй его маршруты сюда» — два независимых решения, и второе нужно не всем.

Пустой префикс и '/' не годятся: оба сводятся к корню, и маршрут получается вида //users — такой путь не совпадёт ни с одним запросом. Раньше это монтировалось молча, теперь отвергается на старте:

text
Plugin 'acme/billing': prefix '/' is not a mount point — every route would start
with '//' and never match a request. Pass a real prefix such as '/billing',
or omit it to import the package without mounting its routes.

Способов два, и оба явные: настоящий префикс — или ни одного аргумента.

Необязательный пакет

php
#[Import('acme/analytics', '/analytics', required: false)]

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

Что видно в логе

Каждый импорт называет себя на старте — иначе «а этот пакет вообще подключился?» приходилось выяснять чтением bootstrap.php:

text
[NOTIC] -sys- (Application): Package acme/billing imported — routes mounted under /billing.
[NOTIC] -sys- (Application): Package acme/toolkit imported — no routes mounted.
[WARN ] -sys- (Application): Optional package acme/analytics is not installed — import skipped.

Пропущенный необязательный пакет — предупреждение, а не заметка: required: false означает «продолжай без него», и до сих пор это продолжение выглядело ровно как наличие пакета — тот же тихий старт, минус функциональность, о которой никто не сказал.

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

Вклад, которому некуда приложиться

Пакет может привезти то, для чего у приложения не включён нужный переключатель. Ничего не ломается — оно просто никогда не происходит, и искать причину начинают в пакете. Поэтому о таком говорится вслух:

text
[WARN ] Package acme/billing brings 7 controller(s) but was imported without a prefix,
      so none of its routes are mounted. Pass a prefix to #[Import] to mount them.
[WARN ] Package acme/billing brings 7 controller(s), but the application declares no
      #[EnableWeb], so nothing is served.
[WARN ] Package acme/notify brings 3 #[Scheduled] method(s), but the application declares
      no #[EnableScheduler], so none of them run.

Решение остаётся вашим — фреймворк ничего не включает сам, он только называет недостающий переключатель. Включили нужный #[Enable*] — предупреждения исчезают.

Подсчёт едет на том же проходе скана, который делается для пакета в любом случае: отдельного обхода файлов нет, а на 182 классах проверки занимают 0.03 и 0.15 мс.

Что попадает в скан

Не весь каталог пакета, а то, что пакет объявил о себе сам — autoload.psr-4 из его composer.json:

vendor/acme/billing/composer.json
{ "autoload": { "psr-4": { "Acme\\Billing\\": "src/" } } }

→ сканируется src/, и только он.

Почему не угадываем каталог

Раньше каталог определялся догадкой: src/, если есть, иначе весь корень пакета. Оба варианта ломались на пакете с другой раскладкой — например с кодом в main/: маршрутизатор его пропускал молча, а boot-скан читал корень целиком.

Чтение корня означает require_once для resources/ с шаблонами — а шаблон при require выполняется, — и для bootstrap.php, если пакет сам является приложением: его класс Application сталкивается именем с хозяйским.

Composer уже знает ответ, и берётся он оттуда. Пакет без autoload.psr-4 сканировать нечего — #[Import] на него отвечает ошибкой с объяснением.

Что пакет может добавить

Что Как складывается
Контроллеры и маршруты под префиксом пакета; без префикса не монтируются
Бины, #[Configuration], #[Bean] попадают в общий контейнер
Классы с #[Singleton], #[Request], #[Transient] как свои
#[Async]-методы проксируются, если у приложения есть #[EnableAsync]
WebConfigurer правила CORS складываются в общий реестр
LoggingConfigurer каналы складываются в общий реестр
HealthContributor попадают в /actuator/health, если актуатор включён
Cmd / CmdCustom видны в call script list
#[Scheduled] запускаются, если у приложения есть #[EnableScheduler]
Сущности и DbConfig доступны call db --plugin=<имя> и --plugins

Что решает только приложение

Манифест процессов

#[EnableWeb], #[EnableScheduler], #[EnableProcess], #[EnableDaemon], #[EnableActuator], #[EnableAsync] читаются только с класса приложения. Пакет их объявить не может, и его собственные, если он сам является приложением, не читаются.

Эти атрибуты решают не поведение кода, а форму процесса: поднять сервер, породить воркеров, сменить семантику вызовов. Если бы их читали с пакетов, один composer require менял бы топологию развёртывания, а узнавал бы об этом разработчик из docker ps.

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

Настройки сервера

Адрес и тюнинг Swoole живут в отдельном контракте:

php
interface WebConfigurer      // много, складывается, доступен пакетам
{
  public function configureCors(CorsRegistry $cors): void;
}

interface ServerConfigurer   // один, только приложение
{
  public function configureServer(ServerSettings $server, ApplicationArguments $args): void;
}

Разделение по арности, а не по «вебовости». CORS — набор правил: два вклада складываются, результат объединение. ServerSettings — один объект, изменяемый на месте: два вклада не складываются, второй затирает первый.

ServerConfigurer, найденный внутри пакета, — ошибка загрузки с именем класса и пакета:

text
Only the application may configure the server; found Acme\Billing\Tuning in acme/billing.
A package may implement WebConfigurer (CORS) but not ServerConfigurer.

Больше одного ServerConfigurer в самом приложении — тоже ошибка: у сервера один хозяин.

Два вида переключателей

Атрибуты #[Enable*] делятся на два вида, и для пакетов разница между ними — главное, что стоит понять.

Включил один раз — находит везде

#[EnableWeb], #[EnableScheduler], #[EnableActuator], #[EnableAsync]

Эти включают возможность, а что именно она будет обслуживать, фреймворк ищет сам — одинаково в вашем коде и в коде импортированных пакетов.

Скажем, вы подключили пакет для рассылки уведомлений, и внутри него есть метод, помеченный #[Scheduled] — раз в час подчищать очередь. Вам не нужно ни знать имя этого метода, ни где-то его перечислять. Достаточно, чтобы у приложения стоял #[EnableScheduler]: планировщик обойдёт и ваш код, и код пакета, и возьмёт всё, что помечено.

Обратное тоже верно. Не поставили #[EnableScheduler] — не запустится ничего: ни задачи пакета, ни ваши собственные. Планировщика в приложении просто нет.

Атрибут Что включает Что находит сам
#[EnableWeb] HTTP-сервер контроллеры — ваши и пакетов с префиксом
#[EnableScheduler] планировщик методы с #[Scheduled] везде
#[EnableActuator] эндпоинты /actuator/* классы HealthContributor везде
#[EnableAsync] асинхронные вызовы методы с #[Async] везде

Назвали явно — запустится только названное

#[EnableProcess], #[EnableDaemon]

Эти принимают обязательный аргумент — класс:

php
#[EnableProcess(AcmeNotificationsWorkerDispatcher::class)]
#[EnableDaemon(MainDaemonImportDaemon::class)]

Автоматики здесь нет намеренно. Фоновый процесс — это не «обработать всё, что найдётся», а «запустить вот этот воркер и держать его живым, пока приложение работает». Фреймворк не может догадаться, нужен ли вам воркер из пакета, сколько таких воркеров поднять и в каком порядке — это решение о форме развёртывания, и принимает его тот, кто разворачивает.

Поэтому процесс из пакета включается ровно так же, как свой: вы называете его класс. Атрибут повторяемый — назовите столько, сколько нужно.

Сам пакет запустить процесс не может. Это защита: иначе обычная установка зависимости молча добавляла бы вам ещё один работающий процесс, и заметили бы вы это не раньше, чем посмотрели список процессов на сервере.

Нужен ли #[Import], если процесс из пакета

Чтобы запустить — нет: имя класса найдёт обычный автозагрузчик Composer.

Но всё, чем этот процесс пользуется внутри своего пакета — бины из #[Configuration], #[Async]-методы, свои конфигурации — появится только с #[Import]. Практическое правило простое: пользуетесь пакетом — импортируйте его; класс процесса при этом всё равно назовите отдельно.

Порядок применения

text
1. пакеты — в порядке объявления #[Import]
2. приложение — всегда после

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

Пример: пакет и приложение оба настраивают CORS для https://admin.example.com. Побеждает приложение — оно применяется последним.

Манифест не наследуется

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

php
#[EnableWeb]
abstract class BaseApp extends WinterApplication {}

final class Application extends BaseApp {}   // #[EnableWeb] здесь НЕ действует

Наследование не вводится намеренно: ценность манифеста в том, что читаешь один класс и знаешь всё, что поднимется. Но и молчать об этом нельзя — атрибут на предке даёт ошибку загрузки:

text
#[EnableWeb] is declared on BaseApp, but PHP does not inherit attributes —
it has no effect on Application. Declare it on the application class itself.

Сценарии

WebConfigurer только в пакете

Работает: его правила CORS попадают в общий реестр. Сервер пакет не настраивает — этой возможности у WebConfigurer нет.

WebConfigurer и там, и там

Оба применяются, вклады складываются. Конфликта нет по построению: CORS — набор правил, а не одно значение. При совпадении побеждает приложение.

У пакета есть контроллеры, у приложения нет #[EnableWeb]

Сервер не поднимается, маршруты не обслуживаются. Пакет не может это исправить — и не должен: он не знает, как его развернут. Приложение решает, нужен ли ему HTTP.

Пакет сам является приложением

Сканируется только то, что указано в его autoload.psr-4. Его bootstrap.php, storage/ и docker/ в скан не попадают, столкновения имён не возникает, а его #[Enable*] не читаются.

Инструменты

bash
php call script list                      # команды, включая пакетные
php call mapping                          # маршруты, включая пакетные
php call db ping --plugins                # базы всех импортированных пакетов
php call db migrate --plugin=acme/billing # миграции одного пакета

Опция --plugin принимает имя пакета в Composer.

Дальше