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:czydatabase:) - 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:
- walidowane są sekcje konfiguracji wszystkich rozszerzeń (
getConfigSchema()) - każde rozszerzenie rejestruje swoje usługi (
loadConfiguration()); sekcjaservices:użytkownika przetwarzana jest ostatnia, więc aplikacja ma zawsze ostatnie słowo - gdy wszystkie definicje są na miejscu, a typy usług rozwiązane, rozszerzenia mogą je modyfikować
(
beforeCompile()) - 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 przezsetType(),setFactory(),addSetup(),addTag()isetAutowired()FactoryDefinition– generowana fabryka: interfejs, którego metodacreate()przy każdym wywołaniu zwraca nowy obiektAccessorDefinition– generowany akcesor: interfejs, którego metodaget()zwraca istniejącą usługęLocatorDefinition– multifabryka / lokator łącząca kilka fabryk albo akcesorów w jednym interfejsieImportedDefinition– 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 argumentnew 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.