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

David Grudl
Nette Core | 8301
+
+15
-

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

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 (Včera 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.