Nette DI 3.3: Nový fázový systém pro extensions

David Grudl
founder | 8320
+
+16
-

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
+
+2
-

Ahoj, za mě palec hore. Mám dvě myšlenky:

  1. 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í?

  2. ad autodiscovery

    Představte si: nainstalujete composer require nette/database a 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
+
+2
-

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
+
0
-
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.

mystik
Člen | 323
+
0
-

@JakubBouček Mozna mit u rucni registrace extension moznost nastavit before a after pro kazdou phase. Tj muzu zaregistrovat rucne extension A a nastavit ze register hooky se spusti pred B a za C.

David Grudl
founder | 8320
+
+2
-

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.