Пакеты
Пакет — обычная Composer-зависимость, чей код участвует в жизни приложения: даёт
контроллеры, команды, бины, сущности, задачи планировщика. От библиотеки он отличается
только этим участием, и включается одной строкой — #[Import].
Что это и зачем
Проблема. Часть кода хочется вынести и переиспользовать: биллинг, авторизацию,
админку, набор сущностей. Composer это умеет — но обычная зависимость для фреймворка
невидима. Её контроллеры не станут маршрутами, её #[Bean] не попадут в контейнер, её
#[Scheduled] никогда не запустятся: фреймворк просто не знает, что в этот каталог надо
заглянуть.
Решение. Приложение называет пакет явно, и с этого момента его код сканируется наравне со своим.
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]
#[Import('acme/billing', '/billing')]
#[Import('acme/toolkit')]
#[Import('acme/optional', required: false)]| Аргумент | Тип | По умолчанию | Назначение |
|---|---|---|---|
$package |
string |
— | Имя пакета в Composer |
$prefix |
?string |
null |
URL-префикс для его контроллеров; null — маршруты не монтируются. Пустой и '/' отвергаются |
$required |
bool |
true |
Падать, если пакет не установлен |
Атрибут повторяемый, объявляется на классе приложения. Порядок объявления — это порядок применения.
Префикс необязателен
Пакет из одних сервисов, команд и сущностей не должен выдумывать себе URL:
#[Import('acme/toolkit')] // код в скане, маршрутов нет«Сканируй пакет» и «примонтируй его маршруты сюда» — два независимых решения, и второе нужно не всем.
Пустой префикс и '/' не годятся: оба сводятся к корню, и маршрут получается вида
//users — такой путь не совпадёт ни с одним запросом. Раньше это монтировалось молча,
теперь отвергается на старте:
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.Способов два, и оба явные: настоящий префикс — или ни одного аргумента.
Необязательный пакет
#[Import('acme/analytics', '/analytics', required: false)]Пакета нет — импорт пропускается. Пакет есть — работает как обычный. Для функциональности, которую ставят не везде.
Что видно в логе
Каждый импорт называет себя на старте — иначе «а этот пакет вообще подключился?»
приходилось выяснять чтением bootstrap.php:
[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 — заметки об импорте
исчезнут, предупреждение об отсутствующем пакете останется.
Вклад, которому некуда приложиться
Пакет может привезти то, для чего у приложения не включён нужный переключатель. Ничего не ломается — оно просто никогда не происходит, и искать причину начинают в пакете. Поэтому о таком говорится вслух:
[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:
{ "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 живут в отдельном контракте:
interface WebConfigurer // много, складывается, доступен пакетам
{
public function configureCors(CorsRegistry $cors): void;
}
interface ServerConfigurer // один, только приложение
{
public function configureServer(ServerSettings $server, ApplicationArguments $args): void;
}Разделение по арности, а не по «вебовости». CORS — набор правил: два вклада складываются,
результат объединение. ServerSettings — один объект, изменяемый на месте: два вклада не
складываются, второй затирает первый.
ServerConfigurer, найденный внутри пакета, — ошибка загрузки с именем класса и
пакета:
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]
Эти принимают обязательный аргумент — класс:
#[EnableProcess(AcmeNotificationsWorkerDispatcher::class)]
#[EnableDaemon(MainDaemonImportDaemon::class)]Автоматики здесь нет намеренно. Фоновый процесс — это не «обработать всё, что найдётся», а «запустить вот этот воркер и держать его живым, пока приложение работает». Фреймворк не может догадаться, нужен ли вам воркер из пакета, сколько таких воркеров поднять и в каком порядке — это решение о форме развёртывания, и принимает его тот, кто разворачивает.
Поэтому процесс из пакета включается ровно так же, как свой: вы называете его класс. Атрибут повторяемый — назовите столько, сколько нужно.
Сам пакет запустить процесс не может. Это защита: иначе обычная установка зависимости молча добавляла бы вам ещё один работающий процесс, и заметили бы вы это не раньше, чем посмотрели список процессов на сервере.
Нужен ли #[Import], если процесс из пакета
Чтобы запустить — нет: имя класса найдёт обычный автозагрузчик Composer.
Но всё, чем этот процесс пользуется внутри своего пакета — бины из #[Configuration],
#[Async]-методы, свои конфигурации — появится только с #[Import]. Практическое
правило простое: пользуетесь пакетом — импортируйте его; класс процесса при этом всё
равно назовите отдельно.
Порядок применения
1. пакеты — в порядке объявления #[Import]
2. приложение — всегда послеДля складывающихся реестров это влияет только на порядок записей. Для всего, где последний выигрывает, гарантирует: приложение перекрывает пакет, никогда наоборот.
Пример: пакет и приложение оба настраивают CORS для https://admin.example.com.
Побеждает приложение — оно применяется последним.
Манифест не наследуется
PHP не отдаёт атрибуты вверх по иерархии, поэтому общий базовый класс приложения не работает:
#[EnableWeb]
abstract class BaseApp extends WinterApplication {}
final class Application extends BaseApp {} // #[EnableWeb] здесь НЕ действуетНаследование не вводится намеренно: ценность манифеста в том, что читаешь один класс и знаешь всё, что поднимется. Но и молчать об этом нельзя — атрибут на предке даёт ошибку загрузки:
#[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*] не
читаются.
Инструменты
php call script list # команды, включая пакетные
php call mapping # маршруты, включая пакетные
php call db ping --plugins # базы всех импортированных пакетов
php call db migrate --plugin=acme/billing # миграции одного пакетаОпция --plugin принимает имя пакета в Composer.
Дальше
- Конфигурация веба —
WebConfigurerиServerConfigurer - Внедрение зависимостей — как бины пакета попадают в контейнер
- Маршрутизация — как строится URL под префиксом
- Actuator — health-индикаторы из пакетов