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 toutes vos applications. Ce sont les fondations nécessaires pour écrire du code propre, compréhensible et maintenable.
Si vous adoptez ces règles et les suivez, Nette vous soutiendra à chaque étape. Il s'occupera des tâches routinières pour vous 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. Vous n'avez rien à craindre.
Vous souvenez-vous de votre premier programme ?
Nous ne savons pas dans quel langage vous l'avez écrit, mais si c'était en PHP, il aurait probablement ressemblé à quelque chose comme ceci :
function soucet(float $a, float $b): float
{
return $a + $b;
}
echo soucet(23, 1); // affiche 24
Quelques lignes de code triviales, mais elles cachent tellement de concepts clés. Qu'il existe des variables. Que le code est divisé en unités plus petites, telles que des fonctions. Que nous leur passons des arguments d'entrée et qu'elles retournent des résultats. Il ne manque que les conditions et les boucles.
Le fait qu'une fonction reçoive des données d'entrée et retourne un résultat est un concept parfaitement compréhensible, utilisé également dans d'autres domaines, comme les mathématiques.
Une fonction a sa signature, qui comprend son nom, une liste de paramètres et leurs types, et enfin le type de la valeur de retour. En tant qu'utilisateur, nous nous intéressons à la signature ; nous n'avons généralement pas besoin de connaître l'implémentation interne.
Maintenant, imaginez que la signature de la fonction ressemble à ceci :
function soucet(float $x): float
Une somme avec un seul paramètre ? C'est étrange… Et comme ça ?
function soucet(): float
C'est vraiment très étrange, n'est-ce pas ? Comment la fonction est-elle utilisée ?
echo soucet(); // qu'est-ce que cela va afficher ?
En regardant un tel code, nous serions confus. Non seulement un débutant ne le comprendrait pas, mais même un programmeur expérimenté ne comprendrait pas ce code.
Vous demandez-vous à quoi ressemblerait réellement une telle fonction à l'intérieur ? Où obtiendrait-elle les opérandes ? Elle les obtiendrait probablement d'une manière ou d'une autre elle-même, peut-être comme ceci :
function soucet(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Dans le corps de la fonction, nous avons découvert des liens cachés vers d'autres fonctions globales ou méthodes statiques. Pour savoir d'où proviennent réellement les opérandes, nous devons chercher plus loin.
Pas par ici !
La conception que nous venons de montrer est l'essence de nombreuses caractéristiques négatives :
- La signature de la fonction prétendait qu'elle n'avait pas besoin d'opérandes, ce qui nous a induits en erreur.
- Nous ne savons pas du tout comment faire pour que la fonction additionne deux autres nombres.
- Nous avons dû regarder dans le code pour savoir d'où elle tirait les opérandes.
- Nous avons découvert des liens cachés.
- Pour une compréhension complète, il faut également examiner ces liens.
Et est-ce vraiment la tâche d'une fonction d'addition d'obtenir des entrées ? Bien sûr que non. Sa responsabilité est uniquement l'addition elle-même.
Nous ne voulons pas rencontrer un tel code, et nous ne voulons certainement pas l'écrire. La solution est simple : revenir aux bases et simplement utiliser des paramètres :
function soucet(float $a, float $b): float
{
return $a + $b;
}
Règle n°1 : faites-vous passer les choses
La règle la plus importante est : toutes les données dont une fonction ou une classe a besoin doivent lui être passées.
Au lieu d'inventer des moyens cachés pour qu'elles puissent y accéder elles-mêmes, passez simplement les paramètres. Vous économiserez du temps nécessaire à l'invention de chemins cachés, qui n'amélioreront certainement pas votre code.
Si vous suivez toujours et partout cette règle, vous êtes sur la voie d'un code sans liens cachés. D'un code compréhensible non seulement par l'auteur, mais aussi par quiconque le lira après lui. Où tout est compréhensible à partir des signatures des fonctions et des classes, et il n'est pas nécessaire de chercher des secrets cachés dans l'implémentation.
Cette technique est appelée techniquement Injection de Dépendances. Et ces données sont appelées dépendances. En fait, c'est simplement du passage de paramètres, 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, c'est-à-dire quelque chose de diamétralement différent. Nous aborderons les conteneurs plus tard.
Des fonctions aux classes
Et quel est le rapport avec les classes ? Une classe est une entité plus complexe qu'une simple fonction, mais la règle n°1 s'applique également ici sans exception. Il existe simplement plus d'options pour passer les arguments. Par exemple, de manière assez similaire au cas d'une fonction :
class Matematika
{
public function soucet(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Matematika;
echo $math->soucet(23, 1); // 24
Ou en utilisant d'autres méthodes, ou directement le constructeur :
class Soucet
{
public function __construct(
private float $a,
private float $b,
) {
}
public function spocti(): float
{
return $this->a + $this->b;
}
}
$soucet = new Soucet(23, 1);
echo $soucet->spocti(); // 24
Les deux exemples sont entièrement conformes à l'injection de dépendances.
Exemples réels
Dans le monde réel, vous n'écrirez pas de classes pour additionner des nombres. Passons à des exemples pratiques.
Prenons 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 l'utilisation sera la suivante :
$article = new Article;
$article->title = '10 choses que vous devez savoir sur la perte de poids';
$article->content = 'Chaque année, des millions de personnes dans ...';
$article->save();
La méthode save()
enregistre l'article dans une table de base de données. L'implémenter avec Nette Database serait un jeu d'enfant, s'il n'y avait pas un hic : où
Article
obtient-il la connexion à la base de données, c'est-à-dire l'objet de la classe
Nette\Database\Connection
?
Il semble que nous ayons beaucoup d'options. Il peut la prendre quelque part dans une variable statique. Ou hériter d'une classe qui assure la connexion à la base de données. Ou utiliser un singleton. Ou les façades, qui sont 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],
);
}
}
Génial, nous avons résolu le problème.
Ou pas ?
Rappelons la Règle n°1 : faites-vous passer les choses : 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 sale plein de liens cachés, d'incompréhensibilité, et le résultat sera une application qu'il sera pénible de maintenir et de développer.
L'utilisateur de la classe Article
ne sait pas où la méthode save()
enregistre l'article. Dans une
table de base de données ? Laquelle, la production ou le test ? Et comment peut-on changer cela ?
L'utilisateur doit regarder comment la méthode save()
est implémentée et trouve l'utilisation de la méthode
DB::insert()
. Il doit donc chercher plus loin comment cette méthode obtient la connexion à la base de données. Et
les liens cachés peuvent former une chaîne assez longue.
Dans un code propre et bien conçu, il n'y a jamais de liens cachés, de façades Laravel ou de variables statiques. Dans un code propre et bien conçu, on passe des arguments :
class Article
{
public function save(Nette\Database\Connection $db): void
{
$db->query('INSERT INTO articles', [
'title' => $this->title,
'content' => $this->content,
]);
}
}
Ce sera encore plus pratique, comme nous le verrons plus loin, avec le 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 pensez peut-être que Article
ne devrait pas du tout
avoir de méthode save()
, qu'il devrait représenter purement un composant de données et que l'enregistrement
devrait être géré par un dépôt séparé. C'est logique. Mais cela nous éloignerait beaucoup du sujet, qui est l'injection de
dépendances, et de l'effort de fournir des exemples simples.
Si vous écrivez une classe qui nécessite, par exemple, une base de données pour fonctionner, n'inventez pas d'où l'obtenir, mais faites-la vous passer. Par exemple, comme paramètre du constructeur ou d'une autre méthode. Admettez les dépendances. Admettez-les dans l'API de votre classe. Vous obtiendrez un code compréhensible et prévisible.
Et que dire de cette classe, qui enregistre les messages d'erreur :
class Logger
{
public function log(string $message)
{
$file = LOG_DIR . '/log.txt';
file_put_contents($file, $message . "\n", FILE_APPEND);
}
}
Que pensez-vous, avons-nous respecté la Règle n°1 : faites-vous passer les choses ?
Nous ne l'avons pas respectée.
L'information clé, c'est-à-dire le répertoire avec le fichier journal, la classe l'obtient 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 répondre à la question de savoir où les messages sont écrits ? Auriez-vous
pensé que pour fonctionner, l'existence de la constante LOG_DIR
est nécessaire ? Et pourriez-vous créer une
deuxième 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 beaucoup plus compréhensible, configurable et donc plus utile.
$logger = new Logger('/chemin/vers/log.txt');
$logger->log('La température est de 15 °C');
Mais ça ne m'intéresse pas !
« 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 me soucier de l'endroit. Qu'il utilise la configuration globale. »
Ce sont des remarques pertinentes.
Comme exemple, montrons une classe qui envoie des newsletters et enregistre 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 lors de l\'envoi');
throw $e;
}
}
}
Le Logger
amélioré, qui n'utilise plus la constante LOG_DIR
, nécessite le chemin du fichier dans
le constructeur. Comment résoudre cela ? La classe NewsletterDistributor
ne se soucie pas du tout de l'endroit où
les messages sont écrits, elle veut juste les écrire.
La solution est à nouveau la Règle n°1 : faites-vous passer les choses : toutes les données dont la classe a besoin, nous les lui passons.
Cela signifie donc que nous passons le chemin du journal via le constructeur, que nous utilisons 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 ! Le chemin n'appartient pas aux données dont la classe NewsletterDistributor
a besoin ; ce
sont les besoins de Logger
. Voyez-vous la différence ? La classe NewsletterDistributor
a besoin du
logger en tant que tel. Donc, nous le passons :
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 lors de l\'envoi');
throw $e;
}
}
}
Maintenant, il est clair d'après les signatures de la classe NewsletterDistributor
que le logging fait partie de
sa fonctionnalité. Et la tâche de remplacer le logger par un autre, par exemple pour les tests, est tout à fait triviale. De
plus, si le constructeur de la classe Logger
changeait, cela n'aurait aucune incidence sur notre classe.
Règle n°2 : prenez ce qui vous appartient
Ne vous laissez pas tromper et ne vous faites pas passer les dépendances de vos dépendances. Faites-vous passer uniquement vos propres dépendances.
Grâce à cela, le code utilisant d'autres objets sera totalement indépendant des changements dans leurs constructeurs. Son API sera plus véridique. Et surtout, il sera trivial de remplacer ces dépendances par d'autres.
Nouveau membre de la famille
L'équipe de développement a décidé de créer un deuxième logger, qui écrit dans la base de données. Nous créons donc
une classe DatabaseLogger
. Nous avons donc deux classes, Logger
et DatabaseLogger
, l'une
écrit dans un fichier, l'autre dans la base de données… ne trouvez-vous pas quelque chose d'étrange dans ce nommage ? Ne
serait-il pas préférable de renommer Logger
en FileLogger
? Certainement oui.
Mais nous allons le faire intelligemment. Sous le nom d'origine, nous créons une interface :
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 ne sera pas nécessaire de changer quoi que ce soit dans le reste du code où le logger est utilisé. Par
exemple, le constructeur de la classe NewsletterDistributor
sera toujours satisfait d'exiger Logger
comme paramètre. Et ce sera à nous de décider quelle instance lui passer.
C'est pourquoi nous ne donnons jamais aux noms d'interfaces le suffixe Interface
ou le préfixe
I
. Sinon, il ne serait pas possible de développer le code aussi joliment.
Houston, nous avons un problème
Alors que dans toute l'application, nous pouvons nous contenter d'une seule instance de logger, qu'il soit fichier ou base de
données, et simplement la passer partout où quelque chose est loggué, la situation est tout à fait différente dans le cas de
la classe Article
. En effet, nous créons ses instances selon les besoins, parfois plusieurs fois. Comment gérer la
dépendance à la base de données dans son constructeur ?
Comme exemple, prenons un contrôleur qui, après soumission d'un formulaire, doit enregistrer l'article dans la base de données :
class EditController extends Controller
{
public function formSubmitted($data)
{
$article = new Article(/* ... */);
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
Une solution possible s'offre directement : nous nous faisons passer l'objet de la base de données via le constructeur dans
EditController
et utilisons $article = new Article($this->db)
.
Comme dans le cas précédent avec Logger
et le chemin du fichier, ce n'est pas la bonne approche. La base de
données n'est pas une dépendance de EditController
, mais de Article
. Passer la base de données va
donc à l'encontre de la Règle n°2 : prenez ce qui vous appartient.
Si le constructeur de la classe Article
change (un nouveau paramètre est ajouté), il faudra également modifier le
code à tous les endroits où une instance est créée. Ouf.
Houston, que suggérez-vous ?
Règle n°3 : laissez faire la factory
En supprimant les liens cachés et en passant toutes les dépendances comme arguments, nous avons obtenu des classes plus configurables et flexibles. Et par conséquent, nous avons besoin de quelque chose d'autre pour créer et configurer ces classes plus flexibles. Nous appellerons cela des factories.
La règle est la suivante : si une classe a des dépendances, laissez la création de ses instances à une factory.
Les factories sont un remplacement plus intelligent de l'opérateur new
dans le monde de l'injection de
dépendances.
Ne confondez pas avec le patron de conception factory method, qui décrit une manière spécifique d'utiliser les factories et n'est pas lié à ce sujet.
Factory
Une factory est une méthode ou une classe qui produit et configure des objets. La classe produisant Article
s'appellera ArticleFactory
et 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();
}
}
Si la signature du constructeur de la classe Article
change à ce moment, la seule partie du code qui doit y
réagir est la factory ArticleFactory
elle-même. Tout autre code qui travaille avec les objets Article
,
comme EditController
, ne sera en aucun cas affecté.
Peut-être vous tapez-vous sur le front maintenant, vous demandant si nous avons vraiment amélioré les choses. La quantité de code a augmenté et tout cela commence à paraître suspectement compliqué.
Ne vous inquiétez pas, nous arriverons bientôt au conteneur Nette DI. Et il a plusieurs atouts dans sa manche qui
simplifieront énormément 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, attendez encore un peu :-)
Résumé
Au début de ce chapitre, nous avons promis de montrer une méthode pour concevoir du code propre. Il suffit aux classes de
- se faire passer les dépendances dont elles ont besoin
- et inversement, ne pas se faire passer ce dont elles n'ont pas directement besoin
- et que les objets avec dépendances sont mieux fabriqués dans des factories
Cela peut ne pas sembler évident au premier abord, mais ces trois règles ont des conséquences considérables. Elles conduisent à une vision radicalement différente de la conception du code. Est-ce que ça en vaut la peine ? Les programmeurs qui ont abandonné leurs anciennes habitudes et ont commencé à utiliser systématiquement l'injection de dépendances considèrent cette étape comme un moment crucial de leur vie professionnelle. Un monde d'applications claires et maintenables s'est ouvert à eux.
Mais que se passe-t-il si le code n'utilise pas systématiquement l'injection de dépendances ? Que se passe-t-il s'il est basé sur des méthodes statiques ou des singletons ? Cela pose-t-il des problèmes ? Oui, et de très importants.