Extensions für Nette DI erstellen

Eine Extension ist eine Klasse, die sich in die Kompilierung des DI-Containers einklinkt. Sie kann Services programmatisch registrieren, ihren eigenen Konfigurationsabschnitt validieren, Services anderer verändern und sogar den erzeugten Code des Containers anpassen. Diese Seite zeigt Ihnen, wie Sie eine schreiben, was wann passiert und worauf Sie achten müssen.

Über Extensions binden sich Pakete auf die native Art in Nette ein: Alle nette/*-Pakete nutzen sie, und Ihre kann das auch. Eine typische Extension erledigt eines oder mehrere dieser Dinge:

  • bindet eine Bibliothek ein – registriert deren Services im Container und stellt einen freundlichen, validierten Konfigurationsabschnitt bereit (daher kommen die Abschnitte mail: oder database:)
  • automatisiert die Registrierung – registriert viele ähnliche Services in einer Schleife oder nach einer Regel, wo es mühsam wäre, sie in services: aufzuzählen
  • nimmt übergreifende Änderungen vor – findet Services, die andere registriert haben, und ergänzt sie, hängt etwa an jeden Service mit einem bestimmten Tag einen Logger

Für die tägliche Arbeit an einer Anwendung brauchen Sie selten eine – der Abschnitt services der Konfiguration deckt das Registrieren und Verdrahten Ihrer Klassen ab. Greifen Sie zu einer Extension, wenn die Konfiguration allein nicht mehr reicht.

Aktiviert wird eine Extension im Abschnitt extensions. So fügen Sie eine Extension, die die Klasse BlogExtension darstellt, unter dem Namen blog hinzu:

extensions:
	blog: BlogExtension

Wenn ihr Konstruktor Argumente entgegennimmt, übergeben Sie sie gleich dort:

extensions:
	blog: BlogExtension(%debugMode%)

Wie die Kompilierung abläuft

Um Extensions sicher schreiben zu können, müssen Sie eines wissen: wann Ihr Code läuft. Nette verdrahtet die Services nicht beim Bearbeiten von Requests. Stattdessen kompiliert es den Container im Voraus: Es liest alle Konfigurationsdateien, lässt die Extensions ihre Arbeit tun und erzeugt eine optimierte PHP-Klasse, die es auf der Festplatte ablegt. Jeder weitere Request lädt nur noch diese fertige Klasse. Der Code Ihrer Extension läuft also nur dann, wenn der Container (neu) gebaut wird – nicht bei jedem Request.

Das hat eine wichtige Folge: Während der Kompilierung existiert noch kein einziger Service. Was existiert, sind Definitionen – Rezepte, die beschreiben, welche Klasse jeder Service haben wird, wie er zu erzeugen ist und was danach auf ihm aufgerufen werden soll. Die Definitionen leben im Objekt ContainerBuilder. Eine Extension ist im Grunde skriptbare Konfiguration: Alles, was Sie im Abschnitt services: deklarieren können, können Sie auch in PHP bauen – bedingt, in Schleifen oder als Reaktion darauf, was andere registriert haben.

Die Kompilierung läuft in Phasen ab, und eine Extension kann in jede von ihnen eingreifen:

  1. die Konfigurationsabschnitte aller Extensions werden validiert (getConfigSchema())
  2. jede Extension registriert ihre Services (loadConfiguration()); der Abschnitt services: des Nutzers wird zuletzt verarbeitet, die Anwendung hat also immer das letzte Wort
  3. sobald alle Definitionen stehen und die Typen der Services aufgelöst sind, dürfen die Extensions sie verändern (beforeCompile())
  4. die Klasse des Containers wird erzeugt; die Extensions können ihren Code noch anpassen (afterCompile()) und Code ausgeben, der beim Start der Anwendung läuft (Initialisierung)

Im Entwicklermodus wird der Container automatisch neu kompiliert, sobald Sie eine Konfigurationsdatei oder die Klasse der Extension selbst ändern – beides wird als Abhängigkeit verfolgt. Sie können Extensions also entwickeln, ohne je einen Cache zu leeren.

Einen tieferen Blick darauf, was in jeder Phase geschieht – wann Parameter aufgelöst werden, wann aus @service eine Referenz wird und ab wann es sicher ist, Services nach Typ zu suchen -, bietet Kompilierung im Detail.

Die erste Extension

Hier ist eine kleine, aber vollständige Extension. Aktiviert und konfiguriert wird sie in derselben Datei:

extensions:
	blog: BlogExtension

blog:
	postsPerPage: 5

Und das ist die ganze Klasse:

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() beschreibt, was der Abschnitt blog: (benannt nach dem Schlüssel, unter dem wir die Extension registriert haben) enthalten darf, samt Typen und Standardwerten – die validierten Werte stehen danach in $this->config zur Verfügung. In loadConfiguration() registrieren wir die Services. Achten Sie auf die Namen: $this->prefix('articles') erzeugt blog.articles, sodass sich Services verschiedener Extensions nicht in die Quere kommen können.

Und die letzten Zeilen zeigen, wozu es Extensions überhaupt gibt: Der Service comments wird nur registriert, wenn Kommentare eingeschaltet sind. Eine reine Konfigurationsdatei kann solche Entscheidungen nicht treffen.

So registrierte Services verhalten sich genau so, als stünden sie in services: – sie werden bei Bedarf lazy erzeugt, und das Autowiring übergibt sie überall dorthin, wo Blog\Articles als Typ angegeben ist.

Die folgenden Kapitel beschreiben ausführlich den Lebenszyklus einer Extension, dann die API des ContainerBuilder, die Sie in der Extension verwenden, und schließlich die Fallstricke, die man kennen sollte.

Lebenszyklus einer Extension

Eine Extension erbt von Nette\DI\CompilerExtension und überschreibt einige der vier Methoden getConfigSchema(), loadConfiguration(), beforeCompile() und afterCompile(), die der Compiler während der Kompilierung in dieser Reihenfolge aufruft.

getConfigSchema(): Nette\Schema\Schema

Definiert das Schema des Konfigurationsabschnitts der Extension. Dadurch bekommen die Nutzer Validierung und klare Fehlermeldungen geschenkt: Ein Tippfehler oder ein falscher Typ im Abschnitt blog: wird mit einer verständlichen Meldung gemeldet, ohne dass Sie eine einzige Prüfung schreiben.

Das Schema wird mit der Bibliothek Schema beschrieben und kann Typen, Standardwerte, erlaubte Werte und vieles mehr ausdrücken:

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

Die validierte Konfiguration steht in $this->config als Objekt vom Typ stdClass zur Verfügung (oder als Array, wenn Sie dem Schema castTo('array') anhängen).

Wenn der Wert einer Option zur Kompilierzeit nicht bekannt sein kann – etwa weil er aus einer Umgebungsvariablen stammt -, kennzeichnen Sie ihn mit dynamic(), also zum Beispiel Expect::int()->dynamic(). Mehr dazu unter dynamische Parameter.

loadConfiguration()

Der Ort, an dem die Extension über den ContainerBuilder ihre Services registriert:

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

Soll ein Service auch unter einem kurzen Namen verfügbar sein, ergänzen Sie einen Alias. Üblicherweise geschieht das nur, wenn die Extension unter ihrem gewohnten Namen registriert ist, damit sich mehrere Instanzen der Extension nicht darum streiten können:

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

Wenn es viele Services sind, ist es womöglich bequemer, sie in einer eigenen NEON-Datei mit der vertrauten services-Syntax zu definieren. Das Präfix @extension verweist auf die aktuelle Extension:

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

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

Diese Definitionen laden wir mit loadDefinitionsFromConfig(); die Namen bekommen automatisch das Präfix, und die Datei wird als Abhängigkeit verfolgt, sodass eine Änderung eine Neukompilierung auslöst:

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

beforeCompile()

Wenn diese Methode aufgerufen wird, hält der Builder bereits alle Definitionen: Ihre, die anderer Extensions und die aus den Konfigurationsdateien des Nutzers. Auch die Typen der Services sind aufgelöst, die Suche nach Typ ist also verlässlich. Damit ist diese Phase ideal, um den endgültigen Graphen der Services zu untersuchen und zu ergänzen.

Typischerweise suchen Sie Services nach Tag oder nach Typ und ergänzen die gefundenen Definitionen:

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

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

Der Aufruf setLogger() hat keine ausdrücklichen Argumente – die liefert das Autowiring, genau wie in Factories.

Sie können auch mit anderen registrierten Extensions zusammenarbeiten, die Sie über $this->compiler->getExtensions() bekommen, wahlweise nach Klasse oder Interface gefiltert:

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

afterCompile (Nette\PhpGenerator\ClassType $class)

In der letzten Phase wird die Klasse des Containers als Objekt ClassType der Bibliothek PHP Generator erzeugt. Sie enthält für jeden Service eine Factory-Methode und steht kurz davor, in den Cache geschrieben zu werden. Ihren Code können Sie noch verändern:

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

Diese Phase werden Sie nur selten brauchen. Um Code zu ergänzen, der beim Start der Anwendung läuft, verwenden Sie stattdessen die Initialisierung:

Initialisierungscode

Alle bisherigen Phasen beeinflussen, wie der Container gebaut wird. Darüber hinaus kann eine Extension Code ausgeben, der zur Laufzeit läuft, unmittelbar nachdem der Container erzeugt wurde – etwa um eine Session zu starten oder Services anzustoßen. Der Code wird über die Methode addBody() in das Objekt $this->initialization geschrieben:

public function loadConfiguration(): void
{
	// Services mit dem Tag 'run' müssen gleich nach dem Start des Containers erzeugt werden
	$builder = $this->getContainerBuilder();
	foreach ($builder->findByTag('run') as $name => $attrs) {
		$this->initialization->addBody('$this->getService(?);', [$name]);
	}
}

Nette selbst nutzt die Initialisierung zum Beispiel, um die Session automatisch zu starten oder Sicherheits-HTTP-Header zu senden. Und denken Sie daran: Anders als alles andere in einer Extension läuft dieser Code bei jedem Request, halten Sie ihn also klein.

ContainerBuilder

Nette\DI\ContainerBuilder ist das Objekt, über das eine Extension mit dem Compiler spricht. Es hält die Definitionen aller Services und bietet Methoden, um sie hinzuzufügen, zu finden und zu verändern. Sie bekommen es in loadConfiguration() und beforeCompile():

$builder = $this->getContainerBuilder();

Services hinzufügen

Einen Service zu registrieren ist dasselbe, was Sie im Abschnitt services: einer NEON-Datei tun – nur in PHP geschrieben. Zu jedem Konfigurationsschlüssel gehört eine Methode auf der Definition, diese beiden Schreibweisen sind also gleichwertig:

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

Die von addDefinition() zurückgegebene Definition ist eine ServiceDefinition mit den Gegenstücken zu den Konfigurationsschlüsseln: setType() (die Klasse des Services), setFactory() (wie er zu erzeugen ist), setArguments(), addSetup(), addTag() und setAutowired().

addSetup() spiegelt die Liste setup: wider und akzeptiert dieselben Formen: einen Methodenaufruf addSetup('setLogger', ['@logger']), eine Zuweisung an eine Property addSetup('$cache', ['@cache']) oder einen Aufruf auf einem anderen Service addSetup('@Tracy\Bar::addPanel', [$panel]).

Neben gewöhnlichen Services kann der Builder auch generierte Factories, Accessors und Locators registrieren – jeweils mit einer eigenen Methode, die den passenden Typ der Definition zurückgibt:

Methode Registriert
addDefinition() einen gewöhnlichen Service (gibt ServiceDefinition zurück)
addFactoryDefinition() eine generierte Factory (Interface mit einer Methode create())
addAccessorDefinition() einen generierten Accessor (Interface mit einer Methode get())
addLocatorDefinition() eine Multifactory bzw. einen Locator, die mehrere Factories vereint
addImportedDefinition() einen Service, der dem Container zur Laufzeit von außen übergeben wird
addAlias() einen zweiten Namen für einen bestehenden Service

Bei einer Factory konfigurieren Sie das Objekt, das sie erzeugt, über getResultDefinition(); ein Accessor verweist stattdessen über setReference() auf einen bestehenden Service:

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

addLocatorDefinition() und addImportedDefinition() braucht man selten – solche Services kommen üblicherweise aus dem Schlüssel implement: und aus importierten Services in NEON, statt von Hand geschrieben zu werden.

Services finden und ändern

Zum Suchen und Durchlaufen der bestehenden Definitionen bietet der Builder:

Methode Beschreibung
getDefinition(string $name) die Definition mit dem angegebenen Namen (wirft, wenn sie fehlt)
hasDefinition(string $name) ob eine Definition oder ein Alias mit dem Namen existiert
getDefinitions() alle Definitionen
removeDefinition(string $name) entfernt eine Definition
getByType(string $type) den Namen des autowireten Services des Typs, oder null
getDefinitionByType(string $type) die autowirete Definition des Typs
findByType(string $type) alle Definitionen des Typs als Paare Name => Definition
findByTag(string $tag) Services mit dem Tag als Paare Name => Wert des Tags
addExcludedClasses(array $types) nimmt Klassen und Interfaces vom Autowiring aus

Ein praktisches Idiom ist, mit getByType() herauszufinden, ob ein Service überhaupt existiert – um sich zum Beispiel nur dann an einen Logger zu hängen, wenn die Anwendung einen hat:

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

Typen von Definitionen

Jede Methode add*Definition() gibt eine andere Art von Definition zurück. Sie alle erben vom gemeinsamen Vorfahren Nette\DI\Definitions\Definition:

  • ServiceDefinition – ein gewöhnlicher Service; konfiguriert mit setType(), setFactory(), addSetup(), addTag() und setAutowired()
  • FactoryDefinition – eine generierte Factory: ein Interface, dessen Methode create() bei jedem Aufruf ein neues Objekt zurückgibt
  • AccessorDefinition – ein generierter Accessor: ein Interface, dessen Methode get() einen bestehenden Service zurückgibt
  • LocatorDefinition – eine Multifactory bzw. ein Locator, die mehrere Factories oder Accessors in einem Interface vereint
  • ImportedDefinition – ein Service, den der Container nicht selbst erzeugt, sondern zur Laufzeit von außen bekommt

Denken Sie daran, dass getDefinition() die Art von Definition zurückgibt, die unter dem angegebenen Namen liegt. Wenn Ihr Code auf eine generierte Factory stoßen kann, prüfen Sie zuerst den Typ und konfigurieren Sie das erzeugte Objekt über getResultDefinition():

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

Tipps und Fallstricke

Kompilierzeit vs. Laufzeit

Die häufigste Quelle für Verwirrung: Der Code einer Extension läuft, während der Container kompiliert wird, nicht während die Anwendung Requests bearbeitet. In der Praxis heißt das:

  • Eine Extension arbeitet nie mit Instanzen von Services – die existieren noch nicht. Erzeugen Sie Services nicht mit new; registrieren Sie eine Definition und lassen Sie sie den Container erzeugen.
  • Alle Konfigurationswerte werden in den erzeugten Code eingebacken. Ein Wert, der sich zwischen Umgebungen unterscheiden kann (ein Pfad, ein Passwort aus getenv()), muss als dynamisch gekennzeichnet werden, sonst wird er zur Kompilierzeit eingefroren.
  • Strings, die Sie an $this->initialization->addBody() übergeben, werden jetzt nicht ausgeführt – sie sind PHP-Code, der in den Container ausgegeben und bei jedem Request ausgeführt wird.

Abhängigkeiten von Dateien

Der Container wird neu kompiliert, wenn sich Konfigurationsdateien oder Klassen von Extensions ändern. Wenn Ihre Extension aber irgendeine andere Datei liest – eine Liste von Entities, eine XML-Konfiguration einer Bibliothek -, kann der Container davon nichts wissen. Melden Sie solche Dateien an mit:

$builder->addDependency($file);

Sonst erwartet Sie ein klassisches Rätsel: Sie ändern die Datei, aber die Anwendung verhält sich weiter wie zuvor – die Änderung zeigt sich erst, wenn der Container aus einem anderen Grund neu gebaut wird. (Über loadFromFile() gelesene Dateien werden automatisch verfolgt.)

Bedingte Registrierung

Eine Extension kann sich an ihre Umgebung anpassen. Optionale Integrationen sichert man üblicherweise mit class_exists() ab:

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

Und Werte wie %debugMode% übergibt man am besten über den Konstruktor der Extension:

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

Ein typischer Anwendungsfall ist, ein Tracy-Panel nur im Entwicklermodus zu registrieren.

Komplexe Argumente

Manchmal ist ein Argument für eine Factory oder einen Setup-Aufruf kein einfacher Wert, kein Klassenname und keine @service-Referenz. Für diese Fälle gibt es:

  • new Nette\DI\Definitions\Statement(Blog\Panel::class, [$args]) – ein an Ort und Stelle erzeugtes Objekt, ein “anonymer Service”, der als Argument dient
  • new Nette\DI\Definitions\Reference('blog.articles') – eine Referenz auf einen Service, das Objekt-Gegenstück zum String @name
  • $builder::literal('PHP_SAPI') – ein Stück rohen PHP-Codes, das unverändert in den erzeugten Container eingefügt wird

Ein Beispiel – die Registrierung eines Tracy-Panels:

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

Exportierte Tags und Typen

Der Export der Metadaten lässt sich in der Konfiguration so einschränken, dass der kompilierte Container nur die Tags und Autowiring-Typen behält, die die Anwendung tatsächlich nutzt. Wenn Ihre Extension zur Laufzeit Services über $container->findByTag() oder $container->getByType() holt, könnte eine solche Einschränkung genau die Metadaten entfernen, auf die Sie sich verlassen.

Um dem vorzubeugen, sagen Sie dem Compiler, welche Tags und Typen immer exportiert werden müssen:

public function loadConfiguration(): void
{
	// dieser Tag wird immer exportiert, auch wenn der Export eingeschränkt ist
	$this->compiler->addExportedTag('event.subscriber');

	// dieser Typ steht für getByType() immer zur Verfügung
	$this->compiler->addExportedType(Nette\Database\Connection::class);
}

Beide Methoden ergänzen die exportierten Metadaten nur; sie überschreiben nie die Konfiguration di › export der Anwendung. Schränkt die Anwendung den Export also auf eine Liste ein, bleiben die Tags und Typen, die Ihre Extension braucht, enthalten; nur wenn der Export der Tags ganz abgeschaltet wird (tags: false), verschwinden sie zusammen mit allem anderen.

Version: 3.x