Les Crash-Devs d'un Passionné

Mettre en place l'injection de dépendance avec protocol

/Catégorie/python

Temps de lecture : 6 minutes

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

clipboard
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)

clipboard
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.

clipboard
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

clipboard
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
qui nous permet de mettre en place de l'héritage. Cela apporte une garantie sur la manière dont nos objets sont construits et manipuler

clipboard
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
, qui a été introduit en version python 3.8, va être plutôt utilisé quand on veut mettre en place une composition (Design Pattern structurel) car il repose sur le type hint (typage dynamique).

Il est donc naturellement inclut dans la libairie

typing
. Pour son usage il suffit de mettre le type dans notre paramètre et python va faire le lien (un peu magiquement).

clipboard
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())
s'exécute alors que la class
FileLogger
ne respecte pas l'interface
Logger
. C'est tout à fait normal car le type hint n'est là que pour aider le développeur, au runtime python n'en tient pas compte.
Par contre si vous utilisez un type checking comme
mypy
vous allez avoir ce message

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
on se repose entièrement sur le duck typing (et donc plus de souplesse que l'héritage car pas de classe parent)

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
est à privilégier car il répond au besoin mais on le retrouve assez peu dans les implémentations car c'est un concept nouveau et assez peu connu.

Bien évidemment il faudra absolument mettre en place des outils comme

mypy
en place pour déceler de mauvaise pratique lors de la phase de développement et également typer le code que vous produisez