Go to Hackademy website

Injection de dépendances : the Ruby way

Ludovic de Luna

Posté par Ludovic de Luna dans les catégories architectureruby

Cet article est publié sous licence CC BY-NC-SA

raychan-xpzhUqTep-U-unsplash

Photo par Raychan depuis Unsplash

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 :

schema_application_avant

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.

principe_general

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 ou attr_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) si S implémente le comportement de T.
  • 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 :

principe_avec_registry

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.


L’équipe Synbioz.
Libres d’être ensemble.

Articles connexes

Duplication ou coïncidence ?

02/07/2020

Giving a computer two contradictory pieces of knowledge was Captain James T. Kirk’s preferred way of disabling a marauding artificial intelligence. Unfortunately, the same principle can be effective...

Vous reprendrez bien un morceau ?

04/06/2020

Le langage Ruby foisonne de méthodes diverses et variées pour manipuler des chaînes de caractères, des nombres, des collections, et bien d’autres. Prenons le cas des collections par exemple. Il en...

Regex : attrapez-les tous !

09/04/2020

Encore un article sur les regex me direz-vous !? Effectivement, après avoir traité des quantificateurs, des propriétés Unicode, et même des emojis, que pourrais-je encore raconter que vous ne sachiez...

Comparaison de chaînes équivalentes en Ruby

09/01/2020

Il apparait, à certains moments de la vie d’un développeur, la nécessité de comparer des chaînes de caractères. Cela peut être pour retrouver un mot dans un texte, ou encore pour interpréter une...