Les services n'ont pas besoin de noms

il y a 4 ans par Jiří Pudil  

J'aime la solution de Nette Framework pour l'injection de dépendances. Je l'adore vraiment. Cet article est là pour partager cette passion et expliquer 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 logiciel est un processus itératif infini d'abstraction. Nous trouvons des abstractions appropriées du monde réel dans la modélisation de domaine. En programmation orientée objet, nous utilisons des abstractions pour décrire et faire respecter des contrats entre 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 créer 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 reconnaîtrez probablement que la vérification des informations d'identification n'a pas sa place ici. Le contrôleur n'a pas la responsabilité de déterminer quelles informations d'identification sont valides – selon le principe de responsabilité unique, le contrôleur ne devrait avoir qu'une seule raison de changer, et cette raison devrait se situer dans le cadre de l'interface utilisateur de l'application, et non dans le processus d'authentification.

Prenons le chemin évident 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, il suffit de déléguer du contrôleur à cet authentificateur. Nous avons créé l'authentificateur dépendance du contrôleur, et le contrôleur a soudainement 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 crée juste un nouveau !
        if ( ! $authenticator->authenticate($username, $password)) {
            return $this->render(__DIR__ . '/error.latte');
        }

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

Cette manière naïve fonctionnera. Mais seulement jusqu'à ce qu'une authentification plus robuste soit implémentée, qui nécessitera que l'authentificateur interroge une table d'utilisateurs dans la base de données. Soudain, Authenticator a sa propre dépendance, disons UserRepository, qui dépend à son tour d'une instance de Connection, qui dépend des paramètres de l'environnement spécifique. Cela a rapidement dégénéré !

Créer des instances partout manuellement n'est pas une manière durable de gérer les dépendances. C'est pourquoi nous avons le vzor dependency injection, qui permet au contrôleur de simplement déclarer une dépendance envers Authenticator, et de laisser à quelqu'un d'autre le soin de fournir réellement l'instance. Et ce quelqu'un d'autre s'appelle le conteneur d'injection de dépendances.

Le conteneur d'injection de dépendances est l'architecte en chef de l'application – il sait résoudre les dépendances de n'importe quel service dans le système et est responsable de leur création. Les conteneurs DI sont si courants aujourd'hui que presque tous les frameworks web majeurs ont leur propre implémentation de conteneur, et il existe même des paquets autonomes dédiés à l'injection de dépendances, par exemple PHP-DI.

Les pièges

La quantité d'options a finalement motivé un groupe de développeurs à chercher une abstraction qui les rendrait interopérables. Une interface commune a été peaufiné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 une propriété très importante des conteneurs DI : Ils sont de bons serviteurs, mais peuvent facilement devenir de mauvais maîtres. Ils sont extrêmement utiles si vous savez comment les utiliser, mais si vous les utilisez incorrectement, vous vous brûlerez les doigts. Prenons 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 nous sommes fixées : elle sait effectivement créer chaque service de l'application et résout 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. Bien !

Mais maintenant que vous ne voyez que les deux 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 vous brûler les doigts. En d'autres termes, le conteneur est devenu un mauvais maître. Pourquoi est-ce le cas ?

Premièrement, vous vous fiez à 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 le service est renommé dans le conteneur, vous devez trouver cette référence et la mettre à jour.

Et ce qui est pire, cette dépendance est cachée : à première vue de l'extérieur, le contrôleur ne dépend que de l'abstraction du conteneur. Mais en tant que développeur, vous devez avoir connaissance de la façon dont les services sont nommés dans le conteneur et savoir que le service nommé authenticator est en fait une instance de Authenticator. Tout cela doit être appris par votre nouveau collègue. Inutilement.

Heureusement, nous pouvons nous rabattre sur un identifiant beaucoup plus naturel : le type du service. C'est, après tout, la seule chose qui vous intéresse en tant que développeur. Vous n'avez pas besoin de savoir quelle chaîne aléatoire est assignée au service dans le conteneur. Je crois que ce code est beaucoup plus simple à é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 maîtrisé les flammes. Pas du tout. Le plus gros problème est que vous reléguez humblement le conteneur au rôle de localisateur de services, ce qui est un énorme anti-vzor. C'est comme apporter à quelqu'un tout le réfrigérateur pour qu'il puisse en prendre une collation – il est beaucoup plus raisonnable de lui apporter juste la collation.

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 envers l'authentificateur est complètement cachée au monde extérieur derrière la dépendance envers le conteneur. Tím se kód stává hůře čitelným. Ou à utiliser. Ou à tester ! Simuler l'authentificateur dans un test unitaire nécessite maintenant que vous construisiez tout un conteneur autour de lui.

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

Cuisiner quelque chose de savoureux

Pour être juste, personne ne peut vous reprocher d'être tombé dans cette impasse. Après tout, vous n'avez fait que suivre 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 aussi des localisateurs de services, et il s'avère que ce vzor est en fait la seule interface commune entre eux. Mais cela ne signifie pas que vous devriez les utiliser comme des localisateurs de services. En fait, la spécification PSR elle-même met en garde contre cela.

Voici comment vous pouvez utiliser le conteneur DI comme un bon service :

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);
        //...
    }
}

Dans le constructeur, la dépendance est déclarée explicitement, clairement et de manière transparente. Les dépendances ne sont plus cachées et dispersées dans la classe. Elles sont également appliquées : le conteneur n'est pas capable de 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 échouera prématurément, pas dans la méthode action(). Tester cette classe est également devenu beaucoup plus simple, car il suffit de simuler le service d'authentification sans aucune chaudière de conteneur.

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

La seule étape restante est d'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 avez peut-être remarqué que le conteneur utilise toujours en interne l'approche du localisateur de services. Cependant, cela n'a pas d'importance tant qu'il est contenu (jeu de mots intentionnel). Le seul endroit en dehors du conteneur où l'appel à la méthode get est autorisé est dans index.php, au point d'entrée de l'application, où il faut créer le conteneur lui-même, puis charger et exécuter l'application :

$container = bootstrap();

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

Le joyau caché

Mais ne nous arrêtons pas là, permettez-moi de pousser cette affirmation plus loin : le seul endroit où l'appel à la méthode get est autorisé est le point d'entrée.

Le code du conteneur n'est que du câblage, ce sont des instructions d'assemblage. Ce n'est pas du code exécutable. D'une certaine manière, il n'est pas important. Même si oui, il est crucial pour l'application, mais seulement du point de vue du développeur. Il n'apporte en réalité aucune valeur directe à l'utilisateur et devrait être traité en tenant compte de ce fait.

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 { /* . . . */ }
}

Cela ne concerne qu'un segment très petit et simple de l'application. Au fur et à mesure que l'application grandit, écrire manuellement le conteneur devient incroyablement fastidieux. Comme je l'ai déjà dit, le conteneur n'est qu'un manuel d'assemblage – mais il est trop complexe, comporte de nombreuses pages, d'innombrables références croisées et de nombreuses mises en garde écrites en petits caractères. Nous voulons en faire un manuel de style IKEA, graphique, concis et avec des illustrations de personnes souriantes lorsqu'elles posent ÅUTHENTICATÖR sur le tapis pendant l'assemblage pour ne pas le casser.

C'est là qu'intervient Nette Framework.

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

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

Permettez-moi de souligner deux choses remarquables : premièrement, la liste des services est réellement une liste, pas une table de hachage – il n'y a pas de clés, pas d'identifiants de service artificiels. Il n'y a pas d'authenticator, ni d'Authenticator::class. Deuxièmement, vous n'avez nulle part besoin d'indiquer explicitement de 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 (autowiring). Vous souvenez-vous comment, grâce à l'injection de dépendances, nous pouvions exprimer le type de la dépendance avec un typehint natif ? Le conteneur DI utilise cette information, donc lorsque vous demandez une instance de Authenticator, il contourne complètement tous les noms et trouve le bon service exclusivement par son type.

Vous pourriez objecter que l'autowiring n'est pas une fonctionnalité unique. Et vous auriez raison. Ce qui rend le conteneur Nette Framework unique, c'est son utilisation du système de types de PHP, alors que dans de nombreux autres frameworks, l'autowiring est toujours basé en interne sur les noms de services. Il existe des scénarios dans lesquels les autres conteneurs sont à la traîne. Voici comment vous définiriez le service authentificateur dans le conteneur Symfony DI en utilisant le langage YAML :

services:
  Authenticator: ~

Dans la section services, il y a une table de hachage et le bit Authenticator est l'identifiant du service. Le tilde signifie null en YAML, ce que Symfony interprète comme “utilise l'identifiant du service comme son type”.

Bientôt, cependant, les exigences métier changeront et vous devrez prendre en charge l'authentification via LDAP en plus de la recherche locale dans la base de données. Dans un premier temps, vous transformez la classe Authenticator en interface et extrayez l'implémentation d'origine dans une classe LocalAuthenticator :

services:
  LocalAuthenticator: ~

Soudain, Symfony est désemparé. C'est parce que Symfony travaille avec les noms de services au lieu des types. Le contrôleur s'appuie toujours correctement sur l'abstraction et indique 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 de nom de service :

services:
  LocalAuthenticator: ~
  Authenticator: '@LocalAuthenticator'

Nette Framework, en revanche, n'a besoin ni de noms de services ni d'indices. Il ne vous oblige pas à dupliquer dans la configuration des informations qui sont déjà exprimées dans le code (via la clause implements ). Il est placé directement au-dessus du système de types de PHP. Il sait que LocalAuthenticator est de type Authenticator, et si c'est le seul service qui implémente cette interface, il l'injectera volontiers automatiquement là où cette interface est requise, et ce, uniquement sur la base de cette ligne de configuration :

services:
    - LocalAuthenticator

Je reconnais que si vous ne connaissez pas l'autowiring, cela peut vous sembler un peu magique et vous aurez peut-être besoin d'un certain 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 de manière unique, il lève une exception lors de la compilation, ce qui vous aide à corriger la situation. De cette façon, vous pouvez avoir deux implémentations différentes tout en gardant un bon contrôle sur l'endroit où chacune d'elles est utilisée.

Dans l'ensemble, l'autowiring impose une charge cognitive moindre au développeur. Après tout, vous ne vous souciez que des types et des abstractions, alors pourquoi le conteneur DI devrait-il vous obliger à vous soucier également des implémentations et des identifiants de service ? Et plus important encore, pourquoi devriez-vous vous soucier d'un conteneur ? Dans l'esprit de l'injection de dépendances, vous voulez pouvoir simplement déclarer des dépendances et laisser le problème à quelqu'un d'autre de les fournir. Vous voulez vous concentrer pleinement sur le code de l'application et oublier le câblage. Et c'est ce que le DI de Nette Framework vous permet de faire.

À mes yeux, cela fait de la solution DI de Nette Framework la meilleure qui existe dans le monde PHP. Elle vous fournit un conteneur fiable qui impose de bons modèles architecturaux, tout en étant si facile à configurer et à maintenir que vous n'avez pas du tout besoin d'y penser.

J'espère que ce billet a réussi à piquer votre curiosité. N'oubliez pas de consulter le répertoire Github et la documentation – j'espère que vous découvrirez que je ne vous ai montré que la pointe de l'iceberg et que l'ensemble du paquet est beaucoup plus puissant.