Qu'est-ce que l'injection de dépendances ?
Ce chapitre vous présente les pratiques de programmation de base que vous devriez suivre lors de l'écriture de n'importe quelle application. Ce sont les fondations nécessaires à l'écriture d'un code propre, compréhensible et maintenable.
Si vous adoptez et suivez ces règles, Nette vous accompagnera à chaque étape. Il fera pour vous le travail de routine et vous offrira un maximum de confort, afin que vous puissiez vous concentrer sur la logique elle-même.
Les principes que nous allons montrer ici sont assez simples. Il n'y a rien à craindre.
Vous vous souvenez de votre premier programme ?
Nous ne savons pas dans quel langage vous l'avez écrit, mais si c'était en PHP, il ressemblait probablement à ceci :
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // affiche 24
Quelques lignes de code triviales, et pourtant elles cachent tant de concepts clés. Que les variables existent. Que le code est divisé en unités plus petites, comme les fonctions. Que nous leur passons des arguments d'entrée et qu'elles renvoient des résultats. Il ne manque que les conditions et les boucles.
Le fait de passer des données d'entrée à une fonction et qu'elle renvoie un résultat est un concept parfaitement compréhensible, utilisé aussi dans d'autres domaines, comme les mathématiques.
Une fonction a sa signature, composée de son nom, de la liste de ses paramètres et de leurs types et, enfin, du type de la valeur de retour. En tant qu'utilisateurs, c'est la signature qui nous intéresse ; nous n'avons généralement pas besoin de connaître l'implémentation interne.
Imaginez maintenant que la signature de la fonction ressemble à ceci :
function addition(float $x): float
Une addition à un seul paramètre ? C'est étrange… Et celle-ci ?
function addition(): float
Là, c'est vraiment étrange, non ? Comment utilise-t-on cette fonction ?
echo addition(); // qu'est-ce que cela va afficher ?
Devant un tel code, nous serions perplexes. Non seulement un débutant ne le comprendrait pas, mais même un programmeur chevronné ne comprendrait pas un code pareil.
Vous vous demandez à quoi ressemblerait une telle fonction à l'intérieur ? Où prendrait-elle les nombres à additionner ? Elle se les procurerait probablement d'une manière ou d'une autre elle-même, peut-être comme ceci :
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Dans le corps de la fonction, nous avons découvert des dépendances cachées vers d'autres fonctions globales ou méthodes statiques. Pour savoir d'où viennent réellement les nombres, il nous faut enquêter plus loin.
Pas comme ça !
La conception que nous venons de montrer est à l'origine de nombreuses caractéristiques négatives :
- La signature de la fonction faisait semblant de ne pas avoir besoin des nombres à additionner, ce qui nous a désorientés.
- Nous n'avons aucune idée de la façon de faire additionner à la fonction deux nombres différents.
- Nous avons dû regarder dans le code pour découvrir d'où elle prend les nombres.
- Nous avons découvert des dépendances cachées.
- Pour comprendre pleinement, il faut examiner ces dépendances également.
Et est-ce seulement le rôle de la fonction d'addition d'obtenir les entrées ? Bien sûr que non. Sa responsabilité, c'est uniquement l'addition elle-même.
Nous ne voulons pas rencontrer un tel code, et nous ne voulons certainement pas l'écrire. La correction est simple : revenir aux fondamentaux et utiliser tout simplement des paramètres :
function addition(float $a, float $b): float
{
return $a + $b;
}
Règle n° 1 : laissez-vous les passer
La règle la plus importante est : toutes les données dont les fonctions ou les classes ont besoin doivent leur être fournies.
Au lieu d'inventer des moyens cachés pour qu'elles obtiennent ces données, fournissez-leur simplement les paramètres. Vous économiserez le temps passé à inventer des chemins cachés qui, à coup sûr, n'amélioreront pas votre code.
Si vous suivez toujours cette règle, partout, vous êtes sur la voie d'un code sans dépendances cachées. D'un code compréhensible non seulement par son auteur, mais aussi par quiconque le lira plus tard. Où tout se comprend à partir des signatures des fonctions et des classes, sans avoir à chercher des détails cachés dans l'implémentation.
Cette technique porte le nom savant d'injection de dépendances. Et les données s'appellent les dépendances. Ce n'est que du passage de paramètres ordinaire, rien de plus.
Ne confondez pas l'injection de dépendances, qui est un patron de conception, avec le “conteneur d'injection de dépendances”, qui est un outil, quelque chose de fondamentalement différent. Nous parlerons des conteneurs plus tard.
Des fonctions aux classes
Et comment cela s'applique-t-il aux classes ? Une classe est une entité plus complexe qu'une simple fonction, mais la règle n° 1 s'y applique tout autant. Il y a seulement plus de façons de passer les arguments. Par exemple, d'une manière tout à fait semblable au cas de la fonction :
class Math
{
public function sum(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->sum(23, 1); // 24
Ou à l'aide d'autres méthodes, ou directement du constructeur :
class Sum
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$sum = new Sum(23, 1);
echo $sum->calculate(); // 24
Les deux exemples respectent pleinement l'injection de dépendances.
Exemples de la vie réelle
Dans le monde réel, vous n'écrirez pas de classes pour additionner des nombres. Passons à des exemples pratiques.
Soit une classe Article représentant un article de blog :
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// enregistre l'article dans la base de données
}
}
et son utilisation sera la suivante :
$article = new Article;
$article->title = '10 choses à savoir pour perdre du poids';
$article->content = 'Chaque année, des millions de personnes ...';
$article->save();
La méthode save() enregistrera l'article dans une table de la base de données. L'implémenter à l'aide de Nette Database serait simple, s'il n'y avait un hic : où Article
prend-il la connexion à la base de données, c'est-à-dire un objet de la classe Nette\Database\Connection ?
Il semble que nous ayons de nombreuses possibilités. Il pourrait la prendre dans une variable statique. Ou en héritant d'une classe qui fournit la connexion à la base de données. Ou utiliser un singleton. Ou encore les fameuses façades, comme celles utilisées dans Laravel :
use Illuminate\Support\Facades\DB;
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
DB::insert(
'INSERT INTO articles (title, content) VALUES (?, ?)',
[$this->title, $this->content],
);
}
}
Parfait, nous avons résolu le problème.
Vraiment ?
Rappelons-nous la Règle n° 1 : laissez-vous les passer : toutes les dépendances dont la classe a besoin doivent lui être passées. Car si nous enfreignons la règle, nous nous engageons sur la voie d'un code désordonné, plein de dépendances cachées et de zones d'ombre, et le résultat sera une application dont la maintenance et le développement relèveront du défi.
L'utilisateur de la classe Article n'a aucune idée de l'endroit où la méthode save() enregistre
l'article. Dans une table de base de données ? Laquelle, la base de production ou celle de test ? Et comment peut-on en
changer ?
L'utilisateur doit regarder comment la méthode save() est implémentée et il y trouve l'utilisation de la
méthode DB::insert(). Il doit donc enquêter plus loin pour savoir comment cette méthode obtient la connexion à la
base de données. Et les dépendances cachées peuvent former une chaîne assez longue.
Dans un code propre et bien conçu, il n'y a jamais de dépendances cachées, de façades Laravel ni de variables statiques. Dans un code propre et bien conçu, on passe les arguments :
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Encore plus pratique, comme nous le verrons plus loin, est l'utilisation du constructeur :
class Article
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function save(): void
{
$this->db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Si vous êtes un programmeur expérimenté, vous vous dites peut-être qu'Article ne devrait pas
avoir de méthode save() du tout ; qu'il devrait représenter une pure structure de données et qu'un repository
distinct devrait se charger de l'enregistrement. C'est pertinent. Mais cela nous emmènerait bien au-delà du sujet, qui est
l'injection de dépendances, et de l'objectif de donner des exemples simples.
Si vous écrivez une classe qui a besoin, par exemple, d'une base de données pour fonctionner, n'inventez pas d'où la prendre, mais faites-vous-la passer. Peut-être comme paramètre du constructeur ou d'une autre méthode. Reconnaissez les dépendances. Reconnaissez-les dans l'API de votre classe. Vous obtiendrez un code compréhensible et prévisible.
Et que dire de cette classe, qui journalise les messages d'erreur :
class Logger
{
public function log(string $message): void
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Qu'en pensez-vous, avons-nous respecté la Règle n° 1 : laissez-vous les passer ?
Non.
L'information essentielle, le répertoire contenant le fichier de log, est obtenue par la classe elle-même à partir d'une constante.
Regardez l'exemple d'utilisation :
$logger = new Logger;
$logger->log('La température est de 23 °C');
$logger->log('La température est de 10 °C');
Sans connaître l'implémentation, pourriez-vous dire où les messages sont écrits ? Vous seriez-vous douté que l'existence
de la constante LOG_DIR est nécessaire à son fonctionnement ? Et pourriez-vous créer une seconde instance qui
écrirait ailleurs ? Certainement pas.
Corrigeons la classe :
class Logger
{
public function __construct(
private string $file,
) {
}
public function log(string $message): void
{
file_put_contents($this->file, $message . "\n", FILE_APPEND);
}
}
La classe est maintenant bien plus compréhensible, configurable et donc plus utile.
$logger = new Logger('/path/to/log.txt');
$logger->log('La température est de 15 °C');
Mais je m'en fiche !
“Quand je crée un objet Article et que j'appelle save(), je ne veux pas m'occuper de la base de données ; je veux juste qu'il soit enregistré dans celle que j'ai configurée.”
“Quand j'utilise Logger, je veux juste que le message soit écrit, et je ne veux pas m'occuper de savoir où. Que les réglages globaux soient utilisés.”
Ce sont des remarques légitimes.
Prenons comme exemple une classe qui distribue des newsletters et journalise le résultat :
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('Les e-mails ont été envoyés');
} catch (Exception $e) {
$logger->log('Une erreur est survenue pendant l\'envoi');
throw $e;
}
}
}
Le Logger amélioré, qui n'utilise plus la constante LOG_DIR, exige le chemin du fichier dans son
constructeur. Comment régler cela ? La classe NewsletterDistributor ne se soucie pas de l'endroit où les messages
sont écrits ; elle veut simplement les journaliser.
La solution est de nouveau la Règle n° 1 : laissez-vous les passer : nous passons toutes les données dont la classe a besoin.
Cela veut-il dire que nous passons le chemin du log par le constructeur, pour l'utiliser ensuite lors de la création de
l'objet Logger ?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ PAS COMME ÇA !
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Pas comme ça ! Car le chemin n'est pas une donnée dont la classe NewsletterDistributor a besoin ; c'est
le Logger qui en a besoin. Percevez-vous la différence ? La classe NewsletterDistributor a besoin du
logger lui-même. C'est donc le logger lui-même que nous allons passer :
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('Les e-mails ont été envoyés');
} catch (Exception $e) {
$this->logger->log('Une erreur est survenue pendant l\'envoi');
throw $e;
}
}
}
Maintenant, la signature de la classe NewsletterDistributor montre clairement que la journalisation fait partie de
son fonctionnement. Et remplacer le logger par un autre, par exemple pour les tests, devient tout à fait simple. De plus, si le
constructeur de la classe Logger venait à changer, cela n'aurait aucun impact sur notre classe.
Règle n° 2 : prenez ce qui est à vous
Ne vous laissez pas embrouiller et n'acceptez pas les dépendances de vos dépendances. N'acceptez que vos propres dépendances.
Grâce à cela, le code qui utilise d'autres objets sera totalement indépendant des changements de leurs constructeurs. Son API sera plus juste. Et surtout, il sera simple de remplacer ces dépendances par d'autres.
Un nouveau membre de la famille
L'équipe de développement a décidé de créer un second logger, qui écrit dans la base de données. Nous créons donc une
classe DatabaseLogger. Nous avons maintenant deux classes, Logger et DatabaseLogger ; l'une
écrit dans un fichier, l'autre dans la base de données… le nommage ne vous semble-t-il pas un peu étrange ? Ne vaudrait-il
pas mieux renommer Logger en FileLogger ? Certainement.
Mais faisons-le intelligemment. Nous créons une interface sous le nom d'origine :
interface Logger
{
function log(string $message): void;
}
… que les deux loggers implémenteront :
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
Et grâce à cela, il n'y aura rien à modifier dans le reste du code où le logger est utilisé. Le constructeur de la classe
NewsletterDistributor, par exemple, continuera de se contenter d'exiger un Logger en paramètre. Et
c'est à nous de décider quelle instance nous lui fournissons.
C'est pourquoi nous n'ajoutons jamais le suffixe Interface ni le préfixe I aux noms des
interfaces. Sinon, il ne serait pas possible d'étendre le code aussi élégamment.
Houston, nous avons un problème
Alors que, dans toute l'application, nous pouvons nous contenter d'une seule instance du logger, qu'il écrive dans un fichier
ou dans la base de données, et la passer simplement partout où l'on journalise, la situation est tout autre avec la classe
Article. Nous créons ses instances au besoin, même plusieurs fois. Comment gérer la dépendance à la base de
données dans son constructeur ?
Prenons l'exemple d'un contrôleur qui doit enregistrer un article dans la base de données après la soumission d'un formulaire :
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Une solution possible paraît évidente : faisons passer l'objet base de données par le constructeur dans
EditController et utilisons $article = new Article($this->db).
Tout comme dans le cas précédent du Logger et du chemin du fichier, ce n'est pas la bonne approche. La base de
données n'est pas une dépendance d'EditController, mais d'Article. Passer la base de données enfreint
donc la Règle n° 2 : prenez ce qui est à vous. Si le constructeur de la
classe Article change (un nouveau paramètre y est ajouté), vous devrez modifier le code à tous les endroits où
l'on crée des instances. Aïe.
Houston, quelle est votre proposition ?
Règle n° 3 : laissez faire la factory
En éliminant les dépendances cachées et en passant toutes les dépendances en arguments, nous avons obtenu des classes plus configurables et plus souples. Nous avons donc besoin de quelque chose de plus pour créer et configurer ces classes plus souples à notre place. Nous appellerons cela des factories.
La règle est la suivante : si une classe a des dépendances, déléguez la création de ses instances à une factory.
Les factories sont une alternative plus intelligente à l'opérateur new dans le monde de l'injection de
dépendances.
Ne confondez pas cela avec le patron de conception factory method, qui décrit une façon particulière d'utiliser les factories et n'a rien à voir avec ce sujet.
Factory
Une factory est une méthode ou une classe qui crée et configure des objets. Nous appellerons ArticleFactory la
classe qui produit des Article, et elle pourrait ressembler à ceci :
class ArticleFactory
{
public function __construct(
private Nette\Database\Connection $db,
) {
}
public function create(): Article
{
return new Article($this->db);
}
}
Son utilisation dans le contrôleur sera la suivante :
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// laissons la factory créer l'objet
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
À ce stade, si la signature du constructeur de la classe Article change, la seule partie du code qui doit réagir
est ArticleFactory elle-même. Tout le reste du code qui manipule des objets Article, comme
EditController, n'est pas affecté.
Vous vous grattez peut-être la tête en vous demandant si nous avons vraiment amélioré la situation. La quantité de code a augmenté et l'ensemble commence à paraître suspicieusement compliqué.
Ne vous inquiétez pas, nous arriverons bientôt au conteneur DI de Nette. Et il a plus d'un tour dans son sac, ce qui
simplifiera grandement la construction d'applications utilisant l'injection de dépendances. Par exemple, au lieu de la classe
ArticleFactory, il suffira d'écrire une simple
interface :
interface ArticleFactory
{
function create(): Article;
}
Mais nous anticipons, restez avec nous :-)
Résumé
Au début de ce chapitre, nous avons promis de montrer une méthode pour concevoir du code propre. Il suffit de veiller à ce que les classes :
- reçoivent les dépendances dont elles ont besoin
- et, à l'inverse, ne reçoivent pas ce dont elles n'ont pas directement besoin
- et que les objets ayant des dépendances soient de préférence créés dans des factories
Cela ne saute peut-être pas aux yeux au premier abord, mais ces trois règles ont des conséquences profondes. Elles mènent à une vision radicalement différente de la conception du code. Cela en vaut-il la peine ? Les programmeurs qui ont abandonné leurs vieilles habitudes et se sont mis à utiliser systématiquement l'injection de dépendances considèrent cette étape comme un moment décisif de leur carrière professionnelle. Elle leur a ouvert un monde d'applications claires et maintenables.
Et si le code n'utilise pas systématiquement l'injection de dépendances ? S'il est bâti sur des méthodes statiques ou des singletons ? Cela conduit-il à des problèmes ? Oui, et à des problèmes très importants.