Les services n'ont pas besoin de noms

il y a 2 ans de Jiří Pudil  

J'adore la solution d'injection de dépendances de Nette Framework. Je l'aime vraiment. Ce billet est là pour partager cette passion, en expliquant pourquoi je pense que c'est la meilleure solution DI dans l'écosystème PHP actuel.

(Ce billet a été initialement publié sur le blog de l'auteur).

Le développement de logiciels est un processus itératif sans fin d'abstraction. Nous trouvons des abstractions appropriées du monde réel dans la modélisation du domaine. Dans la programmation orientée objet, nous utilisons des abstractions pour décrire et faire respecter les contrats entre les différents acteurs du système. Nous introduisons de nouvelles classes dans le système pour encapsuler les responsabilités et définir leurs limites, puis nous utilisons la composition pour construire l'ensemble du système.

Je parle de l'envie d'extraire la logique d'authentification du contrôleur suivant :

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        if ($username !== 'admin' || $password !== 'p4ssw0rd!') {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Vous pouvez probablement dire que la vérification des informations d'identification n'a pas sa place ici. Il n'est pas de la responsabilité du contrôleur de dire quelles informations d'identification sont valides – selon le principe de la responsabilité unique, le contrôleur ne devrait avoir qu'une seule raison de changer, et cette raison devrait se trouver dans l'interface utilisateur de l'application, et non dans le processus d'authentification.

Prenons la solution la plus évidente et extrayons la condition dans une classe Authenticator:

final class Authenticator
{
    public function authenticate(string $username, string $password): bool
    {
        return $username === 'admin' && $password === 'p4ssw0rd!';
    }
}

Maintenant, tout ce que nous devons faire est de déléguer du contrôleur à cet authentificateur. Nous avons fait de l'authentificateur une dépendance du contrôleur, et soudainement le contrôleur a besoin de l'obtenir quelque part :

final class SignInController extends Best\Framework\Controller
{
    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = new Authenticator(); // <== facile, je vais juste en créer un nouveau !
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

        $this->signIn(new Identity($username));
        return $this->redirect(HomepageController::class);
    }
}

Cette méthode naïve fonctionnera. Mais seulement jusqu'à ce qu'une authentification plus robuste soit mise en œuvre, nécessitant que l'authentificateur interroge une table de base de données des utilisateurs. Le Authenticator a soudainement une dépendance propre, disons un UserRepository, qui à son tour dépend d'une instance Connection, qui dépend des paramètres de l'environnement spécifique. Cela a vite dégénéré !

Créer des instances à la main partout n'est pas une façon durable de gérer les dépendances. C'est pourquoi nous avons le modèle d'injection de dépendances, qui permet au contrôleur de déclarer simplement sa dépendance à l'égard de Authenticator, et de laisser à quelqu'un d'autre le soin de fournir une instance. Et ce quelqu'un d'autre est appelé “conteneur” d'injection de dépendances.

Le conteneur d'injection de dépendances est l'architecte suprême de l'application – il sait comment résoudre les dépendances de tout service au sein du système et est chargé de les créer. Les conteneurs d'injection de dépendances sont si courants de nos jours que presque tous les principaux frameworks web ont leur propre implémentation de conteneur, et il existe même des paquets autonomes dédiés à l'injection de dépendances, comme PHP-DI.

Brûler les poivrons

L'abondance des options a finalement motivé un groupe de développeurs à rechercher une abstraction pour les rendre interopérables. L'interface commune a été perfectionnée au fil du temps et finalement proposée à PHP-FIG sous la forme suivante :

interface ContainerInterface
{
    public function get(string $id): mixed;
    public function has(string $id): bool;
}

Cette interface illustre un attribut très important des conteneurs DI : **Ils sont un bon serviteur mais peuvent facilement devenir un mauvais maître. Ils sont extrêmement utiles tant que vous savez comment les utiliser, mais si vous les utilisez incorrectement, ils vous brûlent. Considérez le conteneur suivant :

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories['authenticator'] = fn() => new Authenticator($this->get('userRepository'));
        $this->factories['userRepository'] = fn() => new UserRepository($this->get('connection'));
        $this->factories['connection'] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Jusqu'ici tout va bien. L'implémentation semble bonne selon les normes que nous avons établies : elle sait en effet comment créer chaque service de l'application, en résolvant récursivement ses dépendances. Tout est géré en un seul endroit, et le conteneur accepte même des paramètres, de sorte que la connexion à la base de données est facilement configurable. Joli !

Mais maintenant, en voyant les deux seules méthodes de ContainerInterface, vous pourriez être tenté d'utiliser le conteneur comme ceci :

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = $this->container->get('authenticator');
        //...
    }
}

Félicitations, vous venez de brûler vos poivrons. En d'autres termes, le conteneur est devenu le mauvais maître. Et pourquoi ça ?

Premièrement, vous vous appuyez sur un identifiant de service arbitraire : 'authenticator'. L'injection de dépendances consiste à être transparent sur ses dépendances, et l'utilisation d'un identifiant artificiel va directement à l'encontre de cette notion : elle rend le code silencieusement dépendant de la définition du conteneur. Si jamais vous renommez le service dans le conteneur, vous devrez retrouver cette référence et la mettre à jour.

Et le pire, c'est que cette dépendance est cachée : à première vue, de l'extérieur, le contrôleur ne dépend que de l'abstraction d'un conteneur. Mais en tant que développeur, vous devez savoir comment les services sont nommés dans le conteneur, et qu'un service appelé authenticator est, en fait, une instance de Authenticator. Votre nouveau collègue doit apprendre tout cela. Inutilement.

Heureusement, nous pouvons recourir à un identifiant beaucoup plus naturel : le type de service. Après tout, c'est tout ce qui vous intéresse en tant que développeur. Vous n'avez pas besoin de savoir quelle chaîne aléatoire est associée au service dans le conteneur. Je pense que ce code est beaucoup plus facile à écrire et à lire :

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private ContainerInterface $container,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $authenticator = $this->container->get(Authenticator::class);
        //...
    }
}

Malheureusement, nous n'avons pas encore apprivoisé les flammes. Pas le moins du monde. Le plus gros problème est que vous rabaissez le conteneur au rôle de localisateur de services, ce qui est un énorme anti-modèle. C'est comme si vous apportiez à quelqu'un tout le réfrigérateur pour qu'il puisse aller y chercher un seul en-cas – il est beaucoup plus raisonnable de lui apporter seulement l'en-cas.

Encore une fois, l'injection de dépendances est une question de transparence, et ce contrôleur n'est toujours pas transparent sur ses dépendances. La dépendance sur un authentificateur est entièrement cachée du monde extérieur, derrière la dépendance sur le conteneur. Cela rend le code plus difficile à lire. Ou à utiliser. Ou à tester ! Pour simuler l'authentificateur dans un test unitaire, vous devez maintenant créer un conteneur entier autour de lui.

Et d'ailleurs, le contrôleur dépend toujours de la définition du conteneur, et il le fait d'une manière assez mauvaise. Si le service d'authentification n'existe pas dans le conteneur, le code n'échoue que dans la méthode action(), ce qui est un retour assez tardif.

Cuisiner quelque chose de délicieux

Pour être honnête, personne ne peut vraiment vous reprocher de vous retrouver dans cette impasse. Après tout, vous avez simplement suivi l'interface conçue et éprouvée par des développeurs intelligents. Le fait est que tous les conteneurs d'injection de dépendances sont par définition des localisateurs de services également, et il s'avère que le modèle est vraiment la seule interface commune entre eux. Mais cela ne signifie pas que vous devriez les utiliser comme localisateurs de services. En fait, le PSR lui-même met en garde contre cela.

Voici comment vous pouvez utiliser un conteneur DI comme un bon serviteur :

final class SignInController extends Best\Framework\Controller
{
    public function __construct(
        private Authenticator $authenticator,
    ) {}

    public function action(string $username, string $password): Best\Framework\Response
    {
        $areCredentialsValid = $this->authenticator->authenticate($username, $password);
        //...
    }
}

Le contrôleur déclare la dépendance de manière explicite, claire et transparente dans le constructeur. Les dépendances ne sont plus cachées, éparpillées dans la classe. Elles sont également appliquées : le conteneur ne peut pas créer une instance de SignInController sans fournir le Authenticator nécessaire. S'il n'y a pas d'authentificateur dans le conteneur, l'exécution échoue au début, et non dans la méthode action(). Tester cette classe est également devenu beaucoup plus facile, car il suffit de simuler le service de l'authentificateur sans avoir besoin d'un modèle de conteneur.

Et il y a un dernier détail minuscule, mais très important : nous avons introduit en douce l'information sur le type du service. Le fait qu'il s'agisse d'une instance de Authenticator – auparavant implicite et inconnu de l'IDE, des outils d'analyse statique ou même d'un développeur ignorant la définition du conteneur – est maintenant gravé de manière statique dans le typehint du paramètre promu.

Il ne reste plus qu'à apprendre au conteneur comment créer également le contrôleur :

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
        $this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
        $this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
        $this->factories[Connection::class] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Vous pouvez remarquer que le conteneur utilise toujours en interne l'approche du localisateur de services. Mais ce n'est pas grave, tant qu'il est contenu (jeu de mots). Le seul endroit à l'extérieur du conteneur où l'appel de la méthode get est acceptable est le site index.php, au point d'entrée de l'application où vous devez créer le conteneur lui-même, puis aller chercher et exécuter l'application :

$container = bootstrap();

$application = $container->get(Best\Framework\Application::class);
$application->run();

La perle cachée

Mais ne nous arrêtons pas là, permettez-moi d'aller plus loin : le seul endroit où l'appel à la méthode get est acceptable, c'est dans le point d'entrée.

Le code du conteneur n'est qu'un câblage, ce sont des instructions d'assemblage. Ce n'est pas du code exécutif. Il n'est pas important, d'une certaine manière. Si oui, il est crucial pour l'application, c'est uniquement du point de vue du développeur. Il n'apporte pas vraiment de valeur directe à l'utilisateur et doit être traité en gardant cela à l'esprit.

Regardez à nouveau le conteneur :

final class Container implements ContainerInterface
{
    private array $factories = [];
    public function __construct(array $parameters)
    {
        $this->factories[SignInController::class] = fn() => new SignInController($this->get(Authenticator::class));
        $this->factories[Authenticator::class] = fn() => new Authenticator($this->get(UserRepository::class));
        $this->factories[UserRepository::class] = fn() => new UserRepository($this->get(Connection::class));
        $this->factories[Connection::class] = fn() => new Connection($parameters['database']);
    }

    public function get(string $id): mixed { /* . . . */ }
    public function has(string $id): bool { /* . . . */ }
}

Ceci ne couvre qu'un segment très petit et simple de l'application. Au fur et à mesure que l'application se développe, écrire le conteneur à la main devient incroyablement fastidieux. Comme je l'ai déjà dit, le conteneur n'est qu'un manuel d'assemblage, mais il est excessivement compliqué, avec de nombreuses pages, d'innombrables références croisées et beaucoup d'avertissements en petits caractères. Nous voulons le transformer en un manuel de style IKEA, graphique, concis et illustré de personnes souriantes lorsqu'elles posent l'ÅUTHENTICATÖR sur le tapis pendant le montage pour qu'il ne se casse pas.

C'est là que Nette Framework entre en jeu.

La solution DI de Nette Framework utilise Neon, un format de fichier de configuration similaire à YAML, mais sous stéroïdes. Voici comment définir le même conteneur, en utilisant la configuration Neon :

services:
    - SignInController
    - Authenticator
    - UserRepository
    - Connection(%database%)

Permettez-moi de souligner deux choses notables : tout d'abord, la liste des services est véritablement une liste, et non une carte de hachage – il n'y a pas de clés, pas d'identifiants de service artificiels. Il n'y a pas de authenticator, ni de Authenticator::class. Deuxièmement, vous n'avez pas besoin de lister explicitement les dépendances, à l'exception des paramètres de connexion à la base de données.

C'est parce que Nette Framework s'appuie sur le câblage automatique. Rappelez-vous comment, grâce à l'injection de dépendance, nous avons pu exprimer le type de la dépendance dans un typehint natif ? Le conteneur DI utilise cette information, de sorte que lorsque vous avez besoin d'une instance de Authenticator, il ignore complètement les noms et trouve le bon service uniquement par son type.

Vous pourriez dire que le câblage automatique n'est pas une caractéristique unique. Et vous auriez raison. Ce qui rend le conteneur de Nette Framework unique, c'est l'utilisation du système de types de PHP, alors que dans de nombreux autres conteneurs, le câblage automatique est toujours construit sur les noms de services en interne. Il existe des scénarios où les autres conteneurs ne sont pas à la hauteur. Voici comment définir le service d'authentification dans le conteneur DI de Symfony en utilisant YAML :

services:
  Authenticator: ~

La section services est une carte de hachage et le bit Authenticator est un identifiant de service. Le tilde représente null dans YAML, ce que Symfony interprète comme “utiliser l'identifiant du service comme son type”.

Mais bientôt, les besoins de l'entreprise changent et vous devez prendre en charge l'authentification par LDAP en plus de la consultation de la base de données locale. Dans un premier temps, vous transformez la classe Authenticator en une interface et extrayez l'implémentation originale dans une classe LocalAuthenticator:

services:
  LocalAuthenticator: ~

Soudain, Symfony est désemparé. C'est parce que Symfony travaille avec des noms de services au lieu de types. Le contrôleur s'appuie toujours correctement sur l'abstraction et liste l'interface Authenticator comme sa dépendance, mais il n'y a pas de service nommé Authenticator dans le conteneur. Vous devez donner un indice à Symfony, par exemple en utilisant un alias nom de service :

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, en revanche, n'a pas besoin de noms de services ou d'indices. Il ne vous oblige pas à dupliquer dans la configuration les informations qui sont déjà exprimées dans le code (via la clause implements ). Il se situe juste au-dessus du système de types de PHP. Il sait que LocalAuthenticator est de type Authenticator, et tant que c'est le seul service qui implémente l'interface, il l'installe automatiquement là où l'interface est demandée, avec juste cette ligne de configuration :

services:
    - LocalAuthenticator

J'admets que si vous n'êtes pas familier avec le câblage automatique, cela peut sembler un peu magique et vous aurez besoin d'un peu de temps pour apprendre à lui faire confiance. Heureusement, il fonctionne de manière transparente et déterministe : lorsque le conteneur ne peut pas résoudre les dépendances sans ambiguïté, il lève une exception de compilation qui vous aide à résoudre la situation. Ainsi, vous pouvez disposer de deux implémentations différentes tout en gardant un bon contrôle sur l'utilisation de chacune d'elles.

Dans l'ensemble, le câblage automatique réduit la charge cognitive du développeur. Après tout, vous ne vous intéressez qu'aux types et aux abstractions, alors pourquoi un conteneur DI devrait-il vous obliger à vous intéresser également aux implémentations et aux identifiants de service ? Plus important encore, pourquoi devriez-vous même vous soucier d'un conteneur en premier lieu ? Dans l'esprit de l'injection de dépendances, vous voulez être en mesure de déclarer les dépendances et que ce soit le problème de quelqu'un d'autre de les fournir. Vous voulez vous concentrer pleinement sur le code de l'application et oublier le câblage. Et l'injection de dépendances de Nette Framework vous permet de le faire.

À mes yeux, cela fait de la solution DI de Nette Framework la meilleure solution qui existe dans le monde PHP. Elle vous donne un conteneur qui est fiable et qui applique de bons modèles architecturaux, mais qui est en même temps si facile à configurer et à maintenir que vous n'avez pas à y penser du tout.

J'espère que cet article a réussi à piquer votre curiosité. N'oubliez pas de consulter le dépôt Github et la docs. Vous verrez que je ne vous ai montré que la partie émergée de l'iceberg et que l'ensemble est bien plus puissant.