Accéder au contenu principal

Comprendre l'Inversion de Contrôle : Un Guide Pratique de Guice sur l'Injection des Dépendances dans le Développement de Logiciels

L'un des principes fondamentaux dans le développement de logiciels (ou d'applications) est la mise en oeuvre des principes SOLID. Le D ici indique: Dependency Inversion ou Dependency Injection ou encore Inversion Of Control (IoC). Autrement dit: Inversion de Contrôle ou Injection des Dépendances. L'injection des dépendances à pour principal objectif le découplage des modules entre eux. Cela implique donc de créer et développer les différents composants à base de classes abstraites ou d'interfaces, comme le permet Java. Ce faisant, l'application ou le logiciel devra utiliser une librairie ou un framework qui permette d'injecter les fameuses dépendances en question. Nous verrons dans la suite du tutoriel comment une librairie (ou framework léger) comme Guice (Google Guice) nous aidera dans cet objectif.

Note: Spring Framework est l'un des framework les plus connus qui permettent de mettre en oeuvre l'Inversion de Contrôle. 


1. Qu'est ce que l'Injection des Dépendances

L'injection des dépendances consiste à développer une application ou un service en suivant deux étapes principales. La première consiste à ne pas créer de couplage fort entre les différents modules d'une application. Et la seconde consistera à trouver les implémentations adéquates nécessaires au bon fonctionnement de l'application ou du service.

1.1 Couplage faible

Dans le language Java, nous allons développer nos différents composants en utilisant des classes abstraites ou des interfaces, sans forcément indiquer les implémentations dans un premier temps. Ce faisant les composants n'utiliseront que des interfaces pour communiquer entre eux, minimisant ainsi très fortement le couplage en eux. Voyons cela dans l'exemple ci dessous:

@Slf4j
@RequiredArgsConstructor
public class CreateCompanyComponent implements ICreateCompanyComponent {
    private final ICreateCompanyOutputPort createCompanyOutputPort;
    
    @Override
    public Company createCompany(ICreateCompanyInputPort createCompanyInputPort) 
    throws ValidateCompanyException, CreateCompanyException {
        try {
            Company company = createCompanyInputPort.getCompany();
            return createCompanyOutputPort.saveCompany(company);
        } catch (ValidationException e) {
            log.warn("Company validation failed due to: {}", e.getMessage());
            throw new ValidateCompanyException(e.getMessage(), e);
        } catch (Exception e) {
            throw new CreateCompanyException(e.getMessage(), e);
        }
    }
}

Comme nous pouvons le constater, nous avons utilisé une interface ici: ICreateCompanyOutputPort, qui expose la méthode saveCompany. En aucun moment notre composant CreateCompanyComponent n'est en mesure de savoir exactement l'implémentation sous-jacente à l'interface ICreateCompanyOutputPort. Notre composant a donc un faible couplage avec l'implémentation qui se chargera de réaliser effectivement la sauvegarde de l'objet Company.

1.2 Injection des dépendances

L'étape suivante consiste à implémenter notre interface ou nos classes abstraites afin que notre application puisse fonctionner. Il s'agit purement et simplement de l'implémentation de l'interface et rien d'autre.

Ensuite, il va nous falloir, utiliser une librairie ou framework léger qui se chargera d'instancier l'implémentation de l'interface à chaque fois que cela est nécessaire: on parle alors de l'injection de cette implémentation, autrement dit: injection de dépendance.

2. Guice

Guice est un framework léger développé par google qui permet de mettre en oeuvre la JSR 330 (Dependency Injection). Cette librairie est moins gourmande que Spring et rapide à mettre en oeuvre. Comme approche principale, elle privilégie les conventions de développement  au dessus de la configuration (convention over configuration).

3. Utilisation de Guice dans un projet java

Dépendance Gradle

implementation 'com.google.inject:guice:7.0.0'

Dépendance Maven

<dependency>
    <groupId>com.google.inject</groupId>
    <artifactId>guice</artifactId>
    <version>7.0.0</version>
</dependency>

3.1 Définition du module de binding

L'étape suivante est de définir un module Guice qui se chargera d'associer (binding) les interfaces et leur implémentation de manière simple et transparente afin que Guice puisse faire l'injection des dépendances. En effet, dans le module ci dessous, nous indiquons à Guice quelles implémentations instancier pour les interfaces qui nous intéresse. Si l'implémentation que nous souhaitons configurer ici utilise un autre constructeur que le constructeur par défaut, alors nous devons faire le binding avec toConstructor(...). Dans le cas contraire, il faudra utiliser to(...) lors que c'est le constructeur par défaut qui doit être invoqué pour l'instanciation.

@Slf4j
public class CreateCompanyModule extends AbstractModule {
    @Override
    protected void configure() {
        try {
            bind(ICreateCompanyComponent.class).toConstructor(
            	CreateCompanyComponent.class.getConstructor(ICreateCompanyOutputPort.class));
            bind(ICreateCompanyOutputPort.class).to(CreateCompanyAdapter.class);
        } catch (NoSuchMethodException e) {
            log.warn("Unable to locate suitable constructor for CreateCompanyComponent class", e);
            throw new ConfigurationException(e);
        }
    }
}

3.2 Utiliser l'injecteur de Guice pour instancier nos objets

La dernière étape consiste ensuite à utiliser l'injecteur de guice en lui indiquant notre module ou nos modules de configuration. De ce fait, l'injecteur s'aura instancier tous nos objects avec toutes leur dépendances. Autrement dit, c'est à l'aide de Guice que nous assemblons les différents éléments fonctionnels de notre application. C'est avec Guice que le "cablage" des différents composants va se mettre en oeuvre.

@Slf4j
public class CompanyDataStoreApplication {
    public static void main(String[] args) {
        Injector injector = Guice.createInjector(new CreateCompanyModule());
        ICreateCompanyComponent createCompanyComponent = 
        	injector.getInstance(ICreateCompanyComponent.class);
        ICreateCompanyInputPort createCompanyInputPort = 
        	injector.getInstance(ICreateCompanyInputPort.class);
        try {
            Company company = createCompanyComponent.createCompany(createCompanyInputPort);
            log.info(company.toString());
        } catch (Exception e) {
            System.exit(-1);
        }
    }
}

4. Les annotations de Guice

4.1 @Inject

L'annotation @Inject est utilisé pour indiquer à Guice que nous souhaitons qu'il inject tout seule une valeur dans un champ ou dans paramètres d'une méthode ou les paramètres d'un constructeur. Dans l'exemple ci dessous, Guice instanciera le composant en injectant les paramètres du constructeur, pouvu que dans notre Module Guice, nous ayons créé les bons bindings

@Slf4j
public class CreateCompanyComponent implements ICreateCompanyComponent {
    private final ICreateCompanyOutputPort createCompanyOutputPort;
    
    @Inject
    public CreateCompanyComponent(ICreateCompanyOutputPort createCompanyOutputPort) {
        this.createCompanyOutputPort = createCompanyOutputPort;
    }

    @Override
    public Company createCompany(ICreateCompanyInputPort createCompanyInputPort) throws 
    ValidateCompanyException, CreateCompanyException {
        try {
            Company company = createCompanyInputPort.getCompany();
            return createCompanyOutputPort.saveCompany(company);
        } catch (ValidationException e) {
            log.warn("Company validation failed due to: {}", e.getMessage());
            throw new ValidateCompanyException(e.getMessage(), e);
        } catch (CreateCompanyException e) {
            throw e;
        } catch (Exception e) {
            log.warn("Company save failed due to: {}", e.getMessage());
            throw new CreateCompanyException(e.getMessage(), e);
        }
    }
}
@Slf4j
public class CreateCompanyModule extends AbstractModule {
    @Override
    protected void configure() {
        bind(ICreateCompanyComponent.class).to(CreateCompanyComponent.class);
        bind(ICreateCompanyOutputPort.class).to(CreateCompanyAdapter.class);
    }
}

4.2 @Provides

Cette annotation doit être utilisé au dessus d'une méthode définie dans un module Guice. Et lorsqu'une méthode est annotée avec @Provides, Guice pourra se servir de cette méthode pour créer l'instance d'un objet.

@Slf4j
public class CreateCompanyModule extends AbstractModule {
    @Override
    protected void configure() {
        try {
            bind(ICreateCompanyComponent.class).toConstructor(
            CreateCompanyComponent.class.getConstructor(ICreateCompanyOutputPort.class));
        } catch (NoSuchMethodException e) {
            log.warn("Unable to locate suitable constructor for CreateCompanyComponent class", e);
            throw new ConfigurationException(e);
        }
    }

    @Provides
    public ICreateCompanyInputPort provideCreateCompanyInputPort(){
        ConsoleInputAdapter consoleInputAdapter = new ConsoleInputAdapter(System.in, System.out);
        return consoleInputAdapter;
    }
}

4.3 @Named

Cette annotation est utilisée pour marquer/identifier une interface. En effet, imaginons que pour une meme interface vous disposiez de différentes implémentations. Et selon le besoin, vous souhaitez utiliser une implémentation spécifique. dans ces conditions, il est nécessaire d'utiliser l'annotation @Named, afin de distinguer les implémentations à utiliser, comme le montre l'exemple ci dessous.

@Slf4j
public class CreateCompanyComponent implements ICreateCompanyComponent {
    
    private final ICreateCompanyOutputPort createCompanyOutputPort;

    @Inject
    public CreateCompanyComponent(@Named("Straight") ICreateCompanyOutputPort 
    createCompanyOutputPort) {
        this.createCompanyOutputPort = createCompanyOutputPort;
    }

    @Override
    public Company createCompany(ICreateCompanyInputPort createCompanyInputPort) 
    throws ValidateCompanyException, CreateCompanyException {
        try {
            Company company = createCompanyInputPort.getCompany();
            return createCompanyOutputPort.saveCompany(company);
        } catch (ValidationException e) {
            log.warn("Company validation failed due to: {}", e.getMessage());
            throw new ValidateCompanyException(e.getMessage(), e);
        } catch (CreateCompanyException e) {
            throw e;
        } catch (Exception e) {
            log.warn("Company save failed due to: {}", e.getMessage());
            throw new CreateCompanyException(e.getMessage(), e);
        }
    }
}
@Slf4j
public class CreateCompanyModule extends AbstractModule {
    @Override
    protected void configure() {
        try {
            bind(ICreateCompanyComponent.class).toConstructor(
            CreateCompanyComponent.class.getConstructor(ICreateCompanyOutputPort.class));
            bind(ICreateCompanyOutputPort.class).
                    annotatedWith(Names.named("Straight")).toInstance(new CreateCompanyAdapter());
        } catch (NoSuchMethodException e) {
            log.warn("Unable to locate suitable constructor for CreateCompanyComponent class", e);
            throw new ConfigurationException(e);
        }
    }

    @Provides
    public ICreateCompanyInputPort provideCreateCompanyInputPort(){
        ConsoleInputAdapter consoleInputAdapter = new ConsoleInputAdapter(System.in, System.out);
        return consoleInputAdapter;
    }
}

Pour aller plus loin, vous pouvez accéder à la documentation de Guice ici

Le code source de ce tutoriel est présent sur github, à cette adresse: company-data-store

Ne pas hésiter à commenter ou à poser une question ou à demander de l'aide autours de java et des technologies connexes. Nous nous ferons un plaisir de vous répondre.

Commentaires

Posts les plus consultés de ce blog

Comment valider les données utilisateur dans votre application Java : Guide pratique avec Jakarta Bean Validation

Lorsque vous développez une application qui interagit avec un utilisateur, en lui demandant de saisir des données, il est primordial de vérifier et valider ces données avant tout traitement. Et ceci pour plusieurs raisons: vous aurez la possibilité d'éviter des traitements inutiles si les données reçues ne sont pas conformes aux données attendues et aussi cela permet de protéger votre application en cas d'attaque orchestrée par des hackers. Dans le langage java, il existe une spécification qui vous permet de valider tout type de données transmis dans un bean: Java Bean Validation (ou plus récemment: Jakarta Bean Validation). Dans ce tutoriel, au travers d'une petite application de gestion d'une bibliothèque de quartier, nous allons voir ensemble comment utiliser Jakarta Bean Validation. 1. Pourquoi Jakarta Bean Validation L'objectif de Jakarta Bean Validation est de standardiser la validation des champs au travers de specification (JSR 380). Aussi cette standardisat...

Gestion des exceptions en Java: Comprendre, Traiter et Créer

Apprenez à gérer efficacement les exceptions d'exécution en Java, améliorant ainsi la fiabilité et la stabilité de vos applications et programmes. 1. Qu'est ce qu'une exception Comme dans la plus part des languages de programmation, Java vous permet de gérer avec précision des erreurs imprévues (exceptions) qui pourraient survenir lors de l'exécution d'une instruction ou d'un traitement. Une exception est une erreur imprévue qui survient lors de l'exécution d'une instruction de votre code (de votre programme). Imaginons que vous souhaitiez faire la division de deux nombres dans votre programme: x = a / b.  Malheureusement si le diviseur b vaut zéro lors de l'exécution de votre programme, cette situation génèrera une erreur imprévue. On parlera alors d'une exception. Il faut garder à l'esprit que toute instruction dans un programme peut générer une exception (à moins que vos instructions soient des plus basiques et simples). Parfois lorsqu...