- Published on
L'inversion de dépendances et son implémentation en React.js avec typescript
- Authors
- Name
- Léo Delpon
Application d’un principe SOLID: “Dependency Inversion Principle (DIP)” en utilisant typescript pour une application React.js
Avant de commencer, un petit rappel
Est-ce que vous connaissez Robert C. Martin ? Si vous avez dit non
dans votre tête, et bien sachez que cet homme, alias uncle bob
est un ingénieur logiciel qui a énormément contribué pour l’avancée en informatique. C’est un homme tellement passionné qu’il a décrit les premiers principes de conception destinés à produire des architectures de code plus compréhensibles, flexibles et maintenables. En effet, c’est lui qui a défini les premiers principes SOLID
.
Je ne m’étendrai pas trop sur le principe SOLID
mais si vous souhaitez vous renseigner plus en détails et que vous demandez pourquoi on parle de SOLID
en 2023, je vous conseille ce petit d’aller lire cet article et si vous ne connaissez vraiment pas, je vous conseille vivement de lire cet article avant de lire le mien !
Dans cet article je vais parler d’un principe en particulier, celui de l’inversion des dépendances. Ce principe stipule que les classes de haut niveau (celles qui définissent les politiques et les comportements généraux) ne devraient pas dépendre directement de l’implémentation des classes de bas niveau (celles qui implémentent des détails plus spécifiques). Les deux devraient dépendre d’abstractions comme les interfaces
. Cela permet en outre d’améliorer:
- la modularité,
- la testabilité
- la flexibilité du code
Pour respecter ce principe, je vous propose une implémentation du patron de conception de l'injection de dépendances
. A savoir que ceci n’est qu’une implémentation parmis tant d’autres ! Je serais ravi d’avoir vos retours via LinkedIn si vous avez des critiques constructives 🥰
Mais Jamy, c’est quoi l’injection de dépendances ?
L’injection de dépendances à pour objectif de déléguer la création des dépendances d’une classe à un code appelant qui va ensuite les injecter dans la classe correspondant. Grâce à ce mécanisme, la création d’une instance de la dépendance est effectuée à l’extérieur de la classe dépendant et injectée dans cette dernière.
Ca paraît très abstrait n’est-ce pas ? Pas de soucis, imaginons que nous ayons une application de messagerie. Lorsqu'un utilisateur envoie un message, l'application doit notifier l'utilisateur par email et par SMS. Sans utiliser l'injection de dépendances, le code pourrait ressembler à ceci :
class EmailService {
sendEmail(user, message) {
console.log('Email envoyé à ${user.email}: ${message}')
}
}
class PhoneService {
sendSMS(user, message) {
console.log('Sms envoyé à ${user.email}: ${message}')
}
}
class MessagingApp {
constructor() {
this.emailService = new EmailService()
this.smsService = new SMSService()
}
sendMessage(user, message) {
this.emailService.sendEmail(user, message)
this.smsService.sendSMS(user, message)
}
}
const user = { email: 'delponleo@gmail.com', phoneNumber: '0123456789' }
const messagingApp = new MessagingApp()
messagingApp.sendMessage(user, "L'injection de dépendances c'est inutile")
Dans cet exemple, la classe MessagingApp
dépend directement des implémentations concrètes des services EmailService
et SMSService
. Si nous voulons changer la façon dont les notifications sont envoyées, ou si nous voulons ajouter de nouveaux services de notification, nous devons modifier la classe MessagingApp
.
Maintenant reprenons ce code en utilisant l’injection de dépendances :
// Définissons des interfaces pour les services de notification
interface INotify {
notify(user, message): void;
}
class EmailService implements INotify {
notify(user, message) {
console.log(`Email envoyé à ${user.email}: ${message}`);
}
}
class SMSService implements INotify {
notify(user, message) {
console.log(`SMS envoyé à ${user.phoneNumber}: ${message}`);
}
}
class MessagingApp {
constructor(private notifiers: INotify[]) {}
sendMessage(user, message) {
this.notifiers.forEach((notifier) => notifier.notify(user, message));
}
}
const user = { email: "user@example.com", phoneNumber: "0123456789" };
const notifiers = [new EmailService(), new SMSService()];
const messagingApp = new MessagingApp(notifiers);
messagingApp.sendMessage(user, "Bonjour !");
Dans cette version modifiée de l'exemple, nous avons créé une interface appelée Notifier
que les services de notification doivent respecter. Désormais, la classe MessagingApp
s'appuie sur l'abstraction Notifier
au lieu de dépendre directement des implémentations spécifiques. Les services de notification sont introduits dans le constructeur de MessagingApp
, permettant ainsi de séparer les dépendances et rendant l'application plus facile à modifier ou à étendre.
Maintenant que vous comprenez un peu mieux ce qu’est l'injection de dépendances
, je vais vous proposer mon implémentation en typescript
pour un projet React.js
Etape préliminaire
- Installez le package
inversify
ainsi quereflect-metadata
:
npm install inversify reflect-metadata
- Activez les décorateurs expérimentaux et les métadonnées de réflexion dans votre fichier
tsconfig.json
:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// ...
}
}
Implémentation de ce patron de conception
On va enfin taper dans le vif du sujet, la première étape va être la création de nos services. Reprenons notre exemple avec les messages.
// INotifier.ts
import { User } from '../repertoire/vers/le/modele/user'
export interface INotify {
notify(user: User, message: string): void;
}
On commence par créer une interface INotify
pour les deux implémentations que l’on va créer qui sont EmailService
et PhoneService
. On va ensuite créer nos deux services.
// emailService.ts
import { injectable } from 'inversify'
import { INotify } from '../repertoire/vers/interface/INotify'
@injectable()
export class EmailService implements INotify {
notify(user: User, message: string) {
console.log(`Email envoyé à ${user.email}: ${message}`)
}
}
// phoneService.ts
import { injectable } from 'inversify'
import { INotify } from '../repertoire/vers/interface/INotify.ts'
@injectable()
export class PhoneService implements INotify {
notify(user: User, message: string) {
console.log(`Sms envoyé à ${user.email}: ${message}`)
}
}
Enfin, on a notre classe qui va pouvoir utiliser les deux services. A savoir que cette implémentation ne reprend le principe de la boucle itérative que nous avions, je proposerai une autre implémentation pour garder cette boucle grâce à la mise en place des Symbols
avec le module inversify
. On considère aussi que le manager aura besoin d’initialiser quelque chose.
// messagingManager.ts
import { PhoneService } from "../repertoire/vers/phoneService.ts";
import { EmailService } from "../repertoire/vers/emailService.ts";
import { INotify } from "../repertoire/vers/interface/INotify.ts";
@injectable()
export class MessagingManager {
constructor(@inject(EmailService) private readonly emailService: INotify, @inject(PhoneService) private readonly phoneService: INotify) {}
async init() {
// Initialisation du manager
// Fait quelque chose LOL
}
sendMessage(user: { email: string; phoneNumber: string }, message: string): void {
this.emailService.notify(user.email);
this.phoneService.notify(user.phoneNumber);
}
}
Maintenant que nous avons, nos services “bas niveau” et notre classe “haut niveau”, il est temps de créer notre conteneur qui va permettre l’injection des dépendances !
// container.config.tsx
import React, { useContext } from "react";
import { Container, interfaces } from "inversify";
import "reflect-metadata";
import { MessagingManager } from "../john/doe/messagingManager";
import { PhoneService } from "../john/doe/phoneService";
import { EmailService } from "../john/doe/emailService";
// On crée notre context React qui wrappera notre application sur notre App.tsx
const ContainerContext = React.createContext<container: Container | null>({container: null});
// On définit les différents types qui sont composé du conteneur ainsi que l'ensemble des composants qu'il wrappera
type Props = {
container: Container;
children?: React.ReactNode | JSX.Element;
};
// On crée le composant qui va encapsuler l'ensemble des composants dans App.tsx il constitue notre "Context Provider"
export const ContainerProvider: React.FC<Props> = (props: Props) => (
<ContainerContext.Provider value={{ container: props.container }}>{props.children}</ContainerContext.Provider>
);
// Cet injecteur permet d'utiliser nos managers ou services sur n'importe quel écran !
export function useInject<T>(identifier: interfaces.ServiceIdentifier<T>) {
const { container } = useContext(ContainerContext);
if (!container) {
throw new Error("React ContainerContext has not been initialized");
}
return container.get<T>(identifier);
}
// On crée une fonction qui va tout créer pour nous !
export function createContainer() {
const container = new Container();
configure(container);
return container;
}
// Cela nous permet de lier les classes au conteneur
// On ajoute "inSingletonScope" car il peut être utile de partager
// une seule instance d'un service pour réduire les coûts de création
// d'instances ou pour partager des ressources communes.
function configure(container: Container) {
console.log("[Container] container configuration ...");
container.bind<Notifier>(EmailService).toSelf().inSingletonScope();
container.bind<Notifier>(PhoneService).toSelf().inSingletonScope();
container.bind<MessagingManager>(MessagingManager).toSelf().inSingletonScope();
console.log("[Container] Configuring container : OK");
}
Maintenant, nous allons pouvoir continuer notre code avec le fichier App.tsx
, qui constitue le fichier principal de notre projet React.js
.
import React, { useCallback, useEffect, useMemo } from 'react'
import { ContainerProvider, createContainer } from './container.config'
import { MessagingManager } from '../john/doe/messagingManager'
const App = () => {
// 1
const container = useMemo(() => createContainer(), [])
// 2
const initServices = useCallback(async () => {
console.log('[Service App Initialization] Init services...')
await container.get<MessagingManager>(MessagingManager).init()
}, [container])
// 3
const init = useCallback(async () => {
console.log('[App Initialization] Init app...')
await Promise.all([initServices(), delay(1500)])
}, [container, initServices])
// 4
useEffect(() => {
init()
}, [init])
// 5
return <ContainerProvider container={container}>...</ContainerProvider>
}
export default App
Pour ce fichier, je vais le décrire un peu plus en détail.
- Cette est la phase de
mémoïsation
elle permet une mise en cache de données pour une meilleure performance.useMemo()
est utilisé pour mémoïser le conteneur Inversify en utilisant la fonctioncreateContainer
importée. Cela permet de créer le conteneur une seule fois et de le réutiliser lors des rendus ultérieurs du composantApp
. initServices
est une fonction asynchrone définie avecuseCallback
qui initialise les services de l'application. Ici, elle appelle la méthodeinit
du serviceMessagingManager
.useCallback
garantit que la même instance de la fonction est utilisée lors des rendus ultérieurs, sauf si les dépendances changent (dans ce cas,container
).init
est une autre fonction asynchrone définie avecuseCallback
. Elle est responsable de l'initialisation de l'application. Elle exécuteinitServices
et attend également un délai de 1500 ms (pour simuler le chargement de l’application) en utilisantPromise.all
.useEffect
est utilisé pour appeler la fonctioninit
lorsque le composant est monté. Le tableau de dépendances contient uniquementinit
, ce qui signifie que l'effet ne sera exécuté qu'une fois, lors du montage du composant.- Le composant retourne un élément JSX qui utilise
ContainerProvider
pour rendre le conteneur Inversify disponible à tous les composants enfants. Cela permet aux composants enfants d'utiliser le conteneur pour accéder aux services et aux dépendances.
Enfin, on va pouvoir utiliser notre petite fonction useInject
que l’on avait codé dans le fichier container.config.tsx
.
import React from 'react'
import { useInject } from '../john/doe/container.config'
import { MessagingManager } from '../john/doe/messagingManager'
export const MonComposantReactLambda = ({}) => {
const messagingManager = useInject<MessagingManager>(MessagingManager);
return (
<div
onClick={() => {
messagingManager.sendMessage(user, 'voici mon message !')
}}
>
...
</div>
)
}
Au final qu’avons-nous réalisé ? Pas grand chose par rapport à d’habitude:
- On a codé une interface commune pour les services
EmailService
etPhoneService
. - On a réalisé deux classes de services (truc basique) qui implémentent une interface. Les nouvelles classes peuvent être traitées comme la même "forme", mais elles ne sont pas un enfant. Elles peuvent être transmises à toute méthode nécessitant
INotifier
, même si elle a un parent différent deINotifier
. - On a implémenté une classe “haut niveau” qui a besoin de nos deux services.
- On a implémenté un conteneur qui permet d’injecter automatiquement les dépendances.
- On a mis notre conteneur en tant que
Provider
dans notre App.tsx - On a aussi implémenté un
hook
d’injection qui permet de récupérer tout nos objets dont on a besoin.
C’est un implémentation qui semble un peu lourde de prime abord mais pour une application lourde en fonctionnalité, cela permet d’avoir une meilleure visiblité, une meilleure flexibilité ainsi qu’une meilleure modularité !
Bonus: Refaire la même chose mais en gardant la boucle itérative
// container.config.tsx
import React, { useContext } from "react";
import { Container, interfaces } from "inversify";
import "reflect-metadata";
import { MessagingManager } from "../john/doe/messagingManager";
import { PhoneService } from "../john/doe/phoneService";
import { EmailService } from "../john/doe/emailService";
// On crée notre context React qui wrappera notre application sur notre App.tsx
const ContainerContext = React.createContext<container: Container | null>({container: null});
export const NotifierId = Symbol('Notifier');
...
// On crée une fonction qui va tout créer pour nous !
export function createContainer() {
const container = new Container();
configure(container);
return container;
}
// Cela nous permet de lier les classes au conteneur
// On ajoute "inSingletonScope" car il peut être utile de partager
// une seule instance d'un service pour réduire les coûts de création
// d'instances ou pour partager des ressources communes.
function configure(container: Container) {
console.log("[Container] container configuration ...");
container.bind<Notifier>(NotifierId).to(EmailService);
container.bind<Notifier>(NotifierId).to(PhoneService);
container.bind<MessagingManager>(MessagingManager).toSelf().inSingletonScope();
console.log("[Container] Configuring container : OK");
}
const NotifierId = Symbol('Notifier');
crée un Symbol
unique avec une description optionnelle 'Notifier'
. Les Symbol
sont des valeurs primitives uniques introduites dans ECMAScript 2015 (ES6) et sont utilisées pour créer des identifiants uniques pour les propriétés d'objets.
Dans le contexte de l'exemple et d'InversifyJS, le Symbol
est utilisé comme identifiant unique pour lier et résoudre les dépendances associées à l'interface Notifier
. L'utilisation d'un Symbol
garantit que l'identifiant est unique et ne peut pas être accidentellement écrasé ou en conflit avec d'autres identifiants dans le conteneur d'injection de dépendances.
Voici comment le Symbol
est utilisé dans l'exemple :
- Le
Symbol
NotifierId
est créé pour représenter les services de notification qui implémentent l'interfaceNotifier
.
const NotifierId = Symbol('Notifier')
NotifierId
est utilisé pour lier les implémentations deNotifier
(c'est-à-direEmailService
etSMSService
) dans le conteneur Inversify.
container.bind<Notifier>(NotifierId).to(EmailService)
container.bind<Notifier>(NotifierId).to(SMSService)
Ensuite dans notre classe MessaginApp
// messagingManager.ts
import { PhoneService } from "../repertoire/vers/phoneService.ts";
import { EmailService } from "../repertoire/vers/emailService.ts";
import { NotifierId } from "../repertoire/vers/container.config.tsx";
import { INotify } from "../repertoire/vers/interface/INotify.ts";
@injectable()
export class MessagingManager {
constructor(@inject(NotifierId) private notifiers: INotify[]) {}
async init() {
// Initialisation du manager
// Fait quelque chose LOL
}
sendMessage(user: { email: string; phoneNumber: string }, message: string): void {
this.notifiers.forEach((notifier) => notifier.notify(user, message));
}
}
- Dans le code ci-dessus
NotifierId
est utilisé avec le décorateur@inject()
pour indiquer au conteneur Inversify quelle dépendance injecter dans le constructeur deMessagingApp
.
Et voilà ! Vous avez appris à rassembler plusieurs classes “bas niveaux” grâce aux symboles. J’espère que ce petit article aura sû vous aider. N’hésitez pas à me faire des retours et me dire quelles améliorations je pourrais ajouter.
Mais du coup, est-ce que c'est performant et dans quel cas utiliser ce machin ?
Il faut savoir que l'injection de dépendances
lorsqu'elle est mal utilisée peut aussi rendre le code moins propre car c'est plus complexe de comprendre qui influe sur quoi comparé à des méthodes standards de programmation.
Malheureusement pour vous, le clean code n'est pas quelque chose de performant en soi. vous avez surement dû voir la vidéo de Casey Muratori qui montre à quel point le clean code est lent, néanmoins, je trouve qu'il faut prendre un peu de recul. En effet, la performance n'est pas tout le temps la contrainte principale lors de la réalisation d'un projet. En effet, on privilégiera parfois la modularité, la flexbilité, la testabilité ou encore la sécurité.
De même, le "clean code" est appliqué dans tout les cas où la qualité
du code est importante. En effet cette pratique va faciliter la maintenance
du code en le rendant plus facile à comprendre et à modifier. Les développeurs peuvent facilement ajouter de nouvelles fonctionnalités, corriger des bugs ou améliorer des parties existantes du code. On peut parler d'une meilleure évolutivité
grâce à la propreté du code. Enfin, le "clean code" permet pour moi, d'avoir une bien meilleure collaboration
si plusieurs développeurs travaillent sur le même projet.
Par contre, si vous cherchez la performance, je ne vous conseille pas d'utiliser cette pratique. C'est pour ça que j'ai implémenté ce design pattern en typescript avec React.js et non avec du C# par exemple 😝.