Tworzenie rozszerzeń dla Nette DI

Rozszerzenie to klasa, która wpina się w kompilację kontenera DI. Może rejestrować usługi programowo, walidować własną sekcję konfiguracji, modyfikować usługi zdefiniowane przez innych, a nawet zmieniać wygenerowany kod kontenera. Ta strona uczy, jak takie rozszerzenie napisać, co dzieje się kiedy i na co uważać.

Rozszerzenia to natywny sposób, w jaki pakiety integrują się z Nette: używają ich wszystkie pakiety nette/*, a Twój też może. Typowe rozszerzenie robi jedną albo więcej z tych rzeczy:

  • integruje bibliotekę – rejestruje jej usługi w kontenerze i udostępnia przyjazną, walidowaną sekcję konfiguracji (stąd biorą się sekcje mail: czy database:)
  • automatyzuje rejestrację – rejestruje wiele podobnych usług w pętli albo według reguły, gdzie wypisywanie ich w services: byłoby żmudne
  • wprowadza zmiany przekrojowe – znajduje usługi zarejestrowane przez innych i je uzupełnia, np. podpina logger do każdej usługi z określonym tagiem

Do codziennej pracy nad aplikacją rzadko go potrzebujesz – sekcja services konfiguracji wystarcza do rejestrowania i łączenia Twoich klas. Po rozszerzenie sięgnij wtedy, gdy sama konfiguracja przestaje wystarczać.

Rozszerzenie aktywuje się w sekcji extensions. Tak dodasz rozszerzenie reprezentowane przez klasę BlogExtension pod nazwą blog:

extensions:
	blog: BlogExtension

Jeśli jego konstruktor przyjmuje argumenty, przekaż je od razu tam:

extensions:
	blog: BlogExtension(%debugMode%)

Jak działa kompilacja

Aby pewnie pisać rozszerzenia, musisz wiedzieć jedną kluczową rzecz: kiedy Twój kod działa. Nette nie łączy usług podczas obsługi żądań. Zamiast tego kompiluje kontener z wyprzedzeniem: wczytuje wszystkie pliki konfiguracyjne, pozwala rozszerzeniom wykonać swoją pracę i generuje zoptymalizowaną klasę PHP, którą zapisuje na dysku. Każde kolejne żądanie po prostu wczytuje tę gotową klasę. Kod Twojego rozszerzenia działa więc tylko wtedy, gdy kontener jest (prze)budowywany, a nie przy każdym żądaniu.

Ma to ważną konsekwencję: podczas kompilacji nie istnieją jeszcze żadne usługi. Istnieją definicje – przepisy opisujące, jakiej klasy będzie każda usługa, jak ją utworzyć i co następnie na niej wywołać. Definicje żyją w obiekcie ContainerBuilder. Rozszerzenie to w istocie skryptowalna konfiguracja: wszystko, co możesz zadeklarować w sekcji services:, możesz też zbudować w PHP – warunkowo, w pętlach albo w reakcji na to, co zarejestrowali inni.

Kompilacja przebiega w fazach, a rozszerzenie może wkroczyć w każdą z nich:

  1. walidowane są sekcje konfiguracji wszystkich rozszerzeń (getConfigSchema())
  2. każde rozszerzenie rejestruje swoje usługi (loadConfiguration()); sekcja services: użytkownika przetwarzana jest ostatnia, więc aplikacja ma zawsze ostatnie słowo
  3. gdy wszystkie definicje są na miejscu, a typy usług rozwiązane, rozszerzenia mogą je modyfikować (beforeCompile())
  4. generowana jest klasa kontenera; rozszerzenia mogą jeszcze dostosować jej kod (afterCompile()) i wyemitować kod, który wykona się przy starcie aplikacji (inicjalizacja)

W trybie deweloperskim kontener rekompiluje się automatycznie, gdy tylko zmienisz plik konfiguracyjny albo samą klasę rozszerzenia – oba są śledzone jako zależności. Możesz więc rozwijać rozszerzenia, nigdy nie czyszcząc cache.

Głębsze spojrzenie na to, co dzieje się w każdej fazie – kiedy rozwijane są parametry, kiedy @service staje się referencją i dokładnie kiedy bezpiecznie jest wyszukiwać usługi po typie – znajdziesz w Kompilacja w szczegółach.

Pierwsze rozszerzenie

Oto małe, ale kompletne rozszerzenie. Aktywujemy je i konfigurujemy w tym samym pliku:

extensions:
	blog: BlogExtension

blog:
	postsPerPage: 5

A oto cała klasa:

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() opisuje, co może zawierać sekcja blog: (nazwana według klucza, pod którym zarejestrowaliśmy rozszerzenie), wraz z typami i wartościami domyślnymi – zwalidowane wartości dostępne są potem w $this->config. W loadConfiguration() rejestrujemy usługi. Zwróć uwagę na nazwy: $this->prefix('articles') daje blog.articles, więc usługi różnych rozszerzeń nie mogą się zderzyć.

A ostatnie kilka wierszy pokazuje, po co w ogóle istnieją rozszerzenia: usługa comments rejestrowana jest tylko wtedy, gdy komentarze są włączone. Zwykły plik konfiguracyjny takich decyzji podjąć nie potrafi.

Usługi zarejestrowane w ten sposób zachowują się dokładnie tak, jakby były zapisane w services: – tworzone są leniwie na żądanie, a autowiring przekazuje je wszędzie tam, gdzie zadeklarowano typ Blog\Articles.

Kolejne rozdziały szczegółowo opisują cykl życia rozszerzenia, następnie API ContainerBuildera, którego będziesz używać wewnątrz rozszerzenia, a na końcu pułapki, o których warto wiedzieć.

Cykl życia rozszerzenia

Rozszerzenie dziedziczy po Nette\DI\CompilerExtension i nadpisuje niektóre z czterech metod getConfigSchema(), loadConfiguration(), beforeCompile() i afterCompile(), które kompilator wywołuje podczas kompilacji w tej właśnie kolejności.

getConfigSchema(): Nette\Schema\Schema

Definiuje schemat sekcji konfiguracji rozszerzenia. Dzięki niemu użytkownicy dostają za darmo walidację i czytelne komunikaty o błędach: literówka albo zły typ w sekcji blog: zgłaszane są zrozumiałym komunikatem, bez pisania przez Ciebie choćby jednego sprawdzenia.

Schemat opisuje się za pomocą biblioteki Schema i może wyrażać typy, wartości domyślne, wartości dozwolone i wiele więcej:

public function getConfigSchema(): Nette\Schema\Schema
{
	return Expect::structure([
		'postsPerPage' => Expect::int(10),
		'storage' => Expect::anyOf('files', 'database')->firstIsDefault(),
	]);
}

Zwalidowana konfiguracja dostępna jest w $this->config jako obiekt stdClass (albo jako tablica, jeśli dopiszesz do schematu castTo('array')).

Jeśli wartości opcji nie da się poznać w czasie kompilacji – bo pochodzi na przykład ze zmiennej środowiskowej – oznacz ją przez dynamic(), np. Expect::int()->dynamic(). Więcej w parametrach dynamicznych.

loadConfiguration()

Miejsce, w którym rozszerzenie rejestruje swoje usługi, używając ContainerBuildera:

public function loadConfiguration(): void
{
	$builder = $this->getContainerBuilder();
	$builder->addDefinition($this->prefix('articles'))
		->setFactory(Blog\Articles::class);
}

Jeśli usługa ma być dostępna również pod krótką nazwą, dodaj alias. Zwyczajowo robi się to tylko wtedy, gdy rozszerzenie zarejestrowano pod jego zwykłą nazwą, aby kilka instancji rozszerzenia nie mogło się o nią bić:

if ($this->name === 'blog') {
	$builder->addAlias('articles', $this->prefix('articles'));
}

Gdy usług jest wiele, wygodniej bywa zdefiniować je w osobnym pliku NEON, znajomą składnią services. Prefiks @extension odwołuje się do bieżącego rozszerzenia:

services:
	articles:
		create: MyBlog\ArticlesModel(@connection)

	comments:
		create: MyBlog\CommentsModel(@connection, @extension.articles)

Definicje te wczytujemy metodą loadDefinitionsFromConfig(); nazwy dostają prefiks automatycznie, a plik śledzony jest jako zależność, więc jego zmiana wyzwala rekompilację:

public function loadConfiguration(): void
{
	$this->loadDefinitionsFromConfig(
		$this->loadFromFile(__DIR__ . '/services.neon')['services'],
	);
}

beforeCompile()

Gdy wywoływana jest ta metoda, builder zawiera już wszystkie definicje: Twoje, innych rozszerzeń i te z plików konfiguracyjnych użytkownika. Typy usług są też rozwiązane, więc wyszukiwanie po typie jest wiarygodne. Czyni to tę fazę idealną do badania i uzupełniania ostatecznego grafu usług.

Typowo wyszukujesz usługi po tagu albo po typie i uzupełniasz znalezione definicje:

public function beforeCompile(): void
{
	$builder = $this->getContainerBuilder();

	foreach ($builder->findByTag('logaware') as $name => $attrs) {
		$builder->getDefinition($name)->addSetup('setLogger');
	}
}

Wywołanie setLogger() nie ma jawnych argumentów – dostarczy je autowiring, tak samo jak robi to w fabrykach.

Możesz też współpracować z innymi zarejestrowanymi rozszerzeniami, pozyskanymi przez $this->compiler->getExtensions(), opcjonalnie przefiltrowanymi po klasie albo interfejsie:

foreach ($this->compiler->getExtensions(FooExtension::class) as $extension) {
	// ...
}

afterCompile (Nette\PhpGenerator\ClassType $class)

W ostatniej fazie generowana jest klasa kontenera jako obiekt ClassType z biblioteki PHP Generator. Zawiera metodę fabryczną dla każdej usługi i za chwilę zostanie zapisana do cache. Możesz jeszcze zmodyfikować jej kod:

public function afterCompile(Nette\PhpGenerator\ClassType $class): void
{
	$method = $class->getMethod('__construct');
	// ...
}

Ta faza będzie Ci potrzebna tylko rzadko. Aby dodać kod, który wykona się przy starcie aplikacji, użyj zamiast tego inicjalizacji:

Kod inicjalizacyjny

Wszystkie poprzednie fazy wpływają na to, jak kontener jest budowany. Rozszerzenie może ponadto wyemitować kod, który wykona się w czasie działania, zaraz po utworzeniu kontenera – na przykład aby wystartować sesję albo uruchomić usługi. Kod zapisuje się do obiektu $this->initialization jego metodą addBody():

public function loadConfiguration(): void
{
	// usługi z tagiem 'run' muszą zostać utworzone zaraz po starcie kontenera
	$builder = $this->getContainerBuilder();
	foreach ($builder->findByTag('run') as $name => $attrs) {
		$this->initialization->addBody('$this->getService(?);', [$name]);
	}
}

Samo Nette używa inicjalizacji na przykład do automatycznego wystartowania sesji albo wysłania nagłówków HTTP związanych z bezpieczeństwem. I miej na uwadze: w odróżnieniu od wszystkiego innego w rozszerzeniu, ten kod wykonuje się przy każdym żądaniu, więc trzymaj go niewielkim.

ContainerBuilder

Nette\DI\ContainerBuilder to obiekt, przez który rozszerzenie rozmawia z kompilatorem. Zawiera definicje wszystkich usług i oferuje metody do ich dodawania, wyszukiwania i modyfikowania. Pozyskujesz go w loadConfiguration() i beforeCompile():

$builder = $this->getContainerBuilder();

Dodawanie usług

Rejestracja usługi to to samo, co robisz w sekcji services: pliku NEON, tyle że zapisane w PHP. Każdemu kluczowi konfiguracji odpowiada metoda na definicji, więc te dwa zapisy są równoważne:

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');

Definicja zwracana przez addDefinition() to ServiceDefinition oferujący odpowiedniki kluczy konfiguracji: setType() (klasa usługi), setFactory() (jak ją utworzyć), setArguments(), addSetup(), addTag() i setAutowired().

addSetup() odzwierciedla listę setup: i przyjmuje te same postacie: wywołanie metody addSetup('setLogger', ['@logger']), przypisanie do właściwości addSetup('$cache', ['@cache']) albo wywołanie na innej usłudze addSetup('@Tracy\Bar::addPanel', [$panel]).

Poza zwykłymi usługami builder potrafi rejestrować również generowane fabryki, akcesory i lokatory – każdy własną metodą zwracającą odpowiadający typ definicji:

Metoda Rejestruje
addDefinition() zwykłą usługę (zwraca ServiceDefinition)
addFactoryDefinition() generowaną fabrykę (interfejs z metodą create())
addAccessorDefinition() generowany akcesor (interfejs z metodą get())
addLocatorDefinition() multifabrykę / lokator łączący kilka fabryk
addImportedDefinition() usługę przekazywaną do kontenera z zewnątrz w czasie działania
addAlias() drugą nazwę istniejącej usługi

Przy fabryce obiekt, który tworzy, konfigurujesz przez getResultDefinition(); akcesor zamiast tego wskazuje na istniejącą usługę przez setReference():

$builder->addFactoryDefinition($this->prefix('latteFactory'))
	->setImplement(LatteFactory::class)
	->getResultDefinition()
		->setFactory(Latte\Engine::class)
		->addSetup('setStrictTypes', [true]);

addLocatorDefinition() i addImportedDefinition() potrzebne są rzadko – takie usługi zwykle pochodzą z kluczy implement: i usług importowanych w NEON, a nie z ręcznego pisania.

Wyszukiwanie i modyfikowanie usług

Do wyszukiwania i przechodzenia po istniejących definicjach builder udostępnia:

Metoda Opis
getDefinition(string $name) definicja o podanej nazwie (zgłasza wyjątek, gdy jej brak)
hasDefinition(string $name) czy istnieje definicja albo alias o tej nazwie
getDefinitions() wszystkie definicje
removeDefinition(string $name) usuwa definicję
getByType(string $type) nazwa autowirowanej usługi tego typu albo null
getDefinitionByType(string $type) autowirowana definicja tego typu
findByType(string $type) wszystkie definicje tego typu jako pary nazwa => definicja
findByTag(string $tag) usługi noszące tag jako pary nazwa => wartość tagu
addExcludedClasses(array $types) wyklucza klasy i interfejsy z autowiringu

Poręcznym idiomem jest użycie getByType() do sprawdzenia, czy usługa w ogóle istnieje – na przykład aby podpiąć się do loggera tylko wtedy, gdy aplikacja go ma:

if ($builder->getByType(Psr\Log\LoggerInterface::class)) {
	$builder->getDefinition($this->prefix('articles'))
		->addSetup('setLogger');
}

Typy definicji

Każda metoda add*Definition() zwraca inny rodzaj definicji. Wszystkie rozszerzają wspólnego przodka Nette\DI\Definitions\Definition:

  • ServiceDefinition – zwykła usługa; konfigurowana przez setType(), setFactory(), addSetup(), addTag()setAutowired()
  • FactoryDefinition – generowana fabryka: interfejs, którego metoda create() przy każdym wywołaniu zwraca nowy obiekt
  • AccessorDefinition – generowany akcesor: interfejs, którego metoda get() zwraca istniejącą usługę
  • LocatorDefinition – multifabryka / lokator łącząca kilka fabryk albo akcesorów w jednym interfejsie
  • ImportedDefinition – usługa, której kontener nie tworzy sam, lecz otrzymuje ją z zewnątrz w czasie działania

Miej na uwadze, że getDefinition() zwraca taki rodzaj definicji, jaki żyje pod podaną nazwą. Jeśli Twój kod może natknąć się na generowaną fabrykę, sprawdź najpierw typ i skonfiguruj produkowany obiekt przez getResultDefinition():

$def = $builder->getDefinition($name);
if ($def instanceof Nette\DI\Definitions\FactoryDefinition) {
	$def = $def->getResultDefinition();
}
$def->addSetup('setLogger');

Wskazówki i pułapki

Czas kompilacji kontra czas działania

Najczęstsze źródło nieporozumień: kod rozszerzenia działa wtedy, gdy kontener jest kompilowany, a nie wtedy, gdy aplikacja obsługuje żądania. W praktyce oznacza to:

  • Rozszerzenie nigdy nie pracuje z instancjami usług – one jeszcze nie istnieją. Nie twórz usług przez new; zarejestruj definicję i pozwól kontenerowi je utworzyć.
  • Wszystkie wartości konfiguracji są wpiekane w wygenerowany kod. Wartość, która może różnić się między środowiskami (ścieżka, hasło z getenv()), musi być oznaczona jako dynamiczna, w przeciwnym razie zamarza w czasie kompilacji.
  • Stringi przekazywane do $this->initialization->addBody() nie wykonują się teraz – to kod PHP emitowany do kontenera, wykonywany przy każdym żądaniu.

Zależności od plików

Kontener rekompiluje się, gdy zmienią się pliki konfiguracyjne albo klasy rozszerzeń. Jeśli jednak Twoje rozszerzenie czyta jakikolwiek inny plik – listę encji, konfigurację XML biblioteki – kontener nie ma jak się o tym dowiedzieć. Takie pliki zarejestruj przez:

$builder->addDependency($file);

W przeciwnym razie czeka Cię klasyczna zagadka: edytujesz plik, a aplikacja dalej zachowuje się po staremu – zmiana ujawnia się dopiero wtedy, gdy kontener przebuduje się z jakiegoś innego powodu. (Pliki czytane przez loadFromFile() śledzone są automatycznie.)

Rejestracja warunkowa

Rozszerzenie może dostosowywać się do swojego środowiska. Opcjonalne integracje zabezpiecza się typowo przez class_exists():

if (class_exists(Symfony\Component\Console\Command\Command::class)) {
	$builder->addDefinition($this->prefix('command'))
		->setFactory(Blog\Console\SitemapCommand::class);
}

A wartości w rodzaju %debugMode% najlepiej przekazywać przez konstruktor rozszerzenia:

extensions:
	blog: BlogExtension(%debugMode%)
class BlogExtension extends Nette\DI\CompilerExtension
{
	public function __construct(
		private bool $debugMode = false,
	) {}
}

Typowym zastosowaniem jest rejestrowanie panelu Tracy tylko w trybie deweloperskim.

Argumenty złożone

Czasem argument fabryki albo wywołania w setupie nie jest zwykłą wartością, nazwą klasy ani referencją @service. Na takie przypadki są:

  • new Nette\DI\Definitions\Statement(Blog\Panel::class, [$args]) – obiekt tworzony na miejscu, “anonimowa usługa” używana jako argument
  • new Nette\DI\Definitions\Reference('blog.articles') – referencja do usługi, obiektowy odpowiednik stringa @name
  • $builder::literal('PHP_SAPI') – kawałek surowego kodu PHP wstawiany bez zmian do wygenerowanego kontenera

Przykład – rejestracja panelu Tracy:

$builder->getDefinition($this->prefix('articles'))
	->addSetup('@Tracy\Bar::addPanel', [
		new Nette\DI\Definitions\Statement(Blog\ArticlesPanel::class),
	]);

Eksportowane tagi i typy

Eksport metadanych można w konfiguracji ograniczyć tak, aby skompilowany kontener zachował tylko te tagi i typy autowiringu, których aplikacja faktycznie używa. Jeśli Twoje rozszerzenie pobiera usługi w czasie działania przez $container->findByTag() albo $container->getByType(), takie ograniczenie mogłoby usunąć dokładnie te metadane, na których polegasz.

Aby temu zapobiec, powiedz kompilatorowi, które tagi i typy muszą być zawsze eksportowane:

public function loadConfiguration(): void
{
	// ten tag będzie eksportowany zawsze, nawet gdy eksport jest ograniczony
	$this->compiler->addExportedTag('event.subscriber');

	// ten typ będzie zawsze dostępny dla getByType()
	$this->compiler->addExportedType(Nette\Database\Connection::class);
}

Obie metody tylko dodają do eksportowanych metadanych; nigdy nie nadpisują konfiguracji di › export aplikacji. Gdy więc aplikacja ograniczy eksport do listy, tagi i typy potrzebne Twojemu rozszerzeniu pozostaną uwzględnione; dopiero całkowite wyłączenie eksportu tagów (tags: false) odrzuca je wraz ze wszystkim innym.

wersja: 3.x