Tvorba rozšíření pro Nette DI
Rozšíření je třída, která se zapojuje do kompilace DI kontejneru. Umí programově registrovat služby, validovat vlastní konfigurační sekci, upravovat služby definované ostatními a dokonce zasáhnout do vygenerovaného kódu kontejneru. Na této stránce se naučíte, jak rozšíření napsat, co se kdy děje a na co si dát pozor.
Rozšíření jsou způsob, jakým se balíčky integrují do Nette nativní cestou: používají je všechny balíčky
nette/* a může je používat i ten váš. Typické rozšíření dělá jednu nebo více z těchto věcí:
- integruje knihovnu – zaregistruje její služby v kontejneru a nabídne přívětivou, validovanou konfigurační
sekci (přesně odtud pocházejí sekce
mail:nebodatabase:) - automatizuje registraci – zaregistruje mnoho podobných služeb ve smyčce nebo podle pravidla tam, kde by jejich
vypisování v
services:bylo úmorné - provádí plošné změny – najde služby zaregistrované ostatními a doplní je, např. připojí logger ke každé službě s určitým tagem
Pro každodenní práci na aplikaci rozšíření většinou nepotřebujete – registraci a propojování vašich tříd pokryje sekce services v konfiguraci. Po rozšíření sáhněte, když samotná konfigurace přestane stačit.
Rozšíření se aktivuje v sekci extensions. Takto přidáme rozšíření reprezentované třídou
BlogExtension pod názvem blog:
extensions:
blog: BlogExtension
Pokud jeho konstruktor přijímá argumenty, předáme je rovnou tam:
extensions:
blog: BlogExtension(%debugMode%)
Jak probíhá kompilace
Abyste psali rozšíření s jistotou, potřebujete vědět jednu klíčovou věc: kdy váš kód běží. Nette nepropojuje služby během obsluhy požadavků. Místo toho kontejner předem zkompiluje: přečte všechny konfigurační soubory, nechá rozšíření udělat svou práci a vygeneruje optimalizovanou PHP třídu, kterou uloží na disk. Každý další požadavek už jen načte tuto hotovou třídu. Kód vašeho rozšíření tedy běží jen ve chvíli, kdy se kontejner (znovu) sestavuje – ne při každém požadavku.
Z toho plyne důležitý důsledek: během kompilace ještě žádné služby neexistují. Existují jen definice –
recepty popisující, jakou třídu bude každá služba mít, jak se vytvoří a co se na ní potom zavolá. Definice drží
objekt ContainerBuilder. Rozšíření je v podstatě skriptovatelná konfigurace:
cokoli lze deklarovat v sekci services:, můžete stejně tak poskládat v PHP – podmíněně, ve smyčkách
nebo v reakci na to, co zaregistrovali ostatní.
Kompilace probíhá po fázích a rozšíření může vstoupit do každé z nich:
- zvaliduje se konfigurační sekce všech rozšíření (
getConfigSchema()) - každé rozšíření zaregistruje své služby (
loadConfiguration()); uživatelská sekceservices:se zpracovává jako poslední, takže aplikace má vždy poslední slovo - jakmile jsou všechny definice na místě a typy služeb vyřešené, mohou je rozšíření upravovat
(
beforeCompile()) - vygeneruje se třída kontejneru; rozšíření mohou ještě zasáhnout do jejího kódu (
afterCompile()) a přidat kód, který poběží při startu aplikace (inicializace)
Ve vývojářském režimu se kontejner automaticky překompiluje, kdykoli změníte konfigurační soubor nebo samotnou třídu rozšíření – obojí se sleduje jako závislost. Rozšíření tak můžete vyvíjet, aniž byste kdy mazali cache.
První rozšíření
Tady je malé, ale kompletní rozšíření. Aktivujeme ho a nakonfigurujeme ve stejném souboru:
extensions:
blog: BlogExtension
blog:
postsPerPage: 5
A toto je celá třída:
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() popisuje, co smí obsahovat sekce blog: (pojmenovaná podle klíče, pod kterým
jsme rozšíření zaregistrovali), včetně typů a výchozích hodnot – zvalidované hodnoty jsou pak k dispozici v
$this->config. V loadConfiguration() registrujeme služby. Všimněte si názvů:
$this->prefix('articles') vytvoří blog.articles, takže se služby různých rozšíření nemohou
srazit.
A poslední řádky ukazují, proč rozšíření vůbec existují: služba comments se zaregistruje jen tehdy,
když jsou komentáře povolené. Obyčejný konfigurační soubor takhle rozhodovat neumí.
Takto zaregistrované služby se chovají úplně stejně, jako by byly zapsané v services: – vytvářejí se
líně na vyžádání a autowiring je předá všude tam, kde je type-hint Blog\Articles.
Následující kapitoly popisují podrobně životní cyklus rozšíření, dále API ContainerBuilderu, které budete v rozšíření používat, a nakonec úskalí, která stojí za to znát.
Životní cyklus rozšíření
Rozšíření dědí od Nette\DI\CompilerExtension a přepisuje některé ze
čtyř metod getConfigSchema(), loadConfiguration(), beforeCompile() a
afterCompile(), které kompilátor během kompilace volá v tomto pořadí.
getConfigSchema(): Nette\Schema\Schema
Definuje schéma konfigurační sekce rozšíření. Díky němu dostanou uživatelé validaci a jasné chybové hlášky
zadarmo: překlep nebo špatný typ v sekci blog: se ohlásí srozumitelnou zprávou, aniž byste napsali jedinou
kontrolu.
Schéma se popisuje knihovnou Schema a umí vyjádřit typy, výchozí hodnoty, povolené hodnoty a mnohem víc:
public function getConfigSchema(): Nette\Schema\Schema
{
return Expect::structure([
'postsPerPage' => Expect::int(10),
'storage' => Expect::anyOf('files', 'database')->firstIsDefault(),
]);
}
Zvalidovaná konfigurace je k dispozici v $this->config jako objekt stdClass (nebo jako pole,
pokud ke schématu připojíte castTo('array')).
Pokud hodnotu volby nelze znát v čase kompilace – pochází třeba z proměnné prostředí – označte ji pomocí
dynamic(), např. Expect::int()->dynamic(). Více v dynamických parametrech.
loadConfiguration()
Místo, kde rozšíření registruje své služby, a to pomocí ContainerBuilderu:
public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();
$builder->addDefinition($this->prefix('articles'))
->setFactory(Blog\Articles::class);
}
Má-li být služba dostupná i pod krátkým názvem, přidejte alias. Podle konvence se to dělá jen tehdy, když je rozšíření zaregistrované pod svým obvyklým názvem, aby se o alias nemohlo přetahovat víc instancí rozšíření:
if ($this->name === 'blog') {
$builder->addAlias('articles', $this->prefix('articles'));
}
Když je služeb hodně, může být pohodlnější definovat je v samostatném NEON souboru známou syntaxí services. Prefix @extension odkazuje na aktuální
rozšíření:
services:
articles:
create: MyBlog\ArticlesModel(@connection)
comments:
create: MyBlog\CommentsModel(@connection, @extension.articles)
Tyto definice načteme metodou loadDefinitionsFromConfig(); názvy se oprefixují automaticky a soubor se sleduje
jako závislost, takže jeho změna vyvolá překompilování:
public function loadConfiguration(): void
{
$this->loadDefinitionsFromConfig(
$this->loadFromFile(__DIR__ . '/services.neon')['services'],
);
}
beforeCompile()
Ve chvíli volání této metody už builder drží všechny definice: vaše, definice ostatních rozšíření i ty z uživatelských konfiguračních souborů. Typy služeb jsou také vyřešené, takže vyhledávání podle typu je spolehlivé. Tato fáze je proto ideální pro prozkoumání a doplnění výsledného grafu služeb.
Typicky vyhledáte služby podle tagu nebo typu a nalezené definice doplníte:
public function beforeCompile(): void
{
$builder = $this->getContainerBuilder();
foreach ($builder->findByTag('logaware') as $name => $attrs) {
$builder->getDefinition($name)->addSetup('setLogger');
}
}
Volání setLogger() nemá žádné explicitní argumenty – dodá je autowiring, stejně jako to dělá
u továren.
Můžete také spolupracovat s ostatními zaregistrovanými rozšířeními, která získáte přes
$this->compiler->getExtensions(), volitelně filtrovaná podle třídy nebo rozhraní:
foreach ($this->compiler->getExtensions(FooExtension::class) as $extension) {
// ...
}
afterCompile (Nette\PhpGenerator\ClassType $class)
V poslední fázi je třída kontejneru vygenerovaná jako objekt ClassType knihovny PHP Generator. Obsahuje tovární metodu pro každou službu a čeká na zápis do cache. Její kód můžete ještě upravit:
public function afterCompile(Nette\PhpGenerator\ClassType $class): void
{
$method = $class->getMethod('__construct');
// ...
}
Tuto fázi budete potřebovat jen výjimečně. Pro přidání kódu, který poběží při startu aplikace, použijte raději inicializaci:
Inicializační kód
Všechny předchozí fáze ovlivňují, jak se kontejner sestaví. Kromě toho může rozšíření vygenerovat kód,
který poběží za běhu aplikace, hned po vytvoření kontejneru – třeba nastartovat session nebo spustit služby.
Kód se zapisuje do objektu $this->initialization jeho metodou addBody():
public function loadConfiguration(): void
{
// služby s tagem 'run' se musí vytvořit hned po startu kontejneru
$builder = $this->getContainerBuilder();
foreach ($builder->findByTag('run') as $name => $attrs) {
$this->initialization->addBody('$this->getService(?);', [$name]);
}
}
Samotné Nette inicializaci používá například k automatickému startu session nebo k odeslání bezpečnostních HTTP hlaviček. A pamatujte: na rozdíl od všeho ostatního v rozšíření tento kód běží při každém požadavku, takže ho udržujte malý.
ContainerBuilder
Nette\DI\ContainerBuilder je objekt, kterým
rozšíření mluví s kompilátorem. Drží definice všech služeb a nabízí metody
pro jejich přidávání, vyhledávání a úpravy. Získáte ho v loadConfiguration() a
beforeCompile():
$builder = $this->getContainerBuilder();
Přidávání služeb
Registrace služby je totéž, co děláte v sekci services: NEON souboru – jen zapsané v PHP. Každý
konfigurační klíč má odpovídající metodu na definici, takže tyto dva zápisy jsou rovnocenné:
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');
Definice vrácená metodou addDefinition() je ServiceDefinition nabízející
protějšky konfiguračních klíčů: setType() (třída služby), setFactory() (jak ji vytvořit),
setArguments(), addSetup(), addTag() a setAutowired().
addSetup() odpovídá seznamu setup: a přijímá stejné formy: volání metody
addSetup('setLogger', ['@logger']), přiřazení do vlastnosti addSetup('$cache', ['@cache']) nebo
volání na jiné službě addSetup('@Tracy\Bar::addPanel', [$panel]).
Kromě běžných služeb umí builder zaregistrovat i generované továrny, accessory a locatory – každý svou metodou, která vrací odpovídající typ definice:
| Metoda | Registruje |
|---|---|
addDefinition() |
běžnou službu (vrací ServiceDefinition) |
addFactoryDefinition() |
generovanou továrnu (rozhraní s metodou
create()) |
addAccessorDefinition() |
generovaný accessor (rozhraní s metodou
get()) |
addLocatorDefinition() |
multifactory / locator sdružující více továren |
addImportedDefinition() |
službu předanou kontejneru zvenčí za běhu |
addAlias() |
druhý název pro existující službu |
U továrny nastavíte vytvářený objekt přes getResultDefinition(); accessor místo toho odkazuje na
existující službu přes setReference():
$builder->addFactoryDefinition($this->prefix('latteFactory'))
->setImplement(LatteFactory::class)
->getResultDefinition()
->setFactory(Latte\Engine::class)
->addSetup('setStrictTypes', [true]);
addLocatorDefinition() a addImportedDefinition() potřebujete jen zřídka – takové služby
obvykle vznikají z klíčů implement: a imported služeb v NEONu, ne ručním zápisem.
Vyhledávání a úprava služeb
Pro vyhledávání a procházení existujících definic builder nabízí:
| Metoda | Popis |
|---|---|
getDefinition(string $name) |
definici daného názvu (vyhodí výjimku, pokud chybí) |
hasDefinition(string $name) |
zda definice nebo alias daného názvu existuje |
getDefinitions() |
všechny definice |
removeDefinition(string $name) |
odstraní definici |
getByType(string $type) |
název autowirované služby daného typu, nebo null |
getDefinitionByType(string $type) |
autowirovanou definici daného typu |
findByType(string $type) |
všechny definice daného typu jako dvojice name => definice |
findByTag(string $tag) |
služby nesoucí daný tag jako dvojice name => hodnota tagu |
addExcludedClasses(array $types) |
vyloučí třídy a rozhraní z autowiringu |
Šikovný obrat je zjistit pomocí getByType(), jestli nějaká služba vůbec existuje – třeba napojit se na
logger jen tehdy, když ho aplikace má:
if ($builder->getByType(Psr\Log\LoggerInterface::class)) {
$builder->getDefinition($this->prefix('articles'))
->addSetup('setLogger');
}
Typy definic
Každá metoda add*Definition() vrací jiný druh definice. Všechny dědí od společného předka
Nette\DI\Definitions\Definition:
ServiceDefinition– běžná služba; nastavuje se přessetType(),setFactory(),addSetup(),addTag()asetAutowired()FactoryDefinition– generovaná továrna: rozhraní, jehož metodacreate()vrací při každém volání nový objektAccessorDefinition– generovaný accessor: rozhraní, jehož metodaget()vrací existující službuLocatorDefinition– multifactory / locator sdružující více továren nebo accessorů v jednom rozhraníImportedDefinition– služba, kterou kontejner sám nevytváří, ale dostává zvenčí za běhu
Pamatujte, že getDefinition() vrací takový druh definice, jaký se pod daným názvem skrývá. Pokud může
váš kód narazit na generovanou továrnu, nejdřív zkontrolujte typ a vytvářený objekt nastavte přes
getResultDefinition():
$def = $builder->getDefinition($name);
if ($def instanceof Nette\DI\Definitions\FactoryDefinition) {
$def = $def->getResultDefinition();
}
$def->addSetup('setLogger');
Tipy a úskalí
Kompilace vs. běh aplikace
Nejčastější zdroj zmatení: kód rozšíření běží, když se kontejner kompiluje, ne když aplikace obsluhuje požadavky. V praxi to znamená:
- Rozšíření nikdy nepracuje s instancemi služeb – ty ještě neexistují. Nevytvářejte služby přes
new; zaregistrujte definici a nechte kontejner, ať je vytvoří sám. - Všechny hodnoty z konfigurace se zapečou do vygenerovaného kódu. Hodnota, která se může lišit mezi prostředími
(cesta, heslo z
getenv()), musí být označená jako dynamická, jinak zamrzne v čase kompilace. - Řetězce předané do
$this->initialization->addBody()se nespouštějí teď – je to PHP kód vygenerovaný do kontejneru, který se spouští při každém požadavku.
Závislosti na souborech
Kontejner se překompiluje při změně konfiguračních souborů nebo tříd rozšíření. Pokud ale vaše rozšíření čte jakýkoli jiný soubor – seznam entit, XML konfiguraci knihovny – kontejner o něm nemá jak vědět. Takové soubory zaregistrujte pomocí:
$builder->addDependency($file);
Jinak se dočkáte klasické záhady: upravíte soubor, ale aplikace se dál chová postaru – změna se projeví až ve
chvíli, kdy se kontejner přestaví z nějakého jiného důvodu. (Soubory načtené přes loadFromFile() se
sledují automaticky.)
Podmíněná registrace
Rozšíření se umí přizpůsobit svému prostředí. Volitelné integrace se typicky hlídají přes
class_exists():
if (class_exists(Symfony\Component\Console\Command\Command::class)) {
$builder->addDefinition($this->prefix('command'))
->setFactory(Blog\Console\SitemapCommand::class);
}
A hodnoty jako %debugMode% je nejlepší předávat konstruktorem rozšíření:
extensions:
blog: BlogExtension(%debugMode%)
class BlogExtension extends Nette\DI\CompilerExtension
{
public function __construct(
private bool $debugMode = false,
) {}
}
Typickým využitím je registrace Tracy panelu jen ve vývojářském režimu.
Složitější argumenty
Někdy argument továrny nebo setup volání není obyčejná hodnota, název třídy ani reference @service. Pro
tyto případy existují:
new Nette\DI\Definitions\Statement(Blog\Panel::class, [$args])– objekt vytvořený na místě, „anonymní služba“ použitá jako argumentnew Nette\DI\Definitions\Reference('blog.articles')– reference na službu, objektový protějšek zápisu@name$builder::literal('PHP_SAPI')– kus surového PHP kódu vloženého tak, jak je, do vygenerovaného kontejneru
Příklad – registrace Tracy panelu:
$builder->getDefinition($this->prefix('articles'))
->addSetup('@Tracy\Bar::addPanel', [
new Nette\DI\Definitions\Statement(Blog\ArticlesPanel::class),
]);
Exportované tagy a typy
Export metadat lze v konfiguraci
omezit tak, aby si zkompilovaný kontejner ponechal jen tagy a typy pro autowiring, které aplikace skutečně používá. Pokud
vaše rozšíření získává služby za běhu pomocí $container->findByTag() nebo
$container->getByType(), takové omezení může odstranit právě ta metadata, na kterých závisíte.
Abyste tomu předešli, řekněte kompilátoru, které tagy a typy se musí vždy exportovat:
public function loadConfiguration(): void
{
// tento tag se bude exportovat vždy, i když je export omezený
$this->compiler->addExportedTag('event.subscriber');
// tento typ bude vždy dostupný pro getByType()
$this->compiler->addExportedType(Nette\Database\Connection::class);
}
Obě metody metadata pouze přidávají; nikdy nepřepíšou nastavení di › export z aplikace. Když tedy
aplikace omezí export na výčet, tagy a typy, které vaše rozšíření potřebuje, zůstanou zachovány; jen úplné vypnutí
exportu tagů (tags: false) je zahodí spolu se vším ostatním.