Il existe pas mal de façons de gérer la migration d'un service vers un autre de même API, dans mon contexte actuel, on a opté (après un temps d'étude de plusieurs solutions et en étant pragmatique sur le contexte qu'on avait) sur l'utilisation de l'Injection de Dépendance et de l'IoC pour gérer cette migration comme on voulait la faire. Je vous explique tout ça si ça peut vous aider à gérer une situation similaire !
Injection de dépendance ?
On entend souvent parler de DI (Dependancy Injection / Injection de dépendance) ou d'IoC (Inversion of Control / Inversion de Contrôle), mais il est bon de rappeler la base pour être certain qu'on parle de la même chose.
Déjà on parle bien de deux concepts différents : d'un côté l'injection de dépendance et de l'autre l'IoC. L'IoC c'est l'idée de ne pas appeler le framework mais plutôt laisser le framework nous appeler. Une manière de faire ça c'est d'utiliser le pattern DI pour faire en sorte que le framework nous fournisse les éléments dont dépendent chaque partie de notre code.
Prenons un exemple : imaginons qu'on a une entité Personne
qui a une seule action travaille()
, mais pour que Personne
fonctionne elle a besoin qu'on lui fournisse une entité Outil
qui peut être soit Pelle
soit Marteau
. Comment on fait ça en termes de code ?
Version très naïve en Java :
interface Outil {
void utilise();
}
class Pelle implements Outil {…}
class Marteau implements Outil {…}
class Personne {
private Outil outil;
Personne() {
this.outil = new Pelle();
}
travaille() {
this.outil.utilise();
}
}
Dans l'idée ça fonctionne. Mais une Personne
ne peut utiliser qu'une Pelle
, pas un Marteau
. Donc on pourrait partir sur une solution où on crée plusieurs outils dans le constructeur de Personne
avec plusieurs méthodes travailleAvecLaPelle()
, travailleAvecLeMarteau()
et continuer ensuite d'ajouter des méthodes à chaque nouvel outil. Ou on peut fournir l'instance d'Outil
qu'on veut que la Personne
utilise, de sorte que la Personne
ne sache même pas quel Outil
elle utilise, tant qu'il s'utilise de la même façon. Par exemple :
class Personne {
private Outil outil;
Personne(Outil outil) {
this.outil = outil;
}
…
}
Outil pelle = new Pelle();
Outil marteau = new Marteau();
var personne1 = new Personne(pelle);
var personne2 = new Personne(marteau);
personne1.travaille(); // utilise la Pelle
personne2.travaille(); // utilise le Marteau
Ici on fait de l'Injection de Dependance : on passe à une entité les entités dont elle dépend de sorte qu'elle puisse fonctionner.
Inversion of Control
Comme je le disais plus haut, l'Inversion of Control c'est l'idée de laisser le framework gérer l'appel de notre code. C'est très pratique ça permet d'être déclaratif mais par contre on arrive vite à un souci. Reprenons le code juste au-dessus et mettons un peu d'IoC dedans (ça ressemble à des choses connus mais c'est purement imaginaire !) :
@Bean
class Pelle implements Outil {…}
// On supprime la classe Marteau
@Bean
class Personne {
private Outil outil;
Personne(Outil outil) {
this.outil = outil;
}
…
}
En retirant le Marteau
tout fonctionne, car on peut directement imaginer que le framework va pouvoir détecter qu'il n'existe qu'un Outil
possible, Personne
peut toujours utiliser un Outil
sans savoir lequel. Tant qu'on ne veut pas avoir les deux outils en même temps dans notre système tout fonctionne.
Si on a plusieurs Outil
possible dans notre système, on va être obligé de caractériser notre injection d'une façon ou d'une autre. J'y reviens plus tard, car il existe beaucoup de techniques et c'est très dépendant du framework qu'on va utiliser.
Mon cas : la migration vers un nouveau service
Revenons à mon cas précis : je dois ré-écrire un service écrit dans une techno considérée legacy (aucune importance laquelle, c'est une API REST c'est tout ce qui compte) en Java / SpringBoot. Sauf qu'on sait que le développement va prendre du temps, on ne veut pas forcément attendre d'avoir tout ré-écrit pour migrer, on veut être itératif, donc il faut pouvoir utiliser l'API Legacy et l'API Java en production en même temps et pouvoir traiter certains messages qu'on reçoit sur un endpoint avec l'API Java et le reste avec l'API Legacy. Le discriminant est bien dans le body (en XML ici) pas dans l'URL !
J'ai quoi comme option ?
Première option : passer par un API Manager ou autre reverse proxy. On aurait donc une brique qui serait devant les deux versions de l'API et c'est cette brique qui ferait le dispatch. Le problème c'est que je n'avais pas accès à ce genre d'outil facilement, je n'avais pas trop la main sur l'infra non plus, et ça demandait d'apprendre à configurer de manière un peu pointue une nouvelle brique. On a donc éliminé cette option.
La seconde option c'était de déployer un service écrit à la main qui aurait juste été là pour faire du "routing" et dispatch en fonction de toggle feature les messages à la bonne API. On est assez proche de la solution une, mais en écrivant tout à la main, et en devant écrire du code qu'on va jeter après quelques mois. Dans mon contexte il y a de la friction à la création d'une nouvelle API, il y en a à priori encore plus pour en supprimer une, donc on a rejeté cette solution qui demandait quand même beaucoup de travail.
Note : je vais souvent parler de "toggle feature", on parle aussi de "feature flipping". Pour faire simple, ce sont des clés de configuration qu'on va associer à un booléen, si le booléen est à
true
on veut que la feature soit activée, sinon que la feature soit désactivée. Pour gérer ça on va simplement avoir unif
autour d'un morceau de code pour activer ou non une fonctionnalité.
La troisième option était de coder une mécanique de routing dans la nouvelle API, avec un dispatch entre les messages qu'on voulait traiter dans cette nouvelle API, et les messages qu'on transférait à l'API legacy. L'intérêt de faire ça c'est que lorsqu'on voudra décommissionner l'API legacy, il n'y aura qu'à supprimer le code, et aller en production. L'autre intérêt étant que les messages qu'on choisit de traiter dans la nouvelle API le seront sans avoir besoin de traiter le message entrant une fois de plus comme on est déjà dans l'API qui va traiter le message. Le souci étant de bien isoler la partie du code qui n'est utile que pendant la phase où on va re-coder les traitements manquants et qu'on va vouloir supprimer en réduisant au strict minimum le volume de code à retoucher.
On a choisi l'option 3. Et pour isoler le code à supprimer on a défini les interactions comme suit :
L'idée étant que le service est appelé en passant par une couche Controller
, cette couche va appeler un RouterService
qui va choisir en fonction du contenu du message et des toggles features soit d'appeler LegacyPassthroughService
soit Service
. Le jour où on aura tout migrer il suffit de supprimer RouterService
, LegacyPassthroughService
et toutes les toggles features utilisés uniquement par ces briques et on a fini. Sauf qu'en attendant Controller
dépend de RouterService
code qu'on voulait isoler au max et c'est embêtant…
La solution : utiliser le combo DI/IoC avec une interface en plus pour que notre Controller
ne sache pas ce qu'il appelle et que RouterService
, LegacyPassthroughService
et Service
aient la même interface pour qu'on puisse les interchanger !
Côté code on va donc avoir (en Java/Spring) :
interface ServiceInterface {
Response handleMessage(Message message);
}
@Service
class Service implements ServiceInterface {…}
@Service
class LegacyPassthroughService implements ServiceInterface {…}
@Service("router")
class RouterService implements ServiceInterface {
…
public RouterService(Service service, LegacyPassthroughService legacyService) {
…
}
}
@RestController
class Controller {
…
Controller(@Qualifier("router") ServiceInterface service) {
…
}
}
Comme on peut le voir, le Controller
manipule un objet de type ServiceInterface
, il ne peut donc manipuler que l'interface commune entre les implémentations sans pour autant savoir laquelle il manipule. Malheureusement, il faut dans ce cas qualifier l'injection en explicitant quelle version de l'interface on veut, c'est une des limitations de l'Inversion de Contrôle : on laisse le framework gérer donc il faut absolument lui donner des indications.
À noter qu'avec d'autre framework ça aurait pu être différent. Par exemple, en Angular on a un système de provider et de factory qui permet de très facilement gérer ce cas, mais du coup c'est aussi un peu moins "auto-magique" que Spring.
Quand on voudra supprimer le code qui gère le routing vers le legacy, on pourra supprimer les classes RouterService
et LegacyPassthroughService
ainsi que l'annotation @Qualifier("router")
, et évidemment les toggle features qu'on avait introduit pour gérer le routing. Le reste ne bougera pas ! On gardera ServiceInterface
(peut-être en changeant son nom, car ce n'est pas une bonne pratique d'avoir le type de l'élément dans le nom, mais je manquais d'idée cotée nommage…), car le Controller
n'a pas à connaître l'implémentation qu'il utilise, et peut-être que ça pourra servir dans le futur pour une autre manipulation !
Conclusion
Ce n'est pas une solution parfaite, mais elle a le mérite de parfaitement bien fonctionner ! Elle ne demande aucun apprentissage ou outil particulier, de même j'ai fait mon exemple avec Spring mais ça fonctionnerait avec un autre framework et même un autre langage, on pourrait même faire quelque chose de similaire côté front (je l'ai déjà fait avec Angular !). Je me suis dit que c'était un bon cas d'usage à vous partager montrant l'utilisation de l'injection de dépendance et de l'inversion de contrôle pour gérer différent services !
Sources :
- https://fr.wikipedia.org/wiki/Inversion_de_contrôle
- https://en.wikipedia.org/wiki/Inversion_of_control
Crédit photo : https://pixabay.com/photos/injection-needle-disposable-syringe-1973104/