Créer des extensions pour Nette DI
Une extension est une classe qui s'accroche à la compilation du conteneur DI. Elle sait enregistrer des services par programme, valider sa propre section de configuration, modifier les services définis par d'autres et même retoucher le code généré du conteneur. Cette page vous apprend à en écrire une, ce qui se passe et quand, et à quoi faire attention.
Les extensions sont la manière native dont les paquets s'intègrent à Nette : tous les paquets nette/* en
utilisent, et les vôtres le peuvent aussi. Une extension typique fait une ou plusieurs de ces choses :
- elle intègre une bibliothèque – elle enregistre ses services dans le conteneur et expose une section de
configuration agréable et validée (c'est de là que viennent les sections
mail:oudatabase:) - elle automatise l'enregistrement – elle enregistre en boucle, ou selon une règle, de nombreux services similaires
qu'il serait fastidieux d'énumérer dans
services: - elle apporte des changements transversaux – elle retrouve les services enregistrés par d'autres et les complète, par exemple en attachant un logger à chaque service portant un certain tag
Pour le travail quotidien sur une application, vous en avez rarement besoin : la section services de la configuration couvre l'enregistrement et le câblage de vos classes. Tournez-vous vers une extension lorsque la configuration seule ne suffit plus.
Une extension s'active dans la section extensions. C'est ainsi que vous ajoutez une extension représentée par la
classe BlogExtension sous le nom blog :
extensions:
blog: BlogExtension
Si son constructeur prend des arguments, passez-les directement ici :
extensions:
blog: BlogExtension(%debugMode%)
Comment fonctionne la compilation
Pour écrire des extensions en confiance, vous devez savoir une chose essentielle : quand votre code s'exécute. Nette ne câble pas les services pendant le traitement des requêtes. Il compile le conteneur à l'avance : il lit tous les fichiers de configuration, laisse les extensions faire leur travail et génère une classe PHP optimisée qu'il stocke sur le disque. Chaque requête suivante ne fait plus que charger cette classe terminée. Le code de votre extension ne s'exécute donc que lorsque le conteneur est (re)construit, et non à chaque requête.
Cela a une conséquence importante : pendant la compilation, aucun service n'existe encore. Ce qui existe, ce sont des
définitions – des recettes qui décrivent la classe que sera chaque service, la façon de le créer et ce qu'il faudra
lui appeler ensuite. Les définitions vivent dans l'objet ContainerBuilder. Une extension est
essentiellement une configuration scriptable : tout ce que vous pouvez déclarer dans la section services:,
vous pouvez aussi le construire en PHP – conditionnellement, dans des boucles ou en réaction à ce que d'autres ont
enregistré.
La compilation se déroule en phases, et une extension peut intervenir dans chacune d'elles :
- les sections de configuration de toutes les extensions sont validées (
getConfigSchema()) - chaque extension enregistre ses services (
loadConfiguration()) ; la sectionservices:de l'utilisateur est traitée en dernier, l'application a donc toujours le dernier mot - une fois toutes les définitions en place et les types des services résolus, les extensions peuvent les modifier
(
beforeCompile()) - la classe du conteneur est générée ; les extensions peuvent encore en ajuster le code (
afterCompile()) et émettre du code qui s'exécutera au démarrage de l'application (initialisation)
En mode développement, le conteneur est recompilé automatiquement dès que vous modifiez un fichier de configuration ou la classe de l'extension elle-même – les deux sont suivis comme dépendances. Vous pouvez donc développer des extensions sans jamais vider de cache.
Pour un examen plus approfondi de ce qui se passe dans chaque phase – quand les paramètres sont développés,
quand @service devient une référence et à quel moment exactement il est sûr de chercher les services par
type – voir La compilation du conteneur en détail.
Première extension
Voici une extension petite mais complète. Nous l'activons et la configurons dans le même fichier :
extensions:
blog: BlogExtension
blog:
postsPerPage: 5
Et voici la classe entière :
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() décrit ce que la section blog: (nommée d'après la clé sous laquelle nous avons
enregistré l'extension) peut contenir, types et valeurs par défaut compris – les valeurs validées sont ensuite disponibles
dans $this->config. Dans loadConfiguration(), nous enregistrons les services. Notez les noms :
$this->prefix('articles') produit blog.articles, les services de différentes extensions ne peuvent
donc pas entrer en collision.
Et les dernières lignes montrent pourquoi les extensions existent : le service comments n'est enregistré que si
les commentaires sont activés. Un simple fichier de configuration ne peut pas prendre de telles décisions.
Les services enregistrés de cette façon se comportent exactement comme s'ils étaient écrits dans
services: – ils sont créés paresseusement à la demande, et l'autowiring les passe partout où
Blog\Articles est déclaré comme type.
Les chapitres suivants décrivent en détail le cycle de vie d'une extension, puis l'API du ContainerBuilder que vous utiliserez à l'intérieur de l'extension, et enfin les pièges qu'il vaut mieux connaître.
Cycle de vie d'une extension
Une extension hérite de Nette\DI\CompilerExtension
et redéfinit certaines des quatre méthodes getConfigSchema(), loadConfiguration(),
beforeCompile() et afterCompile(), que le compilateur appelle dans cet ordre pendant la compilation.
getConfigSchema(): Nette\Schema\Schema
Définit le schéma de la section de configuration de l'extension. Grâce à lui, les utilisateurs obtiennent gratuitement la
validation et des messages d'erreur clairs : une faute de frappe ou un mauvais type dans la section blog: est
signalé par un message compréhensible, sans que vous écriviez la moindre vérification.
Le schéma se décrit à l'aide de la bibliothèque Schema et peut exprimer les types, les valeurs par défaut, les valeurs autorisées et bien plus encore :
public function getConfigSchema(): Nette\Schema\Schema
{
return Expect::structure([
'postsPerPage' => Expect::int(10),
'storage' => Expect::anyOf('files', 'database')->firstIsDefault(),
]);
}
La configuration validée est disponible dans $this->config sous forme d'objet stdClass (ou de
tableau, si vous ajoutez castTo('array') au schéma).
Si la valeur d'une option ne peut pas être connue à la compilation – parce qu'elle vient par exemple d'une variable
d'environnement – marquez-la avec dynamic(), par exemple Expect::int()->dynamic(). Plus
d'informations dans paramètres
dynamiques.
loadConfiguration()
L'endroit où l'extension enregistre ses services, à l'aide du ContainerBuilder :
public function loadConfiguration(): void
{
$builder = $this->getContainerBuilder();
$builder->addDefinition($this->prefix('articles'))
->setFactory(Blog\Articles::class);
}
Si un service doit également être disponible sous un nom court, ajoutez un alias. Par convention, on ne le fait que lorsque l'extension est enregistrée sous son nom habituel, afin que plusieurs instances de l'extension ne se le disputent pas :
if ($this->name === 'blog') {
$builder->addAlias('articles', $this->prefix('articles'));
}
Quand les services sont nombreux, il peut être plus commode de les définir dans un fichier NEON séparé avec la syntaxe
familière des services. Le préfixe @extension fait référence à l'extension
courante :
services:
articles:
create: MyBlog\ArticlesModel(@connection)
comments:
create: MyBlog\CommentsModel(@connection, @extension.articles)
Nous chargeons ces définitions avec loadDefinitionsFromConfig() ; les noms reçoivent automatiquement le préfixe
et le fichier est suivi comme dépendance, si bien que le modifier déclenche une recompilation :
public function loadConfiguration(): void
{
$this->loadDefinitionsFromConfig(
$this->loadFromFile(__DIR__ . '/services.neon')['services'],
);
}
beforeCompile()
Lorsque cette méthode est appelée, le builder contient déjà toutes les définitions : les vôtres, celles des autres extensions et celles des fichiers de configuration de l'utilisateur. Les types des services ont eux aussi été résolus, la recherche par type est donc fiable. Cette phase est de ce fait idéale pour inspecter et compléter le graphe final des services.
Typiquement, vous cherchez les services par tag ou par type et complétez les définitions trouvées :
public function beforeCompile(): void
{
$builder = $this->getContainerBuilder();
foreach ($builder->findByTag('logaware') as $name => $attrs) {
$builder->getDefinition($name)->addSetup('setLogger');
}
}
L'appel setLogger() n'a pas d'arguments explicites – l'autowiring les fournira, comme il le fait dans les
factories.
Vous pouvez aussi coopérer avec les autres extensions enregistrées, obtenues via
$this->compiler->getExtensions(), éventuellement filtrées par classe ou interface :
foreach ($this->compiler->getExtensions(FooExtension::class) as $extension) {
// ...
}
afterCompile(Nette\PhpGenerator\ClassType $class)
Dans la dernière phase, la classe du conteneur est générée sous forme d'objet ClassType de la bibliothèque PHP Generator. Elle contient une méthode fabrique pour chaque service et s'apprête à être écrite dans le cache. Vous pouvez encore en modifier le code :
public function afterCompile(Nette\PhpGenerator\ClassType $class): void
{
$method = $class->getMethod('__construct');
// ...
}
Vous n'aurez besoin de cette phase que rarement. Pour ajouter du code qui s'exécute au démarrage de l'application, utilisez plutôt l'initialisation :
Code d'initialisation
Toutes les phases précédentes influencent la façon dont le conteneur est construit. Une extension peut en outre
émettre du code qui s'exécute à l'exécution, juste après la création du conteneur – par exemple pour démarrer une
session ou lancer des services. Le code s'écrit dans l'objet $this->initialization à l'aide de sa méthode addBody() :
public function loadConfiguration(): void
{
// les services portant le tag 'run' doivent être créés juste après le démarrage du conteneur
$builder = $this->getContainerBuilder();
foreach ($builder->findByTag('run') as $name => $attrs) {
$this->initialization->addBody('$this->getService(?);', [$name]);
}
}
Nette utilise lui-même l'initialisation, par exemple pour démarrer automatiquement la session ou envoyer les en-têtes HTTP de sécurité. Et gardez à l'esprit que, contrairement à tout le reste dans une extension, ce code s'exécute à chaque requête : gardez-le donc réduit.
ContainerBuilder
Nette\DI\ContainerBuilder est l'objet par lequel une
extension dialogue avec le compilateur. Il contient les définitions de tous
les services et offre des méthodes pour les ajouter, les retrouver et les modifier. Vous l'obtenez dans
loadConfiguration() et beforeCompile() :
$builder = $this->getContainerBuilder();
Ajouter des services
Enregistrer un service, c'est la même chose que ce que vous faites dans la section services: d'un fichier
NEON – écrit en PHP. Chaque clé de configuration a sa méthode correspondante sur la définition, ces deux notations sont
donc équivalentes :
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');
La définition renvoyée par addDefinition() est une ServiceDefinition qui
offre les équivalents des clés de configuration : setType() (la classe du service), setFactory()
(comment le créer), setArguments(), addSetup(), addTag() et
setAutowired().
addSetup() reflète la liste setup: et accepte les mêmes formes : un appel de méthode
addSetup('setLogger', ['@logger']), une affectation de propriété addSetup('$cache', ['@cache']) ou un
appel sur un autre service addSetup('@Tracy\Bar::addPanel', [$panel]).
Outre les services ordinaires, le builder sait aussi enregistrer des factories générées, des accesseurs et des locators – chacun avec sa propre méthode qui renvoie le type de définition correspondant :
| Méthode | Enregistre |
|---|---|
addDefinition() |
un service ordinaire (renvoie ServiceDefinition) |
addFactoryDefinition() |
une factory générée (interface avec une méthode create()) |
addAccessorDefinition() |
un accesseur généré (interface avec une méthode get()) |
addLocatorDefinition() |
une multifactory / locator combinant plusieurs factories |
addImportedDefinition() |
un service passé au conteneur depuis l'extérieur à l'exécution |
addAlias() |
un second nom pour un service existant |
Avec une factory, vous configurez l'objet qu'elle crée via getResultDefinition() ; un accesseur, lui, pointe vers
un service existant via setReference() :
$builder->addFactoryDefinition($this->prefix('latteFactory'))
->setImplement(LatteFactory::class)
->getResultDefinition()
->setFactory(Latte\Engine::class)
->addSetup('setStrictTypes', [true]);
addLocatorDefinition() et addImportedDefinition() sont rarement nécessaires – de tels services
proviennent généralement des clés implement: et des services importés en NEON, plutôt que d'être écrits à
la main.
Trouver et modifier des services
Pour rechercher et parcourir les définitions existantes, le builder fournit :
| Méthode | Description |
|---|---|
getDefinition(string $name) |
la définition portant le nom donné (lève une exception si elle manque) |
hasDefinition(string $name) |
indique si une définition ou un alias de ce nom existe |
getDefinitions() |
toutes les définitions |
removeDefinition(string $name) |
supprime une définition |
getByType(string $type) |
le nom du service autowiré de ce type, ou null |
getDefinitionByType(string $type) |
la définition autowirée de ce type |
findByType(string $type) |
toutes les définitions de ce type sous forme de paires nom => définition |
findByTag(string $tag) |
les services portant le tag sous forme de paires nom => valeur du tag |
addExcludedClasses(array $types) |
exclut des classes et interfaces de l'autowiring |
Un idiome pratique consiste à utiliser getByType() pour savoir si un service existe seulement – par exemple
pour se raccrocher à un logger uniquement si l'application en a un :
if ($builder->getByType(Psr\Log\LoggerInterface::class)) {
$builder->getDefinition($this->prefix('articles'))
->addSetup('setLogger');
}
Types de définitions
Chaque méthode add*Definition() renvoie un genre de définition différent. Toutes étendent l'ancêtre commun
Nette\DI\Definitions\Definition :
ServiceDefinition– un service ordinaire ; se configure avecsetType(),setFactory(),addSetup(),addTag()etsetAutowired()FactoryDefinition– une factory générée : une interface dont la méthodecreate()renvoie un nouvel objet à chaque appelAccessorDefinition– un accesseur généré : une interface dont la méthodeget()renvoie un service existantLocatorDefinition– une multifactory / locator combinant plusieurs factories ou accesseurs dans une seule interfaceImportedDefinition– un service que le conteneur ne crée pas lui-même, mais reçoit de l'extérieur à l'exécution
Gardez à l'esprit que getDefinition() renvoie le genre de définition qui vit sous le nom donné, quel qu'il
soit. Si votre code peut tomber sur une factory générée, vérifiez d'abord le type et configurez l'objet produit via
getResultDefinition() :
$def = $builder->getDefinition($name);
if ($def instanceof Nette\DI\Definitions\FactoryDefinition) {
$def = $def->getResultDefinition();
}
$def->addSetup('setLogger');
Conseils et pièges
Compilation ou exécution
La source de confusion la plus fréquente : le code d'une extension s'exécute pendant la compilation du conteneur, pas pendant le traitement des requêtes. En pratique, cela signifie que :
- Une extension ne travaille jamais avec des instances de services – elles n'existent pas encore. N'instanciez pas de
services avec
new; enregistrez une définition et laissez le conteneur les créer. - Toutes les valeurs de configuration sont figées dans le code généré. Une valeur qui peut différer d'un environnement à
l'autre (un chemin, un mot de passe issu de
getenv()) doit être marquée comme dynamique, sinon elle est figée à la compilation. - Les chaînes passées à
$this->initialization->addBody()ne sont pas exécutées maintenant – c'est du code PHP émis dans le conteneur, exécuté à chaque requête.
Dépendances de fichiers
Le conteneur est recompilé lorsque les fichiers de configuration ou les classes d'extensions changent. Mais si votre extension lit un autre fichier – une liste d'entités, la configuration XML d'une bibliothèque – le conteneur n'a aucun moyen de le savoir. Enregistrez de tels fichiers avec :
$builder->addDependency($file);
Sinon, vous vous exposez à un mystère classique : vous modifiez le fichier, mais l'application continue de se comporter comme
avant – le changement n'apparaît qu'une fois le conteneur reconstruit pour une autre raison. (Les fichiers lus via
loadFromFile() sont suivis automatiquement.)
Enregistrement conditionnel
Une extension peut s'adapter à son environnement. Les intégrations facultatives sont typiquement protégées par
class_exists() :
if (class_exists(Symfony\Component\Console\Command\Command::class)) {
$builder->addDefinition($this->prefix('command'))
->setFactory(Blog\Console\SitemapCommand::class);
}
Et il vaut mieux passer les valeurs comme %debugMode% par le constructeur de l'extension :
extensions:
blog: BlogExtension(%debugMode%)
class BlogExtension extends Nette\DI\CompilerExtension
{
public function __construct(
private bool $debugMode = false,
) {}
}
Un usage typique est d'enregistrer un panneau Tracy uniquement en mode développement.
Arguments complexes
Parfois, un argument d'une factory ou d'un appel de setup n'est ni une valeur simple, ni un nom de classe, ni une référence
@service. Pour ces cas-là, il y a :
new Nette\DI\Definitions\Statement(Blog\Panel::class, [$args])– un objet créé sur place, un „service anonyme“ utilisé comme argumentnew Nette\DI\Definitions\Reference('blog.articles')– une référence vers un service, l'équivalent objet de la chaîne@name$builder::literal('PHP_SAPI')– un morceau de code PHP brut inséré tel quel dans le conteneur généré
Exemple – enregistrement d'un panneau Tracy :
$builder->getDefinition($this->prefix('articles'))
->addSetup('@Tracy\Bar::addPanel', [
new Nette\DI\Definitions\Statement(Blog\ArticlesPanel::class),
]);
Tags et types exportés
L'export des métadonnées peut être restreint dans la
configuration, de sorte que le conteneur compilé ne conserve que les tags et les types d'autowiring que l'application utilise
réellement. Si votre extension récupère des services à l'exécution avec $container->findByTag() ou
$container->getByType(), une telle restriction pourrait supprimer précisément les métadonnées sur lesquelles
vous comptez.
Pour l'éviter, indiquez au compilateur quels tags et types doivent toujours être exportés :
public function loadConfiguration(): void
{
// ce tag sera toujours exporté, même si l'export est restreint
$this->compiler->addExportedTag('event.subscriber');
// ce type sera toujours disponible pour getByType()
$this->compiler->addExportedType(Nette\Database\Connection::class);
}
Ces deux méthodes ne font qu'ajouter aux métadonnées exportées ; elles ne remplacent jamais la configuration
di › export de l'application. Ainsi, lorsque l'application restreint l'export à une liste, les tags et types dont
votre extension a besoin y restent inclus ; seule la désactivation complète de l'export des tags (tags: false) les
écarte avec tout le reste.