Points Clés
- Jakarta CDI
- Clean architecture
- Injection de dépendance
Au sein de la programmation, l'orientation objet (également connue sous le nom de POO) est l'un des paradigmes les plus riches en documentation et en normes, comme c'est le cas avec l'injection de dépendances.
Cependant, le plus courant est que ces normes sont mal interprétées et mises en œuvre et ce qui permettrait une vision claire et une bonne conception pour une application coopérative nuit en pratique à la qualité et à l'efficacité de votre code.
Dans cet article, j'apporterai les avantages d'explorer l'orientation objet, plus précisément l'injection de dépendances, ainsi que la spécification du monde Java qui couvre cette API : Jakarta CDI.
Qu'est-ce que l'injection de dépendance ?
La première étape consiste à contextualiser ce qu'est l'injection de dépendance et pourquoi elle est différente de l'inversion contrôle, bien que les deux concepts soient très confus.
Dans le contexte de la POO (Programmation Orientation Objet), la dépendance est liée à toute subordination directe d'une classe et cela peut se faire de plusieurs manières. Par exemple, via des constructeurs, des méthodes d'accès telles que des setters ou des méthodes de création telles que Factory Method.
public Person(String name) {
this.name = name;
}
Extrait de code avec exemple de dépendance via le constructeur, dans lequel la personne a besoin d'un nom pour sa création.
Dans cet exemple d'injection, il est également possible de mesurer à quel point une classe est directement liée à une autre, c'est ce que nous appelons le couplage. Ceci est très important car cela fait partie du travail d'une bonne conception de code de se préoccuper d'une cohésion élevée et d'un faible couplage.
La différence entre l'injection de dépendance et l'inversion de contrôle
Une fois les concepts de base de la POO expliqués, l'étape suivante consiste à comprendre ce qui diffère entre l'injection de dépendances et l'inversion de contrôle.
Cette distinction est due à ce que nous appelons le DIP, ou Dependency Inversion Principle, qui fonctionne comme un guide des bonnes pratiques axées sur le découplage d'une classe des dépendances concrètes à travers deux recommandations :
-
Les modules de haut niveau ne doivent pas dépendre des modules de bas niveau, mais les deux doivent dépendre d'abstractions (exemple : des interfaces).
-
Les abstractions, à leur tour, ne devraient pas dépendre des détails. Cependant, les détails ou les implémentations concrètes doivent dépendre d'abstractions.
Alors que l'inversion de contrôle assume toutes ces exigences, l'injection de dépendances est une technique DIP pour fournir les dépendances d'une classe, généralement via un constructeur, un attribut ou une méthode telle qu'un setter.
En bref : l'injection de dépendance fait partie de l'ensemble des meilleures pratiques prônées par l'inversion de contrôle (IoC).
Aussi difficiles que soient ces concepts, au début, ils sont fondamentaux car ils peuvent être corrélés au principe de Barbara Liskov, après tout, le principe de Liskov et l'injection de dépendances font partie des principes SOLID.
D'où vient CDI et comment utiliser CDI de Jakarta ?
Comme pour l'injection et l'inversion, il est important de prendre du recul et de contextualiser ce qu'est CDI.
CDI (Dependency and Contexts Injection), en fait, est né de la nécessité de créer une spécification au sein du JCP, la JSR 365, principalement parce que, dans le monde Java - ainsi que dans des projets comme Spring et Google Guice - il existe plusieurs solutions et les implémentations de ces frameworks.
C'est pourquoi, avec l'arrivée de Jakarta EE, en raison du déménagement chez la Fondation Eclipse, Jakarta CDI est né (en savoir plus ici).
En bref, le but de Jakarta CDI est de permettre la gestion du contrôle de la vie des composants avec ou sans état via l'injection de contexte et de composants.
C'est un projet en constante évolution. Il est actuellement optimisé pour améliorer le démarrage via CDI Lite. Si vous voulez vous tenir au courant des dernières mises à jour, il vaut la peine de connaître Eclipse Open-DI.
CDI en pratique
Pour illustrer les fonctionnalités de CDI, nous allons créer une application Java SE simple avec CDI pour afficher six des fonctionnalités les plus importantes de CDI, à savoir :
-
Effectuer une injection
-
Différencier les implémentations par des qualifications
-
Apprendre à CDI à créer et distribuer des objets
-
Appliquer le pattern Observateur
-
Appliquer le pattern Décorateur
-
Utiliser un intercepteur
Dans cet article, pour ne pas faire un texte trop long, nous allons mettre en avant les points forts du code. Si vous le souhaitez, vous pourrez accéder ultérieurement au code complet.
Il est bon de mentionner que ce contenu a été créé avec Karina Varela et fait partie des cours que nous avons suivis tout au long de 2021, principalement dans les pays américains et européens, en partenariat avec Microstream et Payara.
Effectuer une simple injection
Dans notre première implémentation, nous allons créer une interface "Vehicle" et une implémentation utilisant le conteneur Java SE.
Un point important : en plus de l'injection de dépendances, nous avons le contexte, c'est-à-dire que nous pouvons définir le cycle de vie d'une classe ou d'un bean et, dans ce cas, l'implémentation aura la portée application.
try (SeContainer container = SeContainerInitializer.newInstance().initialize()) {
Vehicle vehicle = container.select(Vehicle.class).get();
vehicle.move();
Car car = container.select(Car.class).get();
car.move();
System.out.println("Is the same vehicle? " + car.equals(vehicle));
}
public interface Vehicle {
void move();
}
@ApplicationScoped
public class Car implements Vehicle {
//... implementation here
}
Dans ce morceau de code, nous avons l'interface Vehicle et son implémentation respective, Car, dans laquelle CDI pourra injecter une instance à la fois via l'interface et via l'implémentation.
Puisque nous parlons de meilleures pratiques, veuillez noter que nous utilisons le cas d'une seule interface pour une seule implémentation à des fins pédagogiques. En pratique, l'idéal est que vous ne le fassiez pas pour respecter le principe KISS.
Un bon indicateur pour cela sont les interfaces qui commencent par "I" ou les implémentations qui se terminent par "Impl". En plus d'être un indicateur de complexité inutile, c'est un code smell, après tout ce n'est pas un nom significatif et cela enfreint le principe du Clean Code.
Chaque règle a ses exceptions. Il est très important de prendre aussien considération certains cas comme les tests et les simulacres (mocks), qui sont des cas qui où l’utilisation d’interfaces est fondamentale.
Différencier les implémentations par les qualifications
Dans l'exemple précédent, nous avions le cas d'une relation un-à-un, c'est-à-dire une interface pour une seule implémentation. Mais que se passe-t-il lorsque nous avons plusieurs implémentations pour la même interface ?
Si nous n'avons pas une configuration dédiée, CDI ne saura pas quelle est l'implémentation par défaut et lèvera une AmbiguousResolutionException. Vous pouvez résoudre ce problème en utilisant l'annotation Named ou un Qualifier. Dans notre cas, nous utiliserons les Qualifiers.
Imaginez le scénario suivant dans lequel nous avons un orchestre avec plusieurs instruments de musique. Cet orchestre devra jouer tous les instruments ensemble, en plus de pouvoir les discriminer. Dans notre exemple, ce scénario ressemblerait au code suivant :
public interface Instrument {
String sound();
}
public enum InstrumentType {
STRING, PERCUSSION, KEYBOARD;
}
@Qualifier
@Retention(RUNTIME)
@Target({TYPE, METHOD, FIELD, PARAMETER})
public @interface MusicalInstrument {
InstrumentType value();
}
Dans cet exemple de code, nous avons l'interface Instrument, une énumération pour définir son type et un qualifier CDI via une nouvelle annotation.
Avec les implémentations et les qualifiers, il sera assez simple pour l'orchestre de jouer tous les instruments ensemble, en plus de sélectionner les instruments en tenant compte de leurs différents types, par exemple, s'ils sont à cordes, à percussion ou à bois.
@MusicalInstrument(InstrumentType.KEYBOARD)
@Default
class Piano implements Instrument {
@Override
public String sound() {
return "piano";
}
}
@MusicalInstrument(InstrumentType.STRING)
class Violin implements Instrument {
@Override
public String sound() {
return "violin";
}
}
@MusicalInstrument(InstrumentType.PERCUSSION)
class Xylophone implements Instrument {
@Override
public String sound() {
return "xylophone";
}
}
@ApplicationScoped
public class Orchestra {
@Inject
@MusicalInstrument(InstrumentType.PERCUSSION)
private Instrument percussion;
@Inject
@MusicalInstrument(InstrumentType.KEYBOARD)
private Instrument keyboard;
@Inject
@MusicalInstrument(InstrumentType.STRING)
private Instrument string;
@Inject
private Instrument solo;
@Inject
@Any
private Instance instruments;
}
Nous avons les implémentations d'instruments de musique et de leurs objets respectifs qui, finalement, aboutissent à l'injection dans l'orchestre.
Un point intéressant est que, en plus des qualificatifs, nous avons défini "Piano" comme l'implémentation par défaut pour l'orchestre et la meilleure : le client - dans ce cas, l'orchestre - n'a pas besoin de connaître ces détails.
Orchestra orchestra = container.select(Orchestra.class).get();
orchestra.percussion();
orchestra.keyboard();
orchestra.string();
orchestra.solo();
orchestra.allSound();
Le conteneur CDI injecte une instance de type Orchestra et utilise chaque instrument.
Apprendre à CDI à créer et distribuer des objets
Il n'est souvent pas possible, en plus de définir sa portée, d'effectuer des activités telles que la définition ou la création d'une classe dans le conteneur CDI. Ce type de ressource est important pour des cas comme, par exemple, lorsque nous voulons injecter la création d'une monnaie, de l’API Money, à l'intérieur d'un e-commerce ou une connexion à un service.
Dans CDI, il est possible d'apprendre au conteneur comment créer des instances et à les détruire via les annotations Produces et Disposes, respectivement. Pour cet exemple, nous allons créer une connexion qui sera créée et fermée.
public interface Connection extends AutoCloseable {
void commit(String execute);
}
@ApplicationScoped
class ConnectionProducer {
@Produces
Connection getConnection() {
return new SimpleConnection();
}
public void dispose(@Disposes Connection connection) throws Exception {
connection.close();
}
}
À l'heure actuelle, nous avons une connexion à partir de laquelle nous indiquons comment CDI doit créer et fermer la connexion. Avec cela, le client n'aura pas à se soucier de fermer la connexion dès qu'elle n'est pas nécessaire, puisque cela sera de la responsabilité du CDI.
Connection connection = container.select(Connection.class).get();
connection.commit("Database instruction");
Pour cet exemple, nous injectons une instance de Connection, exécutons une opération et ne nous soucions pas de la fermeture de la ressource par le client, puisque cela sera de la responsabilité de CDI.
Appliquer le pattern Observateur
Parmi les patterns très présents dans les architectures d'entreprise et présents dans le GoF, on ne peut pas oublier l'Observer.
L'une des évaluations que je fais de ce modèle est que, compte tenu de son importance, nous pouvons le voir de la même manière dans les modèles architecturaux, tels que Event-Driven, et même dans un paradigme avec programmation réactive.
Dans CDI, nous pouvons gérer les événements de manière synchrone et asynchrone. Imaginez, par exemple, que nous ayons un journaliste et qu'il doive avertir tous les médias nécessaires. Si on fait le couplage de ces médias directement dans la classe "Journalist", à chaque fois qu'un média est ajouté ou retiré, il faudra la modifier. Cela brise le principe ouvert-fermé : pour résoudre cela, nous utiliserons un Observer.
@ApplicationScoped
public class Journalist {
@Inject
private Event event;
@Inject
@Specific
private Event specificEvent;
public void receiveNews(News news) {
this.event.fire(news);
}
}
public class Magazine implements Consumer {
private static final Logger LOGGER = Logger.getLogger(Magazine.class.getName());
@Override
public void accept(@Observes News news) {
LOGGER.info("We got the news, we'll publish it on a magazine: " + news.get());
}
}
public class NewsPaper implements Consumer {
private static final Logger LOGGER = Logger.getLogger(NewsPaper.class.getName());
@Override
public void accept(@Observes News news) {
LOGGER.info("We got the news, we'll publish it on a newspaper: " + news.get());
}
}
public class SocialMedia implements Consumer {
private static final Logger LOGGER = Logger.getLogger(SocialMedia.class.getName());
@Override
public void accept(@Observes News news) {
LOGGER.info("We got the news, we'll publish it on Social Media: " + news.get());
}
}
Nous avons donc créé une classe "Journalist" qui notifie les médias, une news grâce au pattern Observateur avec CDI. L'événement est déclenché par l'instance "Event" et pour l'écouter, il faut utiliser l'annotation "@Observers" avec le paramètre spécifique à écouter.
Appliquer le pattern Décorateur
Le pattern Decorator nous permet d'ajouter un comportement à l'intérieur de l'objet, en obéissant au principe de composition sur l'héritage. Dans le monde Java, nous voyons cela avec des Wrappers de type primitif comme Integer, Double, Long etc.
Dans notre exemple, un travailleur sera créé qui sera décoré par un responsable, nous avons donc ajouté le comportement d'envoi d'un e-mail pour chaque activité d'un travailleur.
public interface Worker {
String work(String job);
}
@ApplicationScoped
public class Programmer implements Worker {
private static final Logger LOGGER = Logger.getLogger(Programmer.class.getName());
@Override
public String work(String job) {
return "A programmer has received a job, it will convert coffee in code: " + job;
}
}
@Decorator
@Priority(Interceptor.Priority.APPLICATION)
public class Manager implements Worker {
@Inject
@Delegate
@Any
private Worker worker;
@Override
public String work(String job) {
return "A manager has received a job and it will delegate to a programmer -> " + worker.work(job);
}
}
Avec cela, nous créons une abstraction "travailleur" (Worker), le "programmeur" (Programmer), et le "Manager" chargé de déléguer le travailleur. De cette manière, nous aurions pu ajouter un comportement, comme l'envoi d'un e-mail, sans modifier le programmeur.
Worker worker = container.select(Worker.class).get();
String work = worker.work("Just a single button");
System.out.println("The work result: " + work);
Utiliser un intercepteur
Avec CDI, il est également possible d'effectuer et de contrôler certaines opérations dans le code de manière transversale très similaire à ce que nous faisons avec la programmation orientée aspect et les points de coupe avec Spring.
L'intercepteur CDI a tendance à être très utile lorsque nous voulons, par exemple, un mécanisme de journalisation, un contrôle de transaction ou un chronomètre sur le temps d’exécution d’une méthode, entre autres. Dans ce cas, l'exemple utilisé sera un chronométreur défini dans un intercepteur.
@InterceptorBinding
@Target({METHOD, TYPE})
@Retention(RUNTIME)
public @interface Timed {
}
@Timed
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class TimedInterceptor {
private static final Logger LOGGER = Logger.getLogger(TimedInterceptor.class.getName());
@AroundInvoke
public Object auditMethod(InvocationContext ctx) throws Exception {
long start = System.currentTimeMillis();
Object result = ctx.proceed();
long end = System.currentTimeMillis() - start;
String message = String.format("Time to execute the class %s, the method %s is of %d milliseconds",
ctx.getTarget().getClass(), ctx.getMethod(), end);
LOGGER.info(message);
return result;
}
}
Une annotation est définie, elle même annotée avec l’annotation InterceptorBinding. Comme toute annotation de type Binding, son rôle est de faire le lien entre un intercepteur et les méthodes sur lesquelles il doit être appliqué.
L’implémentation de l’intercepteur met en oeuvre un chronomètre sur le temps d’exécution d’une méthode
Le prochain et dernier exemple consiste à créer deux méthodes qui renvoient une chaîne de caractères que l'on chronométrera en utilisant l’intercepteur, avec deux implémentations dont l'une aura un délai de deux secondes.
public class FastSupplier implements Supplier {
@Timed
@Override
public String get() {
return "The Fast supplier result";
}
}
public class SlowSupplier implements Supplier {
@Timed
@Override
public String get() {
try {
TimeUnit.MILLISECONDS.sleep(200L);
} catch (InterruptedException e) {
//TODO it is only a sample, don't do it on production :)
throw new RuntimeException(e);
}
return "The slow result";
}
}
Supplier fastSupplier = container.select(FastSupplier.class).get();
Supplier slowSupplier = container.select(SlowSupplier.class).get();
System.out.println("The result: " + fastSupplier.get());
System.out.println("The result: " + slowSupplier.get());
Deux classes Supplier annotées avec l'annotation Timed qui seront interceptées pour indiquer le temps d'exécution des deux méthodes dans le journal.
Injection de dépendances avec Jakarta CDI : plus d'options pour votre développement
Au vu de ces exemples pratiques, nous pouvons voir les différentes possibilités et défis possibles lorsque l'on travaille avec l'orientation objet, en plus de mieux connaître certains de ses principes autour de l'injection de dépendances et de CDI.
Il est important de se rappeler que, comme l'a dit un jour l'oncle Ben, "un grand pouvoir implique une grande responsabilité". En tant que tel, le bon sens reste la meilleure boussole pour explorer ces fonctionnalités et d'autres de CDI, ce qui se traduit par une conception et une clarté de haute qualité.
Les références:
-
Difference between dependency injection and dependency inversion
-
Dependency Injection Is NOT The Same As The Dependency Inversion Principle