Article écrit par Ludovic de Luna
L’injection est un moyen simple pour donner de la fluidité à vos algorithmes lorsqu’ils manipulent des classes de plus bas niveau. Pour la suite, nous nommerons ces classes « dépendances » et vos algorithmes constituent un « composant ».
Sans injection, votre composant doit savoir comment instancier la dépendance qu’il utilise, ce qui implique de connaître son implémentation (la classe).
Avec injection, votre composant se focalise uniquement sur l’interaction et ne doit plus gérer les détails d’implémentation, ce qui apporte de la résilience face aux changements grâce à un couplage faible.
Quand utiliser l’injection ? Comment procéder ? C’est ce que nous allons voir tout de suite.
Je vous laisse deviner le rapport entre l’injection de dépendances et la tasse Pikachu en photo. Réponse en fin d’article.
Cas concret
Imaginons la situation suivante : vous développez un composant qui fait de la localisation GPS via un service de type Google Maps. Le choix n’est pas définitif.
Voici le composant que vous souhaitez développer :
La classe « Client » va instancier la classe « MapService ». Celle-ci implémente l’algorithme nécessaire aux interactions avec Google Maps. Elle représente une dépendance vis-à-vis de la classe « Client ».
L’objectif de l’article est de rendre cette dépendance injectable. Le choix de l’injection à utiliser devra être paramétrable : une implémentation sera choisie au démarrage de l’application. Toutefois, le développeur garde la possibilité de surcharger cette injection selon ses besoins.
Je vous proposerai dans un premier temps une injection manuelle relativement simple avec une dépendance par défaut. Par la suite, nous verrons comment rendre paramétrable l’injection grâce à un conteneur.
Tout ceci sans recours à aucune gem (bibliothèque) ni framework. Il faudra un peu de développement pour apporter ce mécanisme, mais vous verrez que Ruby rend ceci trivial.
Vous pouvez vous entraîner dans une console Ruby (irb
) pendant la lecture.
Aperçu de notre classe avant / après
Dans le cadre de cet article, je simule le service à injecter via une structure qui répond à la méthode « info ». C’est suffisant pour comprendre le principe.
MapService = Struct.new(:info)
Voici notre classe « Client » :
class Client def api_info map_api.info end private def map_api @map_api ||= MapService.new("Simulate a Google Maps API") end end
Ici, le service est mémoïsé dans une variable d’instance (@map_api
). Cette technique a l’avantage de créer un accesseur et de faire du lazy loading.
Et voici ce que nous souhaitons obtenir au final :
class Client extend Injector inject_attributes(Services, :map_api) def api_info map_api.info end end
Ici, le module Injector
nous permet de créer dynamiquement la méthode privée map_api
pour disposer d’un accesseur à l’image du premier exemple. Le module Service
contient la configuration des injections. Si vous êtes pressé, rendez-vous directement au chapitre « Le conteneur d’injection ». Sinon poursuivons !
Pourquoi faire de l’injection ?
On essaie autant que possible de structurer notre code pour éviter d’entremêler les briques entre elles afin de favoriser les évolutions. Pour y arriver, on définit des interfaces propres qui n’exposent aucuns détails d’implémentation. Si c’est ce que vous faites déjà, alors c’est une très bonne chose.
Structurer son code ainsi, c’est appliquer la « loi de Déméter ». Cette loi nous apprend qu’une application qui grossit sans planification finie par sérieusement partir en cacahuète. Et que pour éviter ça, il faut limiter la connaissance que chaque composant a de l’autre.
Sauf que…
Dans le premier exemple de code, notre classe « Client » sait comment instancier le service. Elle a donc connaissance de son implémentation :
def map_api @map_api ||= MapService.new("Simulate a Google Maps API") end
C’est là qu’entre en jeu l’injection de dépendances. Elle consiste à externaliser cette connaissance à un tiers.
En procédant de cette manière, on gagne de la flexibilité pour faire fonctionner notre composant dans un environnement différent (production / test / dev) ou de l’utiliser dans des cas qui n’étaient pas prévus à l’origine.
C’est quoi l’injection de dépendances ?
L’idée est d’avoir un code – le client – qui va consommer un service sans savoir comment il est initialisé (instancié) ni quelle implémentation est utilisée (la classe elle-même). Ces différentes questions sont adressées via un troisième code nommé injecteur. La seule chose en commun entre le client et le service est l’adhésion à une interface commune.
Les avantages immédiats en Ruby :
- Apporter de la souplesse via le polymorphisme.
- Faciliter l’écriture des tests unitaires.
- Permettre l’extension du code par une équipe en dehors de votre périmètre d’action.
Pour autant, cette technique a été popularisée dans la communauté Java par Martin Fowler en 2004. L’article original parle de 3 façons d’injecter une dépendance :
- Par le constructeur : la méthode
initialize
en Ruby. - Par des
setters
dédiés : ce sont les accesseurs en Ruby (attr_writer
ouattr_accessor
). - Par une interface : l’objet dispose d’un comportement lié à l’injection. Cette technique introduit l’usage du conteneur d’injection.
Pour mettre en pratique l’injection de dépendances, il convient de suivre quelques principes.
Principes SOLID
L’injection est une façon de composer des objets. Elle est encouragée au travers des principes SOLID.
Ces principes ont été introduits par Robert C. Martin (alias Uncle Bob) dans son ouvrage « Agile Software Development, Principles, Patterns and Practices ».
La description étant trop généraliste, je me suis permis une traduction dans le monde Ruby :
- S → Single Responsibility Principle : une classe / module ne devrait avoir qu’un seul rôle.
- O → Open/Closed Principle : modules, classes et méthode sont ouverts à l’extension mais fermés à la modification.
- L → Liskov Substitution Principle :
méthode(T)
équivaut àméthode(S)
siS
implémente le comportement deT
. - I → Interface Segregation Principle : Une interface ne doit pas forcer le client à gérer des aspects qui sont en dehors de son périmètre fonctionnel.
- D → Dependency Inversion Principle : Les objets de haut niveau ont une dépendance envers du comportement et non une implémentation spécifique.
Je pourrais vous donner ce conseil :
Gardez le focus sur le rôle de votre classe / module et respectez autant que possible les principes SOLID.
Tout ceci s’accompagne de principes plus généralistes que vous connaissez déjà, tels que :
- KISS (Keep it Simple, Stupid) : concevez des choses simples, évitez la complexité inutile.
- YAGNI (You ain’t gonna need it) : concentrez-vous sur votre objectif et développez uniquement ce qui est nécessaire.
- DRY (Don’t repeat yourself) : organisez vos algorithmes pour représenter de façon unique chaque intention ou règle métier.
Injection manuelle
Promis, on arrête avec la théorie. Revenons à notre code avec une injection simple : via le constructeur ou un accesseur.
Injection par le constructeur
C’est la technique la plus répandue. L’injection consiste à passer en argument un objet instancié à la méthode initialize
:
class Client def initialize(map_api = nil) @map_api = map_api || MapService.new("Simulate a Google Maps API") end def api_info map_api.info end private attr_reader :map_api end
Ici, en l’absence d’injection, on utilise le service par défaut. Il sera accessible en interne via un accesseur (attr_reader
à la fin).
Exemple d’usage :
MapService = Struct.new(:info) # simulate our service street_map = MapService.new("Simulate an Open Street Map API") client = Client.new(street_map) client.api_info # => "Simulate an Open Street Map API"
Séparez vos paramètres de vos injections
Les derniers paramètres sont généralement dédiés aux injections de dépendances et disposent d’une implémentation par défaut. Mais la formule que nous avons vu a ses limites : que se passe-t-il si nous ajoutons un paramètre optionnel country
:
class Client def initialize(country = nil, map_api = nil) @country = country || "France" @map_api = map_api || MapService.new("Simulate a Google Maps API") end #... end
Si je souhaite utiliser le paramètre country
par défaut :
client = Client.new(nil, street_map)
Je suis obligé d’avoir un argument nil
.
Pas génial
En utilisant en argument un Hash pour les dépendances, vous gagnez en lisibilité et en flexibilité comme l’a démontré un article de Sandy Metz. Cependant, je trouve qu’on peut exploiter plus finement le principe via le double-splat operator :
class Client def initialize(country = nil, **services) @country = country || "France" @map_api = services[:map_api] || MapService.new("Simulate a Google Maps API") end def api_info map_api.info end private attr_reader :map_api end
Pour rappel de la syntaxe, toujours utilisée en fin de paramètres :
- splat operator (
*
) : Le paramètre reçoit tous les arguments restants sous forme de tableau. - double-splat operator (
**
) : Le paramètre reçoit tous les mots-clés restants (ou keyword arguments en Ruby) sous forme de Hash.
C’est utilisable dans l’autre sens, lors de l’appel d’une fonction / méthode. On peut étendre un tableau en liste d’arguments (*
) ou convertir un Hash en liste de mots-clés (**
). Depuis Ruby 2.7, cette dernière devient plus stricte.
Revenons à notre exemple. Voici comment on instancie la classe « Client » avec des arguments par mot-clé :
client = Client.new(map_api: street_map)
C’est déjà mieux !
N’hésitez pas à réserver les arguments par mot-clé à vos injections dès que vous avez plus d’une injection ou lorsque c’est couplé à d’autres paramètres.
Injection par accesseur
L’injection par accesseur a un usage plus limité (dans une boucle de paramétrage par exemple), mais ça reste assez simple en Ruby :
class Client # accessor for test purpose attr_writer :map_api def api_info map_api.info end def map_api @map_api ||= MapService.new("Simulate a Google Maps API") end end
Ici, nous avons simplement créé un accesseur en écriture avec attr_writer
.
Nous pouvons l’utiliser ainsi :
MapService = Struct.new(:info) # simulate our service client = Client.new client.map_api = MapService.new("Simulate an Open Street Map API") client.api_info # => "Simulate an Open Street Map API"
Étant donné son usage plus restreint, il est souhaitable de documenter ce type d’injection afin de lever toute ambiguïté. Notez que cette approche peut rapidement devenir préjudiciable pour vos utilisateurs si cette classe venait à être exposée.
Le conteneur d’injection
C’est une façon parmi d’autres de structurer l’injection. Le concept central s’appuie sur un annuaire (ou registry) qui répertorie des classes à instancier ou des instances disponibles (dans le futur). L’accès à son contenu se fait via une résolution de dépendance : « nom » → « objet instancié ». Il est alimenté au préalable lors de la phase d’initialisation de l’application via un fichier de configuration (un script Ruby suffit).
Le « registry » décrit le mécanisme. Le « conteneur » matérialise les dépendances paramétrées dans l’application.
Pour généraliser l’injection, je vous propose de passer par un « injecteur » qui va créer dans votre classe les appels nécessaires à la résolution de dépendances vis-à-vis d’un conteneur.
Il sera toujours possible de surcharger l’injection via le constructeur ou des accesseurs.
Notez que votre code aura, d’une manière ou d’une autre, une dépendance envers cette solution. Voici dans les grandes lignes ce que nous allons développer :
Encore une fois : c’est trivial en Ruby, alors restez concentré sur le design.
Le registry
C’est un module qui va utiliser :
- Un Hash (
@list
) pour gérer l’annuaire, automatiquement créé au niveau du conteneur. - Un objet « proc » (block) ou tout objet répondant à la méthode
call
. Il décrit la classe à instancier avec ses arguments, ou une instance.
module CallableRegistry def register(key, callable = nil, &block) @list[key] ||= callable.respond_to?(:call) ? callable : block end def resolve(key) @list.fetch(key).call end def configure(&block) instance_eval(&block) end def self.extended(base) base.instance_eval { @list = {} } end end
Le registry fournit deux méthodes utiles pour les injections :
-
register
: déclarer une dépendance et l’associer à un nom (key). Par sécurité, on empêche d’écraser une clé existante. -
resolve
: résoudre une dépendance, c’est-à-dire : jouer le contenu du « proc » (ou de l’objet qui répond àcall
).
Le conteneur
Maintenant, matérialisons notre conteneur pour la classe « Client » :
module Services extend CallableRegistry end
Votre conteneur d’injection est prêt. Ajoutons la dépendance au service map_api
:
Services.configure do register(:map_api) { MapService.new("Simulate a Nebular Nasa API") } end MapService = Struct.new(:info) # simulate our service
Le code ci-dessus (la partie « configure ») trouvera sa place dans un fichier d’initialisation : la brique « Config » que nous avions dans notre schéma.
Si vous expérimentez en console Ruby, n’oubliez pas qu’on déclare une seule fois une dépendance par conteneur.
À noter
Le conteneur fonctionne en cascade. Pour appeler d’autres dépendances lors de la configuration, utilisez la méthode resolve
.
Par exemple, si on souhaite instancier un Hash qui initialise toutes ses clés via la méthode info
depuis la dépendance map_api
:
Services.configure do register(:bee) { Hash.new(resolve(:map_api).info) } end bee_hash = Services.resolve(:bee) bee_hash[:hello] # => "Simulate a Nebular Nasa API"
L’injecteur
Il ne vous reste plus que l’injecteur. Pour ce dernier, nous allons faire appel à un peu de métaprogrammation. Pas de panique, vous venez juste d’en faire.
La métaprogrammation en Ruby est un moyen de modifier la structure et les relations des objets pendant l’exécution du programme.
Le module d’injection a en charge de créer dynamiquement dans la classe un accesseur vers une variable d’instance du même nom que celui utilisé dans le conteneur. Ici, c’est map_api
.
Il s’agit donc d’automatiser la création de ceci dans votre classe :
private def map_api @map_api ||= Service.resolve(:map_api) end
Voici l’injecteur :
module Injector def self._generate_body(registry, key, var = :"@#{key}") proc do instance_variable_get(var) || instance_variable_set(var, registry.resolve(key)) end end private def inject_attributes(registry, *keys) keys.each do |key| body = Injector._generate_body(registry, key) define_method(key, &body) private(key) end end end
Ce qui est intéressant, c’est la méthode privée inject_attributes
. Elle prend en argument le conteneur et la / les clés d’injection à traiter. Elle va construire le corps de la méthode (body) puis définir la méthode en utilisant ce corps et la rend ensuite privée.
Classe « Client » modifiée
Votre classe « Client » doit faire référence à l’injecteur pour en bénéficier. Elle indique le conteneur et le nom de la / des dépendances à injecter :
class Client extend Injector inject_attributes(Services, :map_api) def api_info map_api.info end end
Si vous dérivez cette classe, vous bénéficiez toujours des injections. Vous pouvez aussi en ajouter de nouvelles via un appel à inject_attributes
.
Exemple d’utilisation :
Client.new.api_info => "Simulate a Nebular Nasa API"
Quel est l’impact d’utiliser une injection pour la classe « Client » ?
L’appel à l’accesseur map_api
résout la dépendance pour renseigner la variable d’instance @map_api
et la renvoie à l’appelant. Si @map_api
est déjà renseignée, elle sera retournée directement sans faire la résolution.
On peut coupler le conteneur avec toutes les techniques d’injection.
Ci-dessous, couplé avec l’injection par constructeur :
class Client extend Injector inject_attributes(Services, :map_api) def initialize(**services) @map_api = services[:map_api] end def api_info map_api.info end end
Ci-après, couplé avec de l’injection par accesseur :
class Client extend Injector inject_attributes(Services, :map_api) attr_writer :map_api def api_info map_api.info end end
Injection → Inversion de contrôle
Quelque-soit la technique, l’injection de dépendances met en pratique l’inversion de contrôle que vous verrez souvent abrégé en « IoC ». Qu’est-ce donc ? Le choix des relations se fait toujours de l’objet de haut niveau vers un objet de plus bas niveau. L’inversion de contrôle renverse ce schéma : l’objet de haut niveau reçoit un objet de plus bas niveau sans avoir le choix. Pratiquer l’injection implique toujours de faire de l’inversion de contrôle.
Si vous faites quelques recherches sur internet, vous verrez passer des termes comme « IoC container », synonyme de « DI container » ou plus généralement de « conteneur d’injection ». Ces termes désignent des « outils » pour standardiser l’injection dans votre projet via un conteneur.
Choisissez votre solution
On peut aimer l’approche par conteneur d’injection ou pas. Il n’y a pas de « mauvais choix », juste des choix mal avisés. Prenez le temps de considérer vos options.
Si vous souhaitez aller plus loin dans l’usage des conteneurs d’injection, regardez du côté de dry-rb qui propose dry-container et dry-auto_inject. Cette solution est robuste et adresse une variété de cas plus larges. Elle est également plus complexe en interne mais son usage rend le développeur efficace.
Si vous souhaitez maîtriser vous-même le procédé (et c’est courant en Ruby) sans faire de l’injection comme nous l’avons vu plus haut, passez par votre propre « registry », explorez l’assemblage d’objets via une « factory » (ou usine) et structurez l’API interne de votre composant via des objets de haut niveau. Il est aussi possible de faire de l’auto-enregistrement de dépendances lors de la création de votre gemme.
N’hésitez pas à expérimenter pour trouver ce qui correspond à votre projet. Et une fois votre choix fait, restez constant.
Résumé
Nous avons vu comment créer notre propre conteneur d’injection. Cette base vous permet d’adapter l’approche pour votre projet. Nous avons également vu qu’il existe des bibliothèques pour standardiser l’injection en Ruby.
Mais au-delà de « comment faire », je souhaite produire chez vous un déclic. C’est un peu prétentieux, je l’avoue. Il y a un peu plus d’un an, je regardais un reportage sur Bruce Lee. Rien à voir me direz-vous. J’apprenais que Bruce Lee avait une certaine philosophie de vie et nous a donné bon nombre de citations intéressantes, dont une :
L’eau qu’on verse dans une tasse devient la tasse […] Sois comme l’eau mon ami. – Bruce Lee
Derrière ces mots – presque d’enfant – se cache une leçon de vie : celui de la résilience face à ce que nous ne pouvons pas maîtriser dans notre vie : la difficulté, les impératifs, les changements. Nous devons nous adapter.
Et j’ai eu le déclic à ce moment. Ceci explique la photo d’introduction avec la tasse Pikachu. La tasse, c’est l’environnement de votre application. Vous ne pourrez pas le changer. L’application est le jus de fruit qui s’écoule et s’adapte à la tasse. Et pour y arriver, pour lui donner cette fluidité, l’injection de dépendances est essentielle.
J’ai aimé cette analogie et je trouve que Ruby partage avec Bruce Lee ce côté simple d’accès et bon enfant qui cache à ceux qui en ont une lecture superficielle toute la puissance.
Merci à vous qui avez pris le temps de lire cet article, j’espère que vous continuerez à prendre plaisir dans vos développements.