Qu'est-ce que l'injection de dépendances ?
Ce chapitre vous présente les pratiques de programmation de base que vous devez suivre lors de l'écriture de toute application. Ce sont les bases nécessaires pour écrire un code propre, compréhensible et maintenable.
Si vous apprenez et suivez ces règles, Nette sera là pour vous à chaque étape du processus. Elle s'occupera des tâches de routine pour vous et vous rendra aussi confortable que possible afin que vous puissiez vous concentrer sur la logique elle-même.
Les principes que nous allons présenter ici sont assez simples. Vous n'avez aucun souci à vous faire.
Vous vous souvenez de votre premier programme ?
Nous n'avons aucune idée du langage dans lequel vous l'avez écrit, mais si c'était du PHP, il ressemblerait probablement à quelque chose comme ceci :
function addition(float $a, float $b): float
{
return $a + $b;
}
echo addition(23, 1); // imprime 24
Quelques lignes de code triviales, mais tellement de concepts clés cachés en elles. Qu'il y a des variables. Que le code est décomposé en unités plus petites, qui sont des fonctions, par exemple. Qu'on leur passe 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 arguments à une fonction et qu'elle renvoie un résultat est un concept parfaitement compréhensible et utilisé dans d'autres domaines, comme les mathématiques.
Une fonction a une signature, qui se compose de son nom, d'une liste de paramètres et de leurs types, et enfin du type de valeur de retour. En tant qu'utilisateurs, nous sommes intéressés par la signature ; nous n'avons généralement pas besoin de savoir quoi que ce soit sur l'implémentation interne.
Imaginons maintenant que la signature d'une fonction ressemble à ceci :
function addition(float $x): float
Une addition avec un seul paramètre ? C'est bizarre… Que penses-tu de ça ?
function addition(): float
C'est vraiment bizarre, n'est-ce pas ? Comment pensez-vous que la fonction est utilisée ?
echo addition(); // qu'est-ce que ça imprime ?
En regardant un tel code, nous sommes confus. Non seulement un débutant ne le comprendrait pas, mais même un programmeur compétent ne comprendrait pas un tel code.
Vous vous demandez à quoi ressemblerait une telle fonction à l'intérieur ? Où trouverait-elle les additionneurs ? Elle les obtiendrait probablement d'une manière ou d'une autre par elle-même, comme ceci :
function addition(): float
{
$a = Input::get('a');
$b = Input::get('b');
return $a + $b;
}
Il s'avère qu'il y a des liens cachés vers d'autres fonctions (ou méthodes statiques) dans le corps de la fonction, et pour trouver d'où viennent réellement les addends, nous devons creuser davantage.
Pas par là !
La conception que l'on vient de nous montrer est l'essence même de nombreuses caractéristiques négatives :
- la signature de la fonction prétendait qu'elle n'avait pas besoin d'addition, ce qui nous a déconcertés
- nous n'avons aucune idée de la façon de faire calculer la fonction avec deux autres nombres
- nous avons dû regarder dans le code pour voir où il prend les additions
- nous avons découvert des liaisons cachées
- pour bien comprendre, nous devons également explorer ces liaisons.
Et est-ce même le rôle de la fonction d'addition de se procurer des entrées ? Bien sûr que non. Sa responsabilité est uniquement d'ajouter.
Nous ne voulons pas rencontrer un tel code, et nous ne voulons certainement pas l'écrire. Le remède est simple : revenez à l'essentiel et utilisez simplement des paramètres :
function addition(float $a, float $b): float
{
return $a + $b;
}
Règle n° 1 : Laissez-le vous être transmis
La règle la plus importante est la suivante : toutes les données dont les fonctions ou les classes ont besoin doivent leur être transmises.
Au lieu d'inventer des mécanismes cachés pour les aider à les obtenir eux-mêmes, passez simplement les paramètres. Vous économiserez le temps nécessaire à l'invention de mécanismes cachés, qui n'amélioreront certainement pas votre code.
Si vous suivez cette règle toujours et partout, vous êtes sur la voie d'un code sans liaisons cachées. Vers un code compréhensible non seulement pour l'auteur, mais aussi pour toute personne qui le lira par la suite. Où tout est compréhensible à partir des signatures des fonctions et des classes et où il n'est pas nécessaire de chercher des secrets cachés dans l'implémentation.
Cette technique est expertement appelée injection de dépendance. Et les données sont appelées dépendances. Mais il s'agit d'un simple passage de paramètres, rien de plus.
Ne confondez pas l'injection de dépendances, qui est un modèle de conception, avec le “conteneur d'injection de dépendances”, qui est un outil, quelque chose de complètement différent. Nous parlerons des 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 ici aussi. Il y a simplement plus de façons de passer des arguments. Par exemple, tout comme dans le cas d'une fonction :
class Math
{
public function addition(float $a, float $b): float
{
return $a + $b;
}
}
$math = new Math;
echo $math->addition(23, 1); // 24
Ou en utilisant d'autres méthodes, ou directement par le constructeur :
class Addition
{
public function __construct(
private float $a,
private float $b,
) {
}
public function calculate(): float
{
return $this->a + $this->b;
}
}
$addition = new Addition(23, 1);
echo $addition->calculate(); // 24
Ces deux exemples sont tout à fait conformes à l'injection de dépendances.
Exemples concrets
Dans le monde réel, vous n'écrirez pas de classes pour l'addition de nombres. Passons maintenant aux exemples du monde réel.
Ayons une classe Article
représentant un article de blog :
class Article
{
public int $id;
public string $title;
public string $content;
public function save(): void
{
// sauvegarder l'article dans la base de données
}
}
et l'utilisation sera la suivante :
$article = new Article;
$article->title = '10 Things You Need to Know About Losing Weight';
$article->content = 'Every year millions of people in ...';
$article->save();
La méthode save()
enregistre l'article dans une table de la base de données. L'implémenter en utilisant Nette Database serait un jeu d'enfant, s'il n'y avait pas un problème : où
Article
doit-il trouver la connexion à la base de données, c'est-à-dire l'objet de classe
Nette\Database\Connection
?
Il semble que nous ayons beaucoup d'options. On peut la prendre quelque part dans une variable statique. Ou hériter d'une classe qui fournira la connexion à la base de données. Ou tirer parti d'un singleton. Ou encore 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],
);
}
}
Super, nous avons résolu le problème.
Ou l'avons-nous fait ?
Rappelons la règle n°1 : laissez-le vous être transmis: toutes les dépendances dont la classe a besoin doivent lui être transmises. Parce que si nous ne le faisons pas, et que nous enfreignons la règle, nous nous engageons sur la voie d'un code sale, plein de liaisons cachées, incompréhensible, et le résultat sera une application pénible à maintenir et à développer.
L'utilisateur de la classe Article
n'a aucune idée de l'endroit où la méthode save()
stocke
l'article. Dans une table de la base de données ? Dans laquelle, celle de production ou celle de développement ? Et comment cela
peut-il être modifié ?
L'utilisateur doit regarder comment la méthode save()
est mise en œuvre pour trouver l'utilisation de la
méthode DB::insert()
. Il doit donc chercher plus loin pour savoir comment cette méthode permet d'obtenir une
connexion à la base de données. Et les liaisons cachées peuvent former une chaîne assez longue.
Les liaisons cachées, les façades Laravel ou les variables statiques ne sont jamais présentes dans un code propre et bien conçu. Dans un code propre et bien conçu, les arguments sont passés :
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 ensuite, est d'utiliser un 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 être un pur composant de données et qu'un référentiel séparé devrait
s'occuper du stockage. C'est tout à fait logique. Mais cela nous amènerait bien au-delà du sujet, qui est l'injection de
dépendances, et à essayer de donner des exemples simples.
Si vous écrivez une classe qui a besoin d'une base de données pour fonctionner, par exemple, ne cherchez pas à savoir où la trouver, mais faites-vous la passer. Peut-être en tant que paramètre d'un constructeur ou d'une autre méthode. Déclarez les dépendances. Exposez-les dans l'API de votre classe. Vous obtiendrez un code compréhensible et prévisible.
Que pensez-vous 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);
}
}
Qu'en pensez-vous, avons-nous respecté la règle n°1 : laissez-le vous être transmis?
On ne l'a pas fait.
L'information clé, le répertoire du fichier journal, est obtenu par la classe à partir de la constante.
Voir l'exemple d'utilisation :
$logger = new Logger;
$logger->log('The temperature is 23 °C');
$logger->log('The temperature is 10 °C');
Sans connaître l'implémentation, pourriez-vous répondre à la question de savoir où sont écrits les messages ? Cela vous suggérerait-il que l'existence de la constante LOG_DIR est nécessaire pour que cela fonctionne ? Et seriez-vous en mesure de créer une seconde instance qui écrirait à un autre endroit ? Certainement pas.
Réparons 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 claire, plus configurable et donc plus utile.
$logger = new Logger('/path/to/log.txt');
$logger->log('The temperature is 15 °C');
Mais je m'en fiche !
“Lorsque 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 définie dans la configuration. ”
“Quand j'utilise Logger, je veux juste que le message soit écrit, et je ne veux pas m'occuper de l'endroit. Laissez les paramètres globaux être utilisés. ”
Ces commentaires sont corrects.
À titre d'exemple, prenons une classe qui envoie des bulletins d'information et enregistre comment cela s'est passé :
class NewsletterDistributor
{
public function distribute(): void
{
$logger = new Logger(/* ... */);
try {
$this->sendEmails();
$logger->log('Emails have been sent out');
} catch (Exception $e) {
$logger->log('An error occurred during the sending');
throw $e;
}
}
}
La version améliorée de Logger
, qui n'utilise plus la constante LOG_DIR
, requiert un chemin
d'accès au fichier dans le constructeur. Comment résoudre ce problème ? La classe NewsletterDistributor
ne se
soucie pas de l'endroit où les messages sont écrits, elle veut simplement les écrire.
La solution réside à nouveau dans la règle n° 1 : laissez-le vous être transmis: passez-lui toutes les données dont la classe a besoin.
Nous passons donc le chemin d'accès au journal au constructeur, que nous utilisons ensuite pour créer l'objet
Logger
?
class NewsletterDistributor
{
public function __construct(
private string $file, // ⛔ PAS DE CETTE FAÇON !
) {
}
public function distribute(): void
{
$logger = new Logger($this->file);
Pas comme ça ! Parce que le chemin n'appartient pas aux données dont la classe NewsletterDistributor
a
besoin ; elle a besoin de Logger
. La classe a besoin du logger lui-même. Et c'est ce que nous allons
transmettre :
class NewsletterDistributor
{
public function __construct(
private Logger $logger, // ✅
) {
}
public function distribute(): void
{
try {
$this->sendEmails();
$this->logger->log('Emails have been sent out');
} catch (Exception $e) {
$this->logger->log('An error occurred during the sending');
throw $e;
}
}
}
Il est maintenant clair, d'après les signatures de la classe NewsletterDistributor
, que la journalisation fait
partie de ses fonctionnalités. Et la tâche de remplacer le logger par un autre, peut-être à des fins de test, est assez
triviale. De plus, si le constructeur de la classe Logger
est modifié, cela n'aura aucun effet sur notre classe.
Règle n° 2 : prenez ce qui vous appartient
Ne vous laissez pas abuser et ne laissez pas les paramètres de vos dépendances vous être transmis. Transmettez directement les dépendances.
Cela rendra le code utilisant d'autres objets complètement indépendant des modifications apportées à leurs constructeurs. Son API sera plus vraie. Et surtout, il sera trivial d'échanger ces dépendances contre d'autres.
Un nouveau membre de la famille
L'équipe de développement a décidé de créer un deuxième enregistreur qui écrit dans la base de données. Nous avons donc
créé une classe DatabaseLogger
. Nous avons donc deux classes, Logger
et DatabaseLogger
,
l'une écrit dans un fichier, l'autre dans une base de données … Ne pensez-vous pas que ce nom est étrange ? Ne serait-il pas
préférable de renommer Logger
en FileLogger
? Bien sûr que oui.
Mais faisons-le intelligemment. Nous allons créer une interface sous le nom original :
interface Logger
{
function log(string $message): void;
}
…que les deux loggers implémenteront :
class FileLogger implements Logger
// ...
class DatabaseLogger implements Logger
// ...
Et de cette façon, rien ne devra être modifié dans le reste du code où le logger est utilisé. Par exemple, le constructeur
de la classe NewsletterDistributor
se contentera toujours de demander Logger
comme paramètre. Et ce
sera à nous de choisir l'instance que nous lui passerons.
C'est pourquoi nous ne donnons jamais aux noms d'interface le suffixe Interface
ou le préfixe
I
. Sinon, il serait impossible de développer du code aussi bien.
Houston, nous avons un problème
Alors que dans l'ensemble de l'application, nous pouvons nous contenter d'une seule instance d'un enregistreur, qu'il s'agisse
d'un fichier ou d'une base de données, et le passer simplement partout où quelque chose est enregistré, il en va tout autrement
dans le cas de la classe Article
. En fait, nous créons des instances de cette classe selon les besoins, voire
plusieurs fois. Comment gérer la liaison avec la base de données dans son constructeur ?
À titre d'exemple, nous pouvons utiliser un contrôleur qui doit enregistrer un article dans la base de données après avoir soumis 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 est directement proposée : faire passer l'objet base de données par le constructeur à
EditController
et utiliser $article = new Article($this->db)
.
Comme dans le cas précédent avec Logger
et le chemin du fichier, ce n'est pas l'approche correcte. La base de
données n'est pas une dépendance de EditController
, mais de Article
. Ainsi, passer la base de données
va à l'encontre de la règle n°2 : prenez ce qui vous appartient. Lorsque le
constructeur de la classe Article
est modifié (un nouveau paramètre est ajouté), le code à tous les endroits où
des instances sont créées devra également être modifié. Ufff.
Houston, que suggérez-vous ?
Règle n° 3 : Laissez l'usine s'en occuper
En supprimant les liens cachés et en passant toutes les dépendances comme arguments, nous obtenons des classes plus configurables et plus flexibles. Et donc nous avons besoin de quelque chose d'autre pour créer et configurer ces classes plus flexibles. Nous l'appellerons “usine”.
La règle de base est la suivante : si une classe a des dépendances, laissez la création de leurs instances à la fabrique.
Les fabriques sont un remplacement plus intelligent de l'opérateur new
dans le monde de l'injection de
dépendances.
Usine
Une fabrique est une méthode ou une classe qui produit et configure des objets. Nous appelons Article
la classe
de production ArticleFactory
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 serait la suivante :
class EditController extends Controller
{
public function __construct(
private ArticleFactory $articleFactory,
) {
}
public function formSubmitted($data)
{
// laisser l'usine créer un objet
$article = $this->articleFactory->create();
$article->title = $data->title;
$article->content = $data->content;
$article->save();
}
}
À ce stade, lorsque la signature du constructeur de la classe Article
change, la seule partie du code qui doit
réagir est la fabrique ArticleFactory
elle-même. Tout autre code qui travaille avec les objets
Article
, comme EditController
, ne sera pas affecté.
Vous vous tapez peut-être le front en ce moment en vous demandant si nous nous sommes bien aidés. La quantité de code a augmenté et l'ensemble commence à avoir l'air suspicieusement compliqué.
Ne vous inquiétez pas, nous allons bientôt arriver au conteneur Nette DI. Et il a un certain nombre d'atouts dans sa manche
qui rendront la construction d'applications utilisant l'injection de dépendances extrêmement simple. Par exemple, au lieu de la
classe ArticleFactory
, il suffira d'écrire une
simple interface:
interface ArticleFactory
{
function create(): Article;
}
Mais nous prenons de l'avance, attendez :-)
Résumé
Au début de ce chapitre, nous vous avons promis de vous montrer une méthode pour concevoir du code propre. Il suffit de donner aux classes
- les dépendances dont elles ont besoin
- et pas ce dont elles n'ont pas directement besoin
- et que les objets avec des dépendances sont mieux fabriqués dans des usines.
Cela ne semble peut-être pas être le cas à première vue, mais ces trois règles ont des implications considérables. Elles conduisent à une vision radicalement différente de la conception du code. Cela en vaut-il la peine ? Les programmeurs qui se sont débarrassés de leurs vieilles habitudes et ont commencé à utiliser systématiquement l'injection de dépendances considèrent qu'il s'agit d'un moment décisif dans leur vie professionnelle. Cela leur a ouvert un monde d'applications claires et durables.
Mais que se passe-t-il si le code n'utilise pas systématiquement l'injection de dépendances ? Et s'il est construit sur des méthodes statiques ou des singletons ? Cela pose-t-il des problèmes ? Oui, et c'est très important.