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: nebo database:)
  • 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:

  1. zvaliduje se konfigurační sekce všech rozšíření (getConfigSchema())
  2. každé rozšíření zaregistruje své služby (loadConfiguration()); uživatelská sekce services: se zpracovává jako poslední, takže aplikace má vždy poslední slovo
  3. jakmile jsou všechny definice na místě a typy služeb vyřešené, mohou je rozšíření upravovat (beforeCompile())
  4. 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řes setType(), setFactory(), addSetup(), addTag()setAutowired()
  • FactoryDefinition – generovaná továrna: rozhraní, jehož metoda create() vrací při každém volání nový objekt
  • AccessorDefinition – generovaný accessor: rozhraní, jehož metoda get() vrací existující službu
  • LocatorDefinition – 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 argument
  • new 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.

verze: 3.x 2.x