Nette DI 3.3: Nový fázový systém pro extensions
- David Grudl
- founder | 8320
Před 15 lety, když jsem psal první verzi DI Compileru, potřeboval jsem
nějak vyřešit pořadí, v jakém se extensions spouštějí. Tehdy vznikly
tři metody: loadConfiguration(), beforeCompile() a
afterCompile(). A pořadí jejich volání? Prostě v pořadí,
v jakém extensions zaregistrujete. Hotovo. Tady
je ten původní commit.
Bylo mi jasné, že je to jenom dočasné. Že to brzy bude potřeba vyřešit pořádně. Jenže… jak to tak bývá, dočasná řešení trvají nejdéle 😅

Problémy současného stavu
1. Jedna registrace = pořadí všech fází
Když zaregistruju extension jako třetí, tak její
loadConfiguration() běží jako třetí,
beforeCompile() taky jako třetí, afterCompile() taky
jako třetí. Jenže různé fáze můžou potřebovat různé pořadí!
2. Implicitní závislosti
Extensions vesele volají getByType(), findByType()
a doufají, že ta služba už existuje. Nikde se neříká „hele, potřebuju
aby ApplicationExtension už proběhl“. Prostě to buď funguje, nebo ne.
3. Hardcoded hacky v Compileru
Podívejte se do Compiler::processExtensions(). Je tam tahle
nádhera:
// InjectExtension se přesune na konec
$last = $this->getExtensions(InjectExtension::class);
$this->extensions = array_merge(array_diff_key($this->extensions, $last), $last);
// SearchExtension se vloží před DecoratorExtension
if ($decorator = $this->getExtensions(DecoratorExtension::class)) {
Arrays::insertBefore($this->extensions, key($decorator),
$this->getExtensions(SearchExtension::class));
}
Každá nová extension, která potřebuje speciální pořadí = další hack do Compileru. Neškálovatelné, křehké, ošklivé.
Cesta do pekel: číselné priority
Někdo by řekl: „Dej každé extension prioritu, třeba 10, 20, 30…“
NE! Tohle je naprosto spolehlivá cesta do pekla. Znáte to
z CSS z-index: 99999. Nebo z event listenerů. Nebo z Wordpressu
(teda když jsem ho naposledy před 20 lety viděl, bylo to tam). Priority jsou
anti-pattern, který časem vede k neudržovatelnému chaosu.
Nové řešení
Navrhuju systém inspirovaný tím, jak už v Latte fungují tagy a compiler passes. Elegantní, deklarativní, rozšiřitelné.
Fáze kompilace
Místo tří metod (loadConfiguration,
beforeCompile, afterCompile) máme
5 sémantických fází:
enum Phase: string
{
case Setup = 'setup'; // Příprava prostředí (parametry, registrace extensions)
case Register = 'register'; // Bezpodmínečná registrace služeb
case Discover = 'discover'; // Discovery a podmíněná registrace
case Modify = 'modify'; // Modifikace existujících služeb
case Compile = 'compile'; // Úprava vygenerované třídy
}
Deklarativní závislosti
Můžete si vytvořit kolik handlerů chcete a pojmenovat je jakkoliv, tedy hezky deskriptivně. A každý handler může deklarovat, před kým nebo za kým chce běžet:
use Nette\DI\Attributes\Hook;
use Nette\DI\Phase;
class ApplicationExtension extends CompilerExtension
{
#[Hook(Phase::Register)]
public function doRegisterServices(ContainerBuilder $builder): void
{
// Registrace základních služeb - žádné závislosti
}
#[Hook(Phase::Discover, before: SearchExtension::class)]
public function doDiscoverPresenters(ContainerBuilder $builder): void
{
// Chci běžet před SearchExtension, protože presentery
// znám lépe než obecný auto-discovery
}
#[Hook(Phase::Modify, before: InjectExtension::class)]
public function doTagPresenters(ContainerBuilder $builder): void
{
// Přidávám tag nette.inject, takže musím běžet
// před InjectExtension, která ho zpracuje
}
}
Speciální hodnoty
#[Hook(Phase::Modify, after: '*')] // Běž jako poslední
#[Hook(Phase::Register, before: '*')] // Běž jako první
Manuální registrace
Kdo nechce atributy, může registrovat ručně:
public function register(): void
{
$this->hook(Phase::Register, $this->doSomething(...));
$this->hook(Phase::Modify, $this->doOther(...), before: InjectExtension::class);
}
Proč je to 100% zpětně kompatibilní?
Výchozí implementace register() automaticky zaregistruje
staré metody:
protected function registerLegacyHandlers(): void
{
$this->hook(Phase::Register, $this->loadConfiguration(...));
$this->hook(Phase::Modify, $this->beforeCompile(...));
$this->hook(Phase::Compile, $this->afterCompile(...));
}
Všechny existující extensions budou fungovat beze změny. Můžete je postupně migrovat na nový systém, nebo je nechat být. A snad nemusím dodávat, že nejlepším toolem na migraci je Claude Code :-)
Determinismus
Pro každou fázi se provede topologické řazení. Handlery bez vzájemných závislostí se seřadí abecedně podle názvu třídy extension. Žádná náhoda, předvídatelné chování.
Cyklické závislosti (A before B, B before A) vyhodí výjimku s popisem cyklu.
One more thing…
Už pradávno jsem chtěl do Nette přidat auto-discovery extensions z Composer balíčků.
Představte si: nainstalujete composer require nette/database a
extension se automaticky zaregistruje. Žádné ruční extensions:
v configu. Prostě to funguje.
Jenže… auto-discovery je jen tak dobré, jak dobře je vyřešené pořadí extensions. Když se extensions objevují „odnikud“, nemůžete spoléhat na pořadí registrace. Potřebujete systém, kde si každá extension řekne, co potřebuje.
A přesně to už máme!
Jak to funguje:
Autor balíčku přidá do composer.json:
{
"extra": {
"nette": {
"di-extensions": {
"database": "Nette\\Bridges\\DatabaseDI\\DatabaseExtension",
}
}
}
}
Bootstrap při vytváření kontejneru přečte
vendor/composer/installed.json, najde všechny extensions, a
zaregistruje je. Díky novému fázovému systému nezáleží na pořadí,
v jakém se objeví – každá extension má svoje
before/after deklarace.
Budu moc rád za zpětnou vazbu! Napadají vás další use-cases? Něco, co by tento systém neuměl vyřešit? Dejte vědět.
- jiri.pudil
- Nette Blogger | 1035
Ahoj, za mě palec hore. Mám dvě myšlenky:
- ad determinismus
Pro každou fázi se provede topologické řazení. Handlery bez vzájemných závislostí se seřadí abecedně podle názvu třídy extension. Žádná náhoda, předvídatelné chování.
Tohle platí i pro tu zpětně kompatibilní vrstvu (
registerLegacyHandlers)? Mám obavu, aby z toho nebyla továrna na rozbité buildy. Když budu mít rozšíření (A), které má implicitní závislost na jiném rozšíření (B), pak dokud A nedostane aktualizaci, kde svou závislost vyjádří explicitně, může se stát, že se mi seřadí sice deterministicky, ale ve špatném pořadí? - ad autodiscovery
Představte si: nainstalujete
composer require nette/databasea extension se automaticky zaregistruje. Žádné ručníextensions:v configu. Prostě to funguje.Tohle zní super. Půjde to přebít, když třeba budu chtít zaregistrovat dané rozšíření s jinými parametry než výchozími? A bude možné vynutit, aby se nějaké auto-discovered rozšíření vůbec nezaregistrovalo? Teď například v bootstrapu máme
unset($configurator->defaultExtensions['security']), protože používáme z nette/security jen zlomek a je jednodušší to zaregistrovat napřímo než odregistrovat, co se nám nehodí.
Editoval jiri.pudil (6. 1. 17:13)

- Jakub Bouček
- Člen | 55
Ta potřeba přebití auto-discovery, kterou @jiripudil zmiňuje, je dobrá poznámka.
unset($configurator->defaultExtensions['security'])
Tohle jsem taky několikrát musel použít, rovnou navrhuju feature-request, aby DI v konfiguraci mělo definici blacklistu rozšíření.
A mám ještě jiný dotaz: Někdy se mi mohou sejít v projektu balíčky, které se vzájemně neznají (různí autoři), ale přesto spolu interferují a potřebuji jejich pořadí upravit na úrovni projektu. Je to možné?

- Forrest79
- Backer | 10
unset($configurator->defaultExtensions['security'])
Taky tohle používáme – to, že máme v composeru natažený nějaký Nette balík u nás ještě nemusí implikovat, že ho potřebujeme i v DI. S velikostí DI bojujeme a tak co tam být nemusí, rádi vyhodíme.
Za sebe bych spíš byl pro nějaký volitelný whitelist, ať se tam automaticky nedává o čem člověk neví. Bez něj ať to klidně funguje automaticky jako teď. S ním by si člověk mohl manuálně vybrat.
- David Grudl
- founder | 8320
Díky všem za připomínky! Mezitím jsem na implementaci dost pokročil, takže můžu odpovědět konkrétně.
Ad determinismus a legacy extensions (@jiri.pudil)
Tohle byl naprosto zásadní postřeh, díky za něj. Máš pravdu, že abecední řazení by z BC vrstvy udělalo továrnu na rozbité buildy: extension A s implicitní závislostí na B by se seřadila sice deterministicky, ale špatně.
Řešení: abecedu jsem zahodil. Topologické řazení je stabilní a remízy
(handlery bez vzájemných before/after constraintů)
zachovávají pořadí registrace, tedy pořadí volání
Compiler::addExtension(). To je plně deterministické, určuje ho
Configurator::$defaultExtensions a za ním sekce
extensions: v pořadí z configu. Čistě legacy aplikace se tak
chová stejně jako dnes: loadConfiguration() běží v pořadí
registrace, beforeCompile() taky, staré interní hacky (Inject na
konec, Search před Decorator) jsou vyjádřené jako explicitní constrainty na
konvertovaných extensions. A funguje to i pro smíšený případ, který jsi
popsal: dokud A nedeklaruje závislost explicitně, zůstává na svém místě
v pořadí registrace, konvertované B se posune jen tam, kam ho posunou jeho
vlastní constrainty.
Navíc before/after se matchuje přes
is_a(), takže funguje i pro potomky extension (stejně jako
dřív getExtensions()).
Abecední řazení se používá jen tam, kde skutečná nahodilost vzniká:
u auto-discovered extensions, protože pořadí balíčků v
installed.json nenese žádný význam. Ty se registrují abecedně
podle jména a až za $defaultExtensions. Kdo píše nový
balíček s metadaty, opt-inuje do nového světa a pořadí si má vynutit
přes before/after, ne spoléhat na pozici
v poli.
Ad autodiscovery – vypnutí a přebití parametrů (@jiri.pudil, @Jakub Bouček)
Obojí jde. Místo
unset($configurator->defaultExtensions['security']) bude:
$configurator->excludeExtension('security');
$configurator->excludeExtension('database', 'mail'); // lze i více najednou
Funguje shodně pro discovered i built-in extensions. A přebití
s jinými parametry uděláš kombinací: extension vyloučíš a
zaregistruješ si ji sám v extensions: s vlastními
argumenty.
Zároveň platí, že $defaultExtensions mají před discovered
vždy přednost, takže cizí balíček nemůže podstrčit vlastní verzi
frameworkové extension.
Ad whitelist (@Forrest79)
Pro plně manuální režim slouží excludeExtension() bez
argumentů: nezaregistruje se automaticky vůbec nic (ani defaults, ani
discovery) a všechno si registruješ sám. Mimochodem, výhledově chci
$defaultExtensions postupně vyprázdnit úplně a nechat vše na
discovery; první krok už se stal, extensions ze samotného nette/di (inject,
decorator, search…) už v defaults nejsou a discoverují se z metadat
balíčku.
Ad ruční nastavení before/after (@mystik)
Interně to tak už funguje, Compiler::addHook() bere
before/after jako parametry a atribut
#[Hook] je jen deklarativní obal. Zvažoval jsem i přepisování
pořadí cizích extensions na úrovni projektu, ale zatím to odkládám, dokud
neuvidím reálný use case; správné místo pro deklaraci závislostí je
u autora extension.