L'injection de dépendances (dependency injection) est un mécanisme qui permet d'implémenter le principe de l'inversion de contrôle (Inversion of Control). Cela permet de réduire les adhérences entre objets.
Ce n'est pas un patron de conception (design pattern, DP), c'est un principe de fonctionnement plus générale que l'on appelle patron d'architecture. Le DP associé à cette idée peut être la Factory
L'inversion de contrôle permet de donner le controle d'un objet (une callback par exemple) externe à un contenant (le bout de code qui a besoin de cette dépendance). L'idée peut se résumer à dire "Ne vous appelez pas, on vous appelle".
Ce concept est très présent dans les langages de typepage statique comme C# ou Java. Ce langage nécessite de spécifier le type de notre paramètre (ici checker) et donc naturellement imposer une manière de faire
Copier le code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 // AVANT public class TextEditor { private SpellChecker checker; public TextEditor() { this.checker = new SpellChecker(); } } // APRES public class TextEditor { private IocSpellChecker checker; public TextEditor(IocSpellChecker checker) { this.checker = checker; } } SpellChecker sc = new SpellChecker(); // dependency TextEditor textEditor = new TextEditor(sc);
En python comment ça se passe
En python nous avons par exemple la librairie pytest qui l'utilise avec les fixtures, l'exemple ci-dessous est une fixture de la lib pytest-django pour injecter un client http django dans un test d'intégration (TI)
Copier le code
1 2 def test_something(client): assert b'Success!' in client.get('/some_url_defined_in_test_urls/').content
Pas besoin de définir un client dans ce test, il est fait en amont (dans la lib pytest-django). Ainsi notre TI effectue la vérification demandée (et seulement cela) et la préparation d'exécution est faite ailleurs, cela sépare bien les notions.
Pour mettre en place une inversion de contrôle on peut s'appuyer sur les fonctionnalités natives de python.
Copier le code
1 2 3 4 5 6 7 8 >>> def uppercase(text): ... return text.upper() ... >>> def display(text, transform): ... print(transform(text)) ... >>> display("Hello", uppercase) HELLO
Cet exemple montre qu'en passant une référence de fonction à notre fonction display, cette dernière est capable de l'appeler dans son corps.
Autre exemple sur la framework FastAPI qui propose sur son controlleur une implémentation d'une injection de dépendance avec Depends
Copier le code
1 2 3 4 5 6 7 8 9 10 11 12 from typing import Annotated from fastapi import Depends, FastAPI app = FastAPI() async def common_parameters(q: str | None = None, skip: int = 0, limit: int = 100): return {"q": q, "skip": skip, "limit": limit} @app.get("/items/") async def read_items(commons: Annotated[dict, Depends(common_parameters)]): return commons
Les exemples évoqués jusque ici sont plutôt simples et limités à un usage spécifique, on pourrait vouloir faire des choses plus complexes. Nous allons voir 2 types d'implémentation
Une injection de dépendance avec l'héritage
On pourrait très bien imaginer une implémentation avec le module
ABC
Copier le code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 from abc import ABC # logger.py class Write: """Only useful here to check instance""" pass class Console(Write): def write(self, text): print(text) # user_service.py class Logger(ABC): """Interface contract for log usage""" def attach(self, logger): if isinstance(logger, Write): self.logger = logger else: raise RuntimeError("Wrong logger provided") class UserService(Logger): pass # main.py app = UserService() app.attach(Console()) # Dependency Injection app.logger.write("Hello") # Hello class Bidon: pass app.attach(Bidon()) Traceback (most recent call last): File "protocol.py", line 29, in <module> app.attach(Bidon()) File "protocol.py", line 16, in attach raise RuntimeError("Wrong logger provided") RuntimeError: Wrong logger provided
Une injection de dépendance avec protocol
Le fonctionnement de ce module est similaire à un héritage sauf que l'on ne précise pas de classe parent explicitement.
Protocol
Il est donc naturellement inclut dans la libairie
typing
Copier le code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 from typing import Protocol Logger(Protocol): write(self, text): pass class ConsoleLogger: write(self, text): print(text) class FileLogger: pass class UserService: def attach(self, logger: Logger): print(logger.__class__) app = UserService() ConsoleLogger().write("Hello") # Hello FileLogger().write("Hello") # Traceback (most recent call last): # File "protocol.py", line 21, in <module> # FileLogger().write("Hello") # ^^^^^^^^^^^^^^^^^^ # AttributeError: 'FileLogger' object has no attribute 'write' app.attach(Console()) # <class '__main__.ConsoleLogger'> app.attach(Console()) # <class '__main__.FileLogger'>
On remarque sur cet exemple que le code
app.attach(Console())
FileLogger
Logger
Par contre si vous utilisez un type checking comme
mypy
Argument 1 to "display_text" has incompatible type "FileLogger"; expected "Logger
Et donc vous êtes informé que votre implémentation n'est pas bonne. Si vous couplez avec un linter qui vous bloque votre commit vous serez obligé de fixer cette classe pour pouvoir pousser votre code. Et ainsi vous respecter le contrat d'interface (sans avoir mis d'héritage) !
Avec l'utilisation de
protocol
duck typing c'est ce que nous venons de voir. C'est une sorte de polymorphisme, le duck typing permet d'avoir un comportement identique sur différent objet qui comporte une interface commune
On vient de voir que l'inversion de contrôle et l'injection de dépendance sont naturel en python, pas besoin de framework. En effet Le Duck Typing permet cela, et très simplement
Conclusion
L' usage de
protocol
Bien évidemment il faudra absolument mettre en place des outils comme
mypy