Создание расширений для Nette DI
Расширение – это класс, который подключается к компиляции DI-контейнера. Он может программно регистрировать сервисы, проверять собственную секцию конфигурации, изменять сервисы, определённые другими, и даже править порождённый код контейнера. Эта страница научит вас писать такое расширение, объяснит, что и когда происходит и на что стоит обратить внимание.
Расширения – это тот способ, которым пакеты встраиваются в Nette
по-родному: их используют все пакеты nette/*, и ваш может тоже.
Типичное расширение делает одно или несколько из этого:
- интегрирует библиотеку – регистрирует её сервисы в контейнере
и предоставляет удобную проверяемую секцию конфигурации (именно
оттуда берутся секции
mail:илиdatabase:) - автоматизирует регистрацию – регистрирует множество похожих
сервисов в цикле или по правилу, когда перечислять их в
services:было бы утомительно - вносит сквозные изменения – находит сервисы, зарегистрированные другими, и дополняет их, например прикрепляет логгер к каждому сервису с определённым тегом
В повседневной работе над приложением расширение нужно редко: секция services конфигурации покрывает регистрацию и связывание ваших классов. Беритесь за расширение тогда, когда одной конфигурации перестаёт хватать.
Расширение включается в секции extensions. Вот так вы добавите
расширение, представленное классом BlogExtension, под именем
blog:
extensions:
blog: BlogExtension
Если его конструктор принимает аргументы, передайте их прямо там:
extensions:
blog: BlogExtension(%debugMode%)
Как работает компиляция
Чтобы уверенно писать расширения, вам нужно знать одну ключевую вещь: когда выполняется ваш код. Nette не связывает сервисы во время обработки запросов. Вместо этого он компилирует контейнер заранее: читает все конфигурационные файлы, даёт расширениям сделать свою работу и порождает оптимизированный PHP-класс, который сохраняет на диск. Каждый следующий запрос лишь загружает этот готовый класс. Поэтому код вашего расширения выполняется только тогда, когда контейнер (пере)собирается, а не при каждом запросе.
Отсюда важное следствие: во время компиляции сервисов ещё не
существует. Существуют определения – рецепты, описывающие, какого
класса будет каждый сервис, как его создать и что у него потом вызвать.
Определения живут в объекте ContainerBuilder.
Расширение – это, по сути, конфигурация с возможностью писать
код: всё, что можно объявить в секции services:, можно собрать и на
PHP – по условию, в цикле или в ответ на то, что зарегистрировали
другие.
Компиляция идёт фазами, и расширение может вступить в каждую из них:
- проверяются секции конфигурации всех расширений
(
getConfigSchema()) - каждое расширение регистрирует свои сервисы (
loadConfiguration()); пользовательская секцияservices:обрабатывается последней, поэтому последнее слово всегда за приложением - когда все определения на месте и типы сервисов разрешены, расширения
могут их изменить (
beforeCompile()) - порождается класс контейнера; расширения ещё могут поправить его
код (
afterCompile()) и добавить код, который выполнится при старте приложения (инициализация)
В режиме разработки контейнер автоматически перекомпилируется при изменении конфигурационного файла или самого класса расширения: и то, и другое отслеживается как зависимость. Так что вы можете разрабатывать расширения, ни разу не очищая кеш.
Чтобы глубже разобраться, что происходит в каждой фазе –
когда разворачиваются параметры, когда @service становится ссылкой
и когда именно безопасно искать сервисы по типу, – см. Компиляцию в подробностях.
Первое расширение
Вот небольшое, но полноценное расширение. Мы включаем и настраиваем его в одном файле:
extensions:
blog: BlogExtension
blog:
postsPerPage: 5
А вот и весь класс:
use Nette\Schema\Expect;
class BlogExtension extends Nette\DI\CompilerExtension
{
public function getConfigSchema(): Nette\Schema\Schema
{
return Expect::structure([
'postsPerPage' => Expect::int(10),
'allowComments' => Expect::bool(true),
]);
}
public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();
$builder->addDefinition($this->prefix('articles'))
->setFactory(Blog\Articles::class, ['postsPerPage' => $this->config->postsPerPage]);
if ($this->config->allowComments) {
$builder->addDefinition($this->prefix('comments'))
->setFactory(Blog\Comments::class);
}
}
}
getConfigSchema() описывает, что может содержать секция blog:
(названная по ключу, под которым мы зарегистрировали расширение),
включая типы и значения по умолчанию; проверенные значения затем
доступны в $this->config. В loadConfiguration() мы регистрируем
сервисы. Обратите внимание на имена: $this->prefix('articles') даёт
blog.articles, поэтому сервисы разных расширений не могут
столкнуться.
А последние несколько строк показывают, зачем расширения вообще
нужны: сервис comments регистрируется только тогда, когда
комментарии включены. Обычный конфигурационный файл таких решений
принимать не может.
Зарегистрированные так сервисы ведут себя ровно так же, как если бы
были записаны в services:: они создаются лениво по требованию, а
autowiring передаёт их везде, где объявлен тип Blog\Articles.
Следующие главы подробно описывают жизненный цикл расширения, затем API ContainerBuilder, которым вы будете пользоваться внутри расширения, и наконец подводные камни, о которых стоит знать.
Жизненный цикл расширения
Расширение наследует от Nette\DI\CompilerExtension и переопределяет
некоторые из четырёх методов getConfigSchema(), loadConfiguration(),
beforeCompile() и afterCompile(), которые компилятор вызывает в этом
порядке во время компиляции.
getConfigSchema(): Nette\Schema\Schema
Определяет схему секции конфигурации расширения. Благодаря ей
пользователи бесплатно получают проверку и понятные сообщения об
ошибках: опечатка или неверный тип в секции blog: сопровождается
вразумительным сообщением, а вы не пишете ни одной проверки.
Схема описывается с помощью библиотеки Schema и может выражать типы, значения по умолчанию, допустимые значения и многое другое:
public function getConfigSchema(): Nette\Schema\Schema
{
return Expect::structure([
'postsPerPage' => Expect::int(10),
'storage' => Expect::anyOf('files', 'database')->firstIsDefault(),
]);
}
Проверенная конфигурация доступна в $this->config как объект
stdClass (или как массив, если добавить к схеме castTo('array')).
Если значение параметра нельзя знать во время компиляции –
например, оно приходит из переменной окружения, – пометьте его через
dynamic(), например Expect::int()->dynamic(). Подробнее в разделе динамические
параметры.
loadConfiguration()
Место, где расширение регистрирует свои сервисы с помощью ContainerBuilder:
public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();
$builder->addDefinition($this->prefix('articles'))
->setFactory(Blog\Articles::class);
}
Если сервис должен быть доступен и под коротким именем, добавьте псевдоним. По соглашению это делается только тогда, когда расширение зарегистрировано под своим обычным именем, чтобы несколько экземпляров расширения не спорили за него:
if ($this->name === 'blog') {
$builder->addAlias('articles', $this->prefix('articles'));
}
Когда сервисов много, удобнее бывает определить их в отдельном файле
NEON привычным синтаксисом services. Префикс @extension
отсылает к текущему расширению:
services:
articles:
create: MyBlog\ArticlesModel(@connection)
comments:
create: MyBlog\CommentsModel(@connection, @extension.articles)
Эти определения мы загружаем через loadDefinitionsFromConfig(); имена
получают префикс автоматически, а файл отслеживается как зависимость,
поэтому его изменение вызывает перекомпиляцию:
public function loadConfiguration(): void
{
$this->loadDefinitionsFromConfig(
$this->loadFromFile(__DIR__ . '/services.neon')['services'],
);
}
beforeCompile()
Когда вызывается этот метод, в билдере уже есть все определения: ваши, других расширений и из пользовательских конфигурационных файлов. Типы сервисов тоже разрешены, поэтому поиск по типу надёжен. Благодаря этому данная фаза идеальна для исследования и дополнения окончательного графа сервисов.
Обычно вы ищете сервисы по тегу или по типу и дополняете найденные определения:
public function beforeCompile(): void
{
$builder = $this->getContainerBuilder();
foreach ($builder->findByTag('logaware') as $name => $attrs) {
$builder->getDefinition($name)->addSetup('setLogger');
}
}
У вызова setLogger() нет явных аргументов: их подставит autowiring, как и
в фабриках.
Вы можете сотрудничать и с другими зарегистрированными
расширениями, полученными через $this->compiler->getExtensions(), при
желании отфильтрованными по классу или интерфейсу:
foreach ($this->compiler->getExtensions(FooExtension::class) as $extension) {
// ...
}
afterCompile(Nette\PhpGenerator\ClassType $class)
В последней фазе класс контейнера порождается как объект ClassType библиотеки PHP Generator. Он содержит фабричный метод для каждого сервиса и вот-вот будет записан в кеш. Вы ещё можете изменить его код:
public function afterCompile(Nette\PhpGenerator\ClassType $class): void
{
$method = $class->getMethod('__construct');
// ...
}
Эта фаза понадобится вам лишь изредка. Чтобы добавить код, выполняющийся при старте приложения, используйте вместо этого инициализацию:
Код инициализации
Все предыдущие фазы влияют на то, как контейнер собирается. Кроме
того, расширение может выдать код, который выполняется во время
выполнения, сразу после создания контейнера, например чтобы
стартовать сессию или запустить сервисы. Код записывается в объект
$this->initialization его методом addBody():
public function loadConfiguration(): void
{
// сервисы с тегом 'run' должны быть созданы сразу после старта контейнера
$builder = $this->getContainerBuilder();
foreach ($builder->findByTag('run') as $name => $attrs) {
$this->initialization->addBody('$this->getService(?);', [$name]);
}
}
Сам Nette использует инициализацию, например, чтобы автоматически стартовать сессию или отправить защитные HTTP-заголовки. И помните: в отличие от всего остального в расширении, этот код выполняется при каждом запросе, поэтому держите его небольшим.
ContainerBuilder
Nette\DI\ContainerBuilder – объект,
через который расширение общается с компилятором. Он хранит определения всех сервисов и предлагает методы
для их добавления, поиска и изменения. Вы получаете его в
loadConfiguration() и beforeCompile():
$builder = $this->getContainerBuilder();
Добавление сервисов
Регистрация сервиса – то же самое, что вы делаете в секции
services: файла NEON, только записанное на PHP. Каждому ключу
конфигурации соответствует метод определения, поэтому эти две записи
равнозначны:
services:
articles:
create: Blog\Articles(@connection)
setup:
- setLogger(@logger)
tags: [logaware]
$builder->addDefinition($this->prefix('articles'))
->setFactory(Blog\Articles::class, ['@connection'])
->addSetup('setLogger', ['@logger'])
->addTag('logaware');
Определение, возвращаемое addDefinition(), – это ServiceDefinition, предлагающее соответствия ключей
конфигурации: setType() (класс сервиса), setFactory() (как его
создать), setArguments(), addSetup(), addTag() и setAutowired().
addSetup() отражает список setup: и принимает те же формы:
вызов метода addSetup('setLogger', ['@logger']), присваивание свойства
addSetup('$cache', ['@cache']) или вызов у другого сервиса
addSetup('@Tracy\Bar::addPanel', [$panel]).
Помимо обычных сервисов билдер умеет регистрировать генерируемые фабрики, аксессоры и локаторы, у каждого свой метод, возвращающий соответствующий тип определения:
| Метод | Регистрирует |
|---|---|
addDefinition() |
обычный сервис (возвращает ServiceDefinition) |
addFactoryDefinition() |
генерируемую фабрику (интерфейс с методом
create()) |
addAccessorDefinition() |
генерируемый аксессор (интерфейс с методом
get()) |
addLocatorDefinition() |
мультифабрику или локатор, объединяющие несколько фабрик |
addImportedDefinition() |
сервис, передаваемый в контейнер извне во время выполнения |
addAlias() |
второе имя для существующего сервиса |
У фабрики объект, который она создаёт, настраивается через
getResultDefinition(); аксессор же указывает на существующий сервис через
setReference():
$builder->addFactoryDefinition($this->prefix('latteFactory'))
->setImplement(LatteFactory::class)
->getResultDefinition()
->setFactory(Latte\Engine::class)
->addSetup('setStrictTypes', [true]);
addLocatorDefinition() и addImportedDefinition() нужны редко: такие сервисы
обычно возникают из ключей implement: и импортируемых сервисов в NEON,
а не пишутся вручную.
Поиск и изменение сервисов
Для поиска и обхода существующих определений билдер предлагает:
| Метод | Описание |
|---|---|
getDefinition(string $name) |
определение с заданным именем (выбрасывает исключение, если его нет) |
hasDefinition(string $name) |
существует ли определение или псевдоним с таким именем |
getDefinitions() |
все определения |
removeDefinition(string $name) |
удаляет определение |
getByType(string $type) |
имя autowired-сервиса этого типа или null |
getDefinitionByType(string $type) |
autowired-определение этого типа |
findByType(string $type) |
все определения этого типа парами имя => определение |
findByTag(string $tag) |
сервисы с этим тегом парами имя => значение тега |
addExcludedClasses(array $types) |
исключает классы и интерфейсы из autowiring |
Удобный приём – использовать getByType(), чтобы узнать, существует
ли сервис вообще, например чтобы подключиться к логгеру, только если он
в приложении есть:
if ($builder->getByType(Psr\Log\LoggerInterface::class)) {
$builder->getDefinition($this->prefix('articles'))
->addSetup('setLogger');
}
Типы определений
Каждый метод add*Definition() возвращает свой вид определения. Все
они наследуют от общего предка Nette\DI\Definitions\Definition:
ServiceDefinition– обычный сервис; настраивается черезsetType(),setFactory(),addSetup(),addTag()иsetAutowired()FactoryDefinition– генерируемая фабрика: интерфейс, методcreate()которого при каждом вызове возвращает новый объектAccessorDefinition– генерируемый аксессор: интерфейс, методget()которого возвращает существующий сервисLocatorDefinition– мультифабрика или локатор, объединяющие несколько фабрик или аксессоров в одном интерфейсеImportedDefinition– сервис, который контейнер не создаёт сам, а получает извне во время выполнения
Помните, что getDefinition() возвращает тот вид определения, который
живёт под заданным именем. Если ваш код может столкнуться с
генерируемой фабрикой, сначала проверьте тип и настройте создаваемый
объект через getResultDefinition():
$def = $builder->getDefinition($name);
if ($def instanceof Nette\DI\Definitions\FactoryDefinition) {
$def = $def->getResultDefinition();
}
$def->addSetup('setLogger');
Советы и подводные камни
Время компиляции и время выполнения
Самый частый источник путаницы: код расширения выполняется тогда, когда контейнер компилируется, а не когда приложение обрабатывает запросы. На практике это значит:
- Расширение никогда не работает с экземплярами сервисов: их ещё нет.
Не создавайте сервисы через
new; зарегистрируйте определение и позвольте контейнеру их создать. - Все значения конфигурации впекаются в порождённый код. Значение,
которое может различаться в разных средах (путь, пароль из
getenv()), нужно пометить как динамическое, иначе оно застынет во время компиляции. - Строки, передаваемые в
$this->initialization->addBody(), сейчас не выполняются: это PHP-код, помещаемый в контейнер и выполняемый при каждом запросе.
Зависимости от файлов
Контейнер перекомпилируется при изменении конфигурационных файлов или классов расширений. Но если ваше расширение читает какой-то другой файл – список сущностей, XML-конфигурацию библиотеки, – контейнер об этом никак не узнает. Регистрируйте такие файлы через:
$builder->addDependency($file);
Иначе вас ждёт классическая загадка: вы правите файл, а приложение
продолжает вести себя по-старому, и изменение проявляется только
тогда, когда контейнер пересобирается по какой-то другой причине.
(Файлы, прочитанные через loadFromFile(), отслеживаются
автоматически.)
Условная регистрация
Расширение может подстраиваться под окружение. Необязательные
интеграции обычно оборачивают в class_exists():
if (class_exists(Symfony\Component\Console\Command\Command::class)) {
$builder->addDefinition($this->prefix('command'))
->setFactory(Blog\Console\SitemapCommand::class);
}
А значения вроде %debugMode% лучше всего передавать через
конструктор расширения:
extensions:
blog: BlogExtension(%debugMode%)
class BlogExtension extends Nette\DI\CompilerExtension
{
public function __construct(
private bool $debugMode = false,
) {}
}
Типичный случай применения – регистрация панели Tracy только в режиме разработки.
Сложные аргументы
Иногда аргумент фабрики или вызова setup – не обычное значение, не имя
класса и не ссылка @service. Для таких случаев есть:
new Nette\DI\Definitions\Statement(Blog\Panel::class, [$args])– объект, создаваемый на месте, „анонимный сервис“, используемый как аргументnew Nette\DI\Definitions\Reference('blog.articles')– ссылка на сервис, объектный аналог строки@name$builder::literal('PHP_SAPI')– кусок сырого PHP-кода, вставляемый в порождённый контейнер как есть
Пример – регистрация панели Tracy:
$builder->getDefinition($this->prefix('articles'))
->addSetup('@Tracy\Bar::addPanel', [
new Nette\DI\Definitions\Statement(Blog\ArticlesPanel::class),
]);
Экспортируемые теги и типы
Экспорт метаданных можно ограничить в
конфигурации так, чтобы скомпилированный контейнер сохранял только те
теги и типы autowiring, которые приложение действительно использует. Если
ваше расширение во время выполнения получает сервисы через
$container->findByTag() или $container->getByType(), такое ограничение
может убрать как раз те метаданные, на которые вы полагаетесь.
Чтобы этого не случилось, скажите компилятору, какие теги и типы должны экспортироваться всегда:
public function loadConfiguration(): void
{
// этот тег будет экспортирован всегда, даже при ограниченном экспорте
$this->compiler->addExportedTag('event.subscriber');
// этот тип будет всегда доступен для getByType()
$this->compiler->addExportedType(Nette\Database\Connection::class);
}
Оба метода только добавляют к экспортируемым метаданным, они никогда
не перебивают конфигурацию di › export приложения. Поэтому, когда
приложение ограничивает экспорт списком, нужные вашему расширению
теги и типы остаются включёнными; только полное отключение экспорта
тегов (tags: false) отбрасывает их вместе со всем остальным.