DIY 3 – Un conteneur d’injection de dépendances
L’injection de dépendances est un patron de conception assez facile à maîtriser qu’on retrouve dans pratiquement toutes les applications, partout dans le code. Toujours dans le cadre de la série DIY (Do It Yourself), je vous présente le prochain article qui traite d’un conteneur d’injection de dépendances.
Si vous avez manqué les deux premiers articles de la série, vous pouvez la suivre en utilisant les catégories du blogue.
Les sources de cet article sont sur GitHub.
Objectif
Démontrer comment fonctionne un conteneur d’injection de dépendances. Java sera utilisé pour démontrer le concept, mais tout langage orienté-objet supportant la réflexion pourrait faire.
Plan
- Créer les annotations
- Coder l’application de test
- Édifier un conteneur DI
- Élaborer un scanner de classe
- Accoucher d’un scanner d’attribut
Prérequis
- Une bonne connaissance de l’orienté-objet
- Un bonne compréhension du concept de réflexion
Étape 1 – Créer les annotations
L’injection de dépendances (DI) permet de respecter le principe d’inversion de dépendances ou DIP (voir « D » de SOLID). Le rôle du conteneur DI est d’instancier et d’injecter les objets directement dans ses attributs, soit via le constructeur, via un mutateur (setter) ou par réflexion, comme aujourd’hui.
À titre d’exemple, j’utiliserai les annotations de Java pour marquer les classes comme étant « injectable » et les propriétés comme étant « injectées ». Créons la première indiquant au conteneur DI que la classe est « injectable » :
1 2 3 |
@Retention(RetentionPolicy.RUNTIME) public @interface Injectable { } |
La rétention doit être définie pour que l’annotation persiste à l’exécution, sinon elle ne sera disponible qu’à la compilation. Nos classes dont les instances seront injectées devront être annotées de cette dernière. Pour indiquer qu’un attribut de classe soit « injecté » par le conteneur, nous aurons besoin d’une deuxième annotation :
1 2 3 |
@Retention(RetentionPolicy.RUNTIME) public @interface Injected { } |
Étape 2 – Coder l’application de test
Supposons un cas très simple, soit une application « console » qui affiche une liste d’utilisateurs, mais dans une architecture traditionnelle. Notre application utilisera un service d’utilisateur responsable de la logique d’affaires, qui lui-même utilisera un dépôt (repository) pour simuler un accès à la base de données.
1 2 3 4 |
@Injectable public interface UserRepository { List<User> findAll(); } |
Dans cet exemple, je propose d’ajouter l’annotation directement sur l’interface. Le conteneur sera responsable de trouver l’implémentation à injecter, supposant qu’il n’y en ait qu’une. Pour notre service, ce sera tout aussi simple :
1 2 3 4 |
@Injectable public interface UserService { List<User> findAllUsers(); } |
Pour l’implémentation, le dépôt (repository) retournera une liste d’utilisateurs codée-dure :
1 2 3 4 5 6 7 8 9 10 11 12 |
public class UserRepositoryDemo implements UserRepository { @Override public List<User> findAll() { return Arrays.asList( new User(0), new User(1), new User(2), new User(3), new User(4)); } } |
Le service, quant à lui, ne fera que déléguer l’appel au dépôt dont l’instance lui sera injectée par le conteneur :
1 2 3 4 5 6 7 8 9 |
public class UserServiceDemo implements UserService { @Injected private UserRepository userRepository; @Override public List<User> findAllUsers() { return this.userRepository.findAll(); } } |
Pour finir cette application, il nous faut une classe qui appelle :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public class TestApp { public TestApp() { System.out.println("Creating application..."); } @Injected private UserService userService; private void doSomething() { System.out.println("Application started - Doing Something..."); List<User> users = this.userService.findAllUsers(); users.forEach(x -> System.out.println(x)); System.out.println("Application ended - Bye!"); } } |
Étape 3 – Édifier un conteneur DI
La responsabilité du conteneur est d’indexer les classes « injectables » dans un registre avec leur implémentation. L’instance du conteneur sera construite par le patron de la fabrique (factory).
1 2 3 4 5 6 7 |
public abstract class DiContainer { public static DiContainer getInstance(String rootPackage) { return new DiContainerDemo(rootPackage); } public abstract <T> T getClassInstance(Class<T> testAppClass); } |
Lorsqu’un appelant voudra une instance, cette dernière lui sera retournée depuis une cache, implémentant les patrons Lazy Loading et Singleton pour nos instances.
1 2 3 4 5 6 7 |
public class DiContainerDemo extends DiContainer { private ClassScanner classScanner; private FieldsScanner fieldsScanner; private Map<Class<Injectable>, Class<Injectable>> implementations; private Map<Class<Injectable>, Injectable> registry; } |
À la création, le conteneur va lister les classes récursivement depuis un package de base. Toutes les classes annotées de @Injectable seront conservées dans une Map avec le type de leur implémentation. Lorsqu’on demandera une instance au conteneur, si elle n’est pas dans la cache, nous en créerons une nouvelle et la mettrons en cache. Le constructeur ressemble à :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public DiContainerDemo(String rootPackage) { System.out.println("Creating DI container..."); this.classScanner = ClassScanner.getInstance(rootPackage); this.fieldsScanner = FieldsScanner.getInstance(); this.registry = new HashMap<>(); this.implementations = new HashMap<>(); System.out.println("Looking for classes annotated to be managed by the DI container..."); this.classScanner.findAllClassHavingAnnotation(Injectable.class) .forEach(currentClass -> { Class implementation = this.classScanner.findImplementationOf(currentClass); System.out.println("Interface " + currentClass.getSimpleName() + " has " + implementation.getSimpleName() + " as implementation. Saving in registry."); this.registry.put(currentClass, null); this.implementations.put(currentClass, implementation); }); } |
Pour l’implémentation de la méthode abstraite, nous appelerons une méthode pour instancier la classe en paramètre, puis nous injecterons les dépendances « injectées » de cette dernière :
1 2 3 4 5 6 |
@Override public <T> T getClassInstance(Class<T> testAppClass) { Object instance = this.getInstanceOf(testAppClass); this.injectFields(instance); return (T) instance; } |
La méthode getInstanceOf a la responsabilité de créer une instance de la classe en paramètre si elle n’existe pas en cache, sinon l’instance en cache est retournée. C’est ici que le singleton prend forme.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private <T> Object getInstanceOf(Class<T> classToGetInstanceOf) { System.out.println("Looking for instance of " + classToGetInstanceOf.getSimpleName() + " in DI container cache"); Object instance = this.registry.get(classToGetInstanceOf); if (instance == null) { System.out.println("Instance not found. Creating."); try { instance = classToGetInstanceOf.newInstance(); System.out.println(instance + " created."); } catch (IllegalAccessException | InstantiationException e) { System.out.println("Cannot create instance of " + classToGetInstanceOf.getName()); e.printStackTrace(); } } return instance; } |
Avec la classe instanciée, nous utiliserons la réflexion pour lister les attributs annotés de @Injected, puis nous allons vérifier dans le registre pour trouver l’implémentation de l’interface et allons finalement appeler getClassInstance de façon récursive pour injecter les dépendances dans cette dépendance 🤯.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private void injectFields(Object instance) { if (instance != null) { final Object instancePassedToLambda = instance; this.fieldsScanner.findAllFieldsHavingAnnotation(instance, Injected.class) .forEach(currentField -> { boolean visibilityOfField = currentField.isAccessible(); currentField.setAccessible(true); try { Class<Injectable> implType = this.implementations.get(currentField.getType()); Object instanceCreated = this.getClassInstance(implType); currentField.set(instancePassedToLambda, instanceCreated); } catch (IllegalAccessException e) { e.printStackTrace(); } currentField.setAccessible(visibilityOfField); }); } } |
Étape 4 – Élaborer un scanner de classe
Comme vous avez pu constater ci-dessus, le conteneur DI appel un scanner de classe dans son constructeur pour lister les classes et interfaces annotées de @Injectable. Ce scanner doit lister récursivement les classes d’un package de base. Ici aussi, la fabrique crée l’instance :
1 2 3 4 5 6 7 8 |
public abstract class ClassScanner { public static ClassScanner getInstance(String rootPackage) { return new ClassScannerDemo(rootPackage); } public abstract <T> List<Class> findAllClassHavingAnnotation(Class<T> injectableClass); public abstract <T> Class<? extends T> findImplementationOf(Class<T> injectableClass); } |
Pour lister les classes d’un package, il faut utiliser le ClassLoader (réflexion) :
1 2 3 4 |
private Enumeration<URL> listFilesInClassLoader() throws IOException { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); return classLoader.getResources(""); } |
Le retour de cette méthode contiendra la liste des classes qui sont présentement chargées par le ClassLoader. Donc pour trouver les classes annotées, nous utiliserons (encore) la réflexion afin d’obtenir les annotations :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
@Override public <T> List<Class> findAllClassHavingAnnotation(Class<T> annotation) { List<Class> returned = new LinkedList<>(); System.out.println("Looking for all classes annotated with " + annotation.getSimpleName()); try { Enumeration<URL> urls = listFilesInClassLoader(); while(urls.hasMoreElements()) { URL next = urls.nextElement(); File curDir = new File(next.getFile()); for (Class curClass : this.findClassesInPackage(curDir)) { if (curClass.isAnnotationPresent(annotation)) { System.out.println("Class " + curClass + " found!"); returned.add(curClass); } } } } catch (IOException e) { e.printStackTrace(); } return returned; } |
Pour obtenir l’instance de Class qui correspond à un fichier du ClassLoader, nous utiliserons le chemin d’accès, remplacerons les « \ » en « . » (notation package), retirerons tout ce qui vient avant le package de base (répertoire source) et supprimerons l’extension du fichier (.class). Ceci nous donnera un nom de classe complet avec son package, comme com.ezoqc.blog.di.bootstrap.demo.UserService :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
private <T> Collection<Class> findClassesInPackage(File curDir) { List<Class> classes = new LinkedList<>(); if (curDir.exists()) { File[] content = curDir.listFiles(); for (File curFile : content) { if (curFile.isDirectory()) { classes.addAll(this.findClassesInPackage(curFile)); } else { try { String path = curFile.getAbsolutePath(); String asPackageFormat = path.replaceAll("\\\\", "."); int indexOfPackageRoot = asPackageFormat.indexOf(this.rootPackage); String fullClassName = asPackageFormat.substring(indexOfPackageRoot, asPackageFormat.length() - 6); classes.add(Class.forName(fullClassName)); } catch (ClassNotFoundException e) { e.printStackTrace(); } } } } return classes; } |
Étape 5 – Accoucher d’un scanner d’attribut
Comme vous avez pu voir dans la méthode injectFields du conteneur DI, un scanner d’attribut est utilisé pour obtenir les attributs de l’objet instancié qui sont annotés de @Injected. Une fabrique instanciera ce dernier, tout comme le scanner de classe :
1 2 3 4 5 6 7 |
public abstract class FieldsScanner { public static FieldsScanner getInstance() { return new FieldsScannerDemo(); } public abstract <T extends Annotation> List<Field> findAllFieldsHavingAnnotation(Object instance, Class<T> annotation); } |
Toujours par réflexion, nous listons les attributs et validons la présence de l’annotation :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
@Override public <T extends Annotation> List<Field> findAllFieldsHavingAnnotation(Object instance, Class<T> annotation) { List<Field> fieldsAnnotated = new LinkedList<>(); System.out.println("Looking for fields annotated with " + annotation.getSimpleName() + " on instance of " + instance.getClass().getSimpleName()); for (Field currentField : instance.getClass().getDeclaredFields()) { if (currentField.isAnnotationPresent(annotation)) { System.out.println("Field " + currentField.getName() + " of type " + currentField.getType().getSimpleName() + " found."); fieldsAnnotated.add(currentField); } } if (fieldsAnnotated.size() == 0) { System.out.println("No injected fields found on type " + instance.getClass().getSimpleName()); } return fieldsAnnotated; } |
Finalement, on attache le tout dans un main dans TestApp :
1 2 3 4 5 |
public static void main(String... argz) { DiContainer container = DiContainer.getInstance("com.ezoqc.blog.di"); TestApp application = container.getClassInstance(TestApp.class); application.doSomething(); } |
Le résultat :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
Creating DI container... Looking for classes annotated to be managed by the DI container... Looking for all classes annotated with Injectable Class interface com.ezoqc.blog.di.bootstrap.demo.UserRepository found! Class interface com.ezoqc.blog.di.bootstrap.demo.UserService found! Trying to find an implementation of UserRepository Class UserRepositoryDemo found! Interface UserRepository has UserRepositoryDemo as implementation. Saving in registry. Trying to find an implementation of UserService Class UserServiceDemo found! Interface UserService has UserServiceDemo as implementation. Saving in registry. Looking for instance of TestApp in DI container cache Instance not found. Creating. Creating application... com.ezoqc.blog.di.bootstrap.TestApp@63961c42 created. Looking for fields annotated with Injected on instance of TestApp Field userService of type UserService found. Looking for instance of UserServiceDemo in DI container cache Instance not found. Creating. com.ezoqc.blog.di.bootstrap.demo.UserServiceDemo@33c7353a created. Looking for fields annotated with Injected on instance of UserServiceDemo Field userRepository of type UserRepository found. Looking for instance of UserRepositoryDemo in DI container cache Instance not found. Creating. com.ezoqc.blog.di.bootstrap.demo.UserRepositoryDemo@3af49f1c created. Looking for fields annotated with Injected on instance of UserRepositoryDemo No injected fields found on type UserRepositoryDemo Application started - Doing Something... User id = 0 User id = 1 User id = 2 User id = 3 User id = 4 Application ended - Bye! |
Conclusion
Aujourd’hui, vous avez analysé comment fonctionne un conteneur d’injection de dépendances. Je vous rappelle que vous pouvez suivre la série via les catégories du blogue pour ne rien manquer. Je vous invite aussi à partager cet article avec vos collègues de travail et à vous inscrire au blogue. Nous sommes fiers de vous offrir du contenu gratuit et sans pub, donc vous pouvez être certains que nous ne vous spamerons pas par courriel 😉
Merci de nous lire! À la semaine prochaine.
Commentaires
2 commentaires
interessant, cependant, peut-être penser ne pas utiliser Google translate pour traduire de l’anglais au français la prochaine fois 😉
Ex:
Accoucher d’un scanner d’attribut
codé-dure
Cependant, l’article reste intéressant.
Salut Vazan!
Merci pour ton commentaire. Tout le matériel du blogue Ezo est du matériel original produit par et pour des francophones. Tu peux voir « accoucher d’un scanner d’attribut » comme une blague basée sur une accumulation de non-sens pour éviter de répéter le mot « créer ».
Désolé si ça t’as porté à confusion! Pour codée-dure, c’est effectivement un faute. Tu devrais lire « codée en dur ». Merci de l’avoir soulevée.
Laisser un commentaire