mail

L'injection de dépendances est la capacité de modifier des types et des méthodes à l'exécution du code. C'est utilisé pour fournir un "logger" ou un pool de connexions à une bibliothèque ou un module. Ca peut être utilisé pour modifier la configuration d'un service et sélectionner une implémentation plutôt qu'une autre. C'est également utilisé pour faire des "mocks" et facilement réaliser des tests unitaires sans coupler les fonctions. Cet article détaille l'injection de dépendance en Go.

TL;DR En Go, l'injection de dépendance doit être visible dans le code. Conséquence directe, le code doit être écrit pour permettre ce fonctionnement et la rendre visible. Il existe plusieurs manières d'injecter des dépendances. cet article fournit des exemples qui s'appuient sur les paramètres de fonction, un type container et des closures... Il présente également certaines alternatives que vous préfèrerez éviter sauf exceptionnellement.

Go n'est pas magique

Commençons par un peu de code magique qui illustre comment il est possible de réaliser de l'injection de dépendance avec Node. Disons que vous avez une bibliothèque qui fournit une fonction qui renvoie hello comme ci-dessous:

// hello.js file
const hello = () => {
	return "hello"
}

module.exports = hello

Vous avez ensuite un script index.js qui charge la function et l'utilise :

// index.js file
const hello = require("./hello")

console.log(hello())

Comme vous pouvez l'imaginer, exécuter le script index.js retournera probablement la chaine "hello". Essayez node index.js ! Vous pouvez ensuite faire de la magie. Si, par exemple, vous appelez au préalable un autre script, comme ci-dessous, le comportement peut être très différent :

// index.js file
require("./inject")
const hello = require("./hello")

console.log(hello())

Il se peut tout à fait que la commande node index.js retourne goodbye quand, par exemple, vous avez le script inject.js ci-dessous :

// inject.js
var mock = require("mock-require");

mock("./hello", () => {
  return "goodbye";
});

Vous pouvez penser que c'est très malin... Go ne permet pas ou au moins décourage fortement ce type de magic. Ainsi vous éviterez d'utiliser des variables globales et les fonctions init(). Et si vous pensez que ça serait très utile pour écrire votre code, arrêtez et pensez à quel point c'est très utile le code soit explicite pour quand vous lisez du code que ce soit celui des autres ou le votre dans 3 semaines...

des fonctions en paramètres

La manière la plus simple de réaliser une injection de dépendances est de passer les dépendances en paramètre de vos fonctions. Disons, par exemple, que vous avez une fonction qui prépare un message et l'envoie. Vous aimeriez que la fonction envoie le message par email ou par un POST HTTP. Vous aimeriez également pouvoir tester votre et fonction et, pour cela, faire un mock de l'envoi.

Une façon de réaliser cela est que votre fonction prenne la fonction d'envoi comme paramètre. Ce code ci-dessous montre comment cela peut être fait. Il montre également comment créer et utiliser une fonction mockSend() pour écrire et tester votre fonction :

package main

import (
	"fmt"
)

func mockSend(msg string) {
	fmt.Println(msg)
}

func prepareAndSend(f func(string)) {
	msg := fmt.Sprintf("message")
	f(msg)
}

func main() {
	prepareAndSend(mockSend)
}

Comme vous pouvez le voir ci-dessus, merci aux fonctions Go qui sont "first-class", vous pouvez utiliser une fonction comme un paramètre d'une autre fonction ce qui vous permet d'injecter du code dans une autre fonction à l'exécution à la seule condition d'avoir préparé le terrain à la conception.

Utiliser une interface en paramètre

Il se peut qu'au lieu d'avoir besoin d'injecter une fonction, il y en ait plusieurs à injecter. Par exemple, vous voudrez utiliser les fonctions Create, Read, Update et Delete. Si c'est le cas, vous pouvez toujours créer 4 paramètres pour injecter les dépendances...

Une façon plus commune de passer les fonctions consiste à déclarer et implémenter une interface et passer une struct, même vide, qui implémente cette interface avec vos fonctions en paramètre. Ci-dessous un exemple avec une interface qui utilise 2 fonctions string() et sendMSG():

package main

import (
	"fmt"
)

type gopher interface {
	string() string
	sendMSG(string)
}

type mockGopher struct{}

func (a *mockGopher) string() string {
	return "Rob Pike"
}

func (a *mockGopher) sendMSG(msg string) {
	fmt.Println(msg)
}

func prepareAndSend(g gopher) {
	msg := g.string()
	g.sendMSG(msg)
}

func main() {
	prepareAndSend(&mockGopher{})
}

Un receiver est un paramètre

Avant d'aller plus loin, il faut souligner qu'un receiver est un paramètre. En fait, il existe une syntaxe qui permet de passer le receiver en premier paramètre d'une méthode :

package main

import ( "fmt" )

type team struct {
	gophers []string
}

func (t *team) String() {
	for _, v := range t.gophers {
		fmt.Println(v)
	}
}

func main() {
	t := team{"Rob Pike", "Ken Thompson", "Robert Griesemer"}
	(*team).String(&t)
}

Struct et receiver

Si vous poussez toujours un peu plus loin, il est possible que vos fonctions ne dépendent pas simplement de fonctions/méthodes d'un package mais d'un ensemble de composants issus de plusieurs packages. Vous trouverez qu'une manière fréquente de gérer un tel ensemble de dépendances consiste à créer une structure qui contient les différentes interfaces comme champs. Cette structure, utilisée la plupart du temps comme receiver et appelée container des dépendances et c'est certainement une des formes les plus utilisées pour réaliser l'injection de dépendances. En voici un exemple:

  • le container est la structure qui contient les dépendances. Il possède une méthode appelée prepareAndSend pour laquelle on peut facilement modifier les dépendances
  • Les dépendances elles-mêmes sont stockées dans la structure container. Dans ce cas, on passe une variable qui implémente l'interface gopher laquelle, dans cet exemple, est une structure mockGopher qui implémente l'interface gopher
package main

import (
	"fmt"
)

type gopher interface {
	string() string
	sendMSG(string)
}

type container struct{
	gopher gopher
}

func (c *container) prepareAndSend() {
	msg := c.gopher.string()
	c.gopher.sendMSG(msg)
}

type mockGopher struct{}

func (a *mockGopher) string() string {
	return "Rob Pike"
}

func (a *mockGopher) sendMSG(msg string) {
	fmt.Println(msg)
}

func main() {
	c := &container{&mockGopher{}}
	c.prepareAndSend()
}

Dans l'exemple qui précède le receiver c est initialisé une structure mockGopher par défaut ce qui permet à prepareAndSend d'accèder aux méthodes associées.

Closures

Une autre manière fréquente d'injecter des dépendances consiste à créer une closure au lieu d'une structure. Une closure est une fonction anonyme qui reference des variables définies en dehors de son scope. Une closure est généralement plus facile à créer et plus facile à tester. Elle supprime le besoin de définir et créer une structure container.

Vous trouverez ci-dessous une même exemple que le précédent qui s'appuie sur une closure générée par la fonction generatePrepareAndSend. La closure est équivalente à la fonction prepareAndSend de l'exemple qui précède :

package main

import (
	"fmt"
)

type gopher interface {
	string() string
	sendMSG(string)
}

func generatePrepareAndSend(gopher gopher) func() {
	return func() {
		msg := gopher.string()
		gopher.sendMSG(msg)
	}
}

type mockGopher struct{}

func (a *mockGopher) string() string {
	return "Rob Pike"
}

func (a *mockGopher) sendMSG(msg string) {
	fmt.Println(msg)
}

func main() {
	generatePrepareAndSend(&mockGopher{})()
}

Les closures sont le plus souvent utilisées comme des fonctions singleton comme dans le cas d'un middleware HTTP mais ce n'est pas obligatoire.

Autres solutions

De fait, il existe d'autres manières de gérer les injections de dépendances. Elles peuvent être liées à des besoins d'injections multiples comme pour google/wire, uber-go/dig et un certain nombre de bibliothèques appelées dingo telles que elliotchance/dingo. Ces solutions peuvent s'avérer utiles, mais la plupart du temps, vous n'en aurez pas besoin.

Enfin, vous pourriez être tenté, s'il vous plait, ne le soyez pas, de mettre en place d'autres idiomes et, par exemple, la possibilité d'aller chercher vos dépendances dans un registre global. Le ServiceLocator est un complexe à lire et à tester et, là encore vous pouvez probablement vous en passer.

Continuez à prendre plaisir à lire et écrire du code clair et sans magie !