Inversion de contrôle en Java

Romain Guy (Gfx)

Depuis l'avènement de la programmation orientée objet, les développeurs imaginent et implémentent des composants logiciels réutilisables. Les différentes techniques employées jusqu'à aujourd'hui ne sont malheureusement pas toujours parfaites.

- Titre : Inversion de contrôle en Java

- Auteur : Romain Guy (Gfx)

- Parution : 29 mai 2007

- Mise à jour : 8 novembre 2019

- Licence : Cette création est mise à disposition sous un contrat Creative Commons (Paternité - Partage des Conditions Initiales à l'Identique).

Sommaire

- I. Introduction

- II. Conception par abstraction

- III. L'inversion de contrôle

- IV. Injection par mutateur avec Spring

- V. Injection par constructeur avec PicoContainer

- VI. Screenshots

I - Introduction

L'univers Java voit souvent apparaître de nouvelles technologies favorisant la conception d'architectures logicielles. Depuis quelques années, de nombreux développeurs s'intéressent à l'inversion de contrôle, ou IoC pour Inversion of Control. L'inversion de contrôle figure une nouvelle approche de la programmation de services et de composants. Pour utiliser cette technique, vous devez avoir recours à un conteneur d'inversion de contrôle comme Hivemind, PicoContainer ou Spring. Un conteneur d'IoC peut être identifié par trois caractéristiques majeures : il contient des objets, il contrôle la création de ces objets et il résout les dépendances entre les objets. De par sa nature le conteneur gère le cycle de vie de ces objets. Vous n'avez pas à créer les instances ni à libérer les ressources. Avant de nous intéresser à des exemples d'utilisation de ces conteneurs, nous allons voir l'intérêt de l'inversion de contrôle par rapport aux solutions existantes.

II - Conception par abstraction

Pour comprendre l'inversion de contrôle, nous allons prendre l'exemple d'un composant permettant de trouver des livres dans une bibliothèque. L'utilisateur pourra par exemple demander tous les livres écrits par John Grisham. Le terme composant désigne, en programmation orientée objet, un ensemble de classes formant une entité indépendante et réutilisable sans en modifier le code source. Nous utiliserons également le terme service, qui désigne un composant distant, manipulé par un système de messages ou par RPC par exemple. Notre composant bibliothèque comprendra donc au minimum une classe Bookshelf contenant pour sa part la méthode getBooksByAuthor(String author) comme le montre le listing 1. Le code présenté n'est malheureusement pas très souple puisqu'il repose sur la seule implémentation de la méthode readBooks() pour lire une liste de livres. Nous aimerions que notre bibliothèque intelligente puisse rechercher des livres dans une source de données quelconque, que ce soit un fichier CSV ou un fichier XML par exemple. Il suffit pour cela de remplacer l'appel à readBooks() par la ligne suivante :

List<Book> books = importer.readBooks();

Dans ce cas, nous invoquons la méthode readBooks() de l'objet importé qui est une instance de l'interface IBookImporter décrite dans le listing 2. Puisque nous utilisons à présent une interface pour s'abstraire de la source de données, la bibliothèque doit créer une instance d'une classe concrète, par exemple XMLBookImporter. Cette dernière classe est décrite dans le listing 3. Une manière naïve de créer l'instance serait de procéder de cette manière :

public Bookshelf(String bookshelf) {
this.importer = new XMLBookImporter(bookshelf);
}

Cette solution fonctionne, mais ne permet pas de substituer aisément l'import de livres depuis un fichier au format CSV, ou autre à l'import XML. Pour pallier ce problème, il est possible d'utiliser un singleton avec la classe DefaultBookImporter définie dans le listing 4 :

this.importer = DefaultBookImporter.instance();

Le choix d'un singleton permet à présent de modifier l'implémentation de l'import de livres de manière générale. Malheureusement, ce système ne permet d'utiliser qu'une seule implémentation à la fois. Cela peut poser des problèmes si votre application permet de créer plusieurs bibliothèques provenant de sources différentes. Pour contourner cet écueil, les développeurs et concepteurs utilisent généralement un design pattern appelé fabrique. Le listing 5 propose un exemple de fabrique pour notre exemple que nous pourrions utiliser de cette manière :

this.importer = BookImporterFactory.getImporter(sourceName);

La fabrique proposée en exemple analyse le nom de la source de données pour déterminer la meilleure classe concrète à utiliser. L'utilisation d'une fabrique constitue une amélioration considérable par rapport au singleton, mais certains problèmes subsistent. Il est notamment envisageable que la seule analyse du nom de la source ne suffise pas à définir l'implémentation adéquate. Votre composant souffrira alors de limitations qui pourront conduire à la modification de son code lorsqu'il sera réutilisé dans un autre projet. La fabrique impose également au développeur de gérer lui-même toutes les dépendances entre les objets. Une dernière solution pour découpler l'interface de programmation de l'implémentation consiste enfin à utiliser un service de recherche comme JNDI (Java Naming and Directory Interface) :

this.importer = (IBookImporter) context.lookup("bookImporter");

Un tel mécanisme de recherche nécessite malheureusement de truffer votre code de chaînes de caractères « magiques », utilisées pour identifier les objets à rechercher. Pour changer d'objet, vous devrez donc rechercher une chaîne de caractères particulière dans tous vos fichiers source ou utiliser une classe contenant des constantes relatives au mécanisme de recherche. Cette solution, comme les précédentes, n'est donc pas totalement satisfaisante.

III - L'inversion de contrôle

Comme nous le savons à présent, les conteneurs d'inversion de contrôle prennent en charge le cycle de vie des objets ainsi que leurs dépendances. Avant de continuer, nous allons donc modifier notre bibliothèque pour faire apparaître clairement sa dépendance vis-à-vis d'IBookImporter. Son constructeur devient donc le suivant :

public Bookshelf(IBookImporter importer)

Nous pouvons à présent créer un conteneur d'IoC, enregistrer nos services auprès de celui-ci puis demander une référence au service qui nous intéresse. Pour nous la donner, le conteneur créera les instances de la classe appropriée ainsi que celles de ses dépendances. Dans notre exemple, nous demanderons le service Bookshelf. Le conteneur essayera alors de créer son instance avant de constater qu'elle dépend d'IBookImporter. Il va donc rechercher une référence à IBookImporter parmi ses services puis l'injecter dans Bookshelf. Cette injection peut être réalisée par appel du constructeur ou du mutateur approprié. Cela signifie concrètement que l'ajout de dépendances dans votre architecture ne vous demandera aucun effort. Par exemple, si vous décidez que votre bibliothèque doit également dépendre d'un IAddressBook pour gérer les emprunteurs des livres, il vous suffira de modifier le constructeur de Bookshelf :

public Bookshelf(IBookImporter importer, IAddressBook addressBook)

À condition d'enregistrer le service IAddressBook auprès du conteneur d'IoC, vous n'aurez absolument aucun changement à apporter à votre code source. La principale difficulté de l'inversion de contrôle consiste à choisir un conteneur adapté à vos besoins. Chacun possède des caractéristiques particulières, mais ils diffèrent principalement par le type d'inversion de contrôle géré, il en existe trois, et par la méthode de configuration. Les types d'IoC sont appelés Type 1 (injection d'interface), Type 2 (injection par mutateur) et Type 3 (injection par constructeur). Nous ne nous intéresserons qu'aux Type 2 et 3, les plus répandus. La configuration des services et des dépendances peut quant à elle se faire par le code ou par l'entremise d'un fichier de configuration, généralement du XML. Malgré les débats enflammés à ce sujet, il n'existe pas de type d'IoC ni de méthode de configuration supérieure aux autres, il s'agit avant tout d'une question de goût personnel.

IV - Injection par mutateur avec Spring

Spring est un framework généraliste principalement utilisé pour le développement d'applications J2EE. Il comprend par exemple des couches de transaction, de persistance ou d'abstraction pour JDBC. Spring comprend notamment un conteneur d'inversion de contrôle appelé ApplicationContext. Fidèle à ses origines J2EE, ce framework utilise la terminologie des JavaBeans et des EJB plutôt que des composants et des services. Il gère enfin les IoC de Type 2 et de Type 3 et permet de configurer les dépendances non seulement avec du code, mais également avec des fichiers de configuration. Leur utilisation conjointe avec une IoC de Type 2 semble être le choix favori des auteurs de Spring. Pour utiliser Spring IoC, vous devez tout d'abord récupérer les archives spring.jar et spring-context.jar de la distribution standard. Ces deux bibliothèques nécessitent en outre log4j-1.2.9.jar et commons-logging.jar que vous trouverez dans le dossier lib/ de Spring. Vous devez enfin importer les packages org.springframework.context et org.springframework.context.support dans votre code. La création et l'utilisation du conteneur sont alors très simples :

ApplicationContext context = new FileSystemXmlApplicationContext("spring-conf.xml");
Bookshelf shelf = (Bookshelf) context.getBean("Bookshelf");

Dans cet exemple, nous utilisons la configuration contenue dans le fichier spring-conf.xml, qui se trouve dans le listing 7. Chaque composant, ou service, que nous souhaitons définir dans le conteneur est déclaré ici comme un bean. L'attribut id désigne la clé caractéristique du bean utilisée pour en obtenir une instance à partir d'un ApplicationContext. Bien que Spring gère l'injection par constructeur, nous utilisons ici l'injection par mutateur avec la balise <property> dont le contenu peut être une référence à un autre composant. Cette méthode impose de déclarer soi-même l'ensemble des dépendances ce qui peut devenir rapidement fastidieux. Fort heureusement, Sprint permet de créer les dépendances automatiquement grâce à l'attribut autowire :

<bean id="Bookshelf" class="Bookshelf" autowire="byType" />

Les beans possèdent de nombreux attributs que vous pouvez manipuler pour gérer leur cycle de vie, leurs parents, ainsi que d'autres propriétés avancées. Vous pouvez par exemple utiliser singleton="false" pour qu'une nouvelle instance du bean soit renvoyée à chaque appel de getBean(). Spring est un excellent conteneur d'IoC qui trouvera parfaitement sa place dans vos projets J2EE.

V - Injection par constructeur avec PicoContainer

PicoContainer est un conteneur d'IoC très léger, moins de 80 ko, capable de gérer les Type 2 et 3 ainsi que de résoudre les dépendances cycliques ou en graphes. Il propose en outre un support partiel du cycle de vie des objets à travers les interfaces Startable et Disposable. Par exemple, en implémentant Disposable et sa méthode dispose() vous pourrez savoir à quel moment votre objet est libéré par PicoContainer. Vous pouvez également vous intéresser à NanoContainer, une version enrichie de ce conteneur. Son utilisation est extrêmement simple ainsi qu'en témoigne le listing 6. La méthode configureContainer() crée un nouveau conteneur en utilisant l'implémentation par défaut. Nous enregistrons ensuite nos deux services, Bookshelf et IBookImporter, en invoquant la méthode registerComponentImplementation(). Pour enregistrer un service simple comme Bookshelf, il suffit de donner la classe du service. Le cas d'IBookImporter est un peu plus compliqué puisque nous devons enregistrer des implémentations de l'interface et non l'interface elle-même. Dans ce cas, le premier paramètre définit une clé identifiant le composant de manière unique, tandis que le deuxième paramètre désigne la classe concrète à utiliser. Enfin, le dernier paramètre permet de passer des données au constructeur, comme ici le nom du fichier XML à ouvrir.
Les conteneurs d'inversion de contrôle sont simples à utiliser et permettent de découpler efficacement votre code. Les différentes philosophies proposées, le type d'injection et la méthode de configuration doivent être étudiés convenablement avant de faire un choix. Par exemple, une configuration de dépendances en XML est très pratique pour substituer une implémentation à une autre sans recompiler votre projet. Cette configuration rend néanmoins particulièrement difficile l'utilisation du refactoring au sein d'un IDE comme Eclipse ou IntelliJ IDEA. Quel que soit votre choix, vous pourrez créer et réutiliser des composants plus facilement et plus rapidement pour tous vos projets à venir.

listing 1

public class Bookshelf {
public List<Book> readBooks() {
// lit une liste de livres
}

public Book[] getBooksByAuthor(String author) {
List<Book> books = readBooks();
List<Book> results = new ArrayList<Book>();
for (Book b: books) {
if (author.equals(b.getAuthor())) {
results.add(b);
}
}
return (Book[]) results.toArray(new Book[results.size()]);
}
}

listing 2

public interface IBookImporter {
public List<Book> readBooks();
}

listing 3

public class XMLBookImporter implements IBookImporter {
private String sourceName;

public XMLBookImporter(String sourceName) {
this.sourceName = sourceName;
}

public List<Book> readBooks() {
// parcourir le fichier XML
}
}

listing 4

public class DefaultBookImporter {
private static IBookImporter importer = new XMLBookImporter();

public static IBookImport instance() {
return importer;
}
}

listing 5

public class BookImporterFactory {
public static IBookImporter getImporter(String sourceName) {
IBookImporter importer;
if (sourceName.startsWith("jdbc:")) {
importer = new JDBCBookImporter(sourceName);
} else if (sourceName.endsWith(".xml")) {
importer = new XMLBookImporter(sourceName);
} else {
importer = new CSVBookImporter(sourceName);
}
return importer;
}
}

listing 6

public class BookshelfTest {
private MutablePicoContainer configureContainer() {
MutablePicoContainer pico = new DefaultPicoContainer();
Parameter[] importerParams = { new ConstantParameter("books.xml") };
pico.registerComponentImplementation(IBookImporter.class, XMLBookImporter.class, importerParams);
pico.registerComponentImplementation(Bookshelf.class);
return pico;
}

public void test() {
MutablePicoContainer pico = configureContainer();
Bookshelf shelf = (Bookshelf) pico.getComponentInstance(Bookshelf.class);
Book[] books = shelf.getBooksByAuthor("John Grisham");
for (Book b: books) {
System.out.println(b);
}
}
}

listing 7

<?xml version="1.0"?>
<!DOCTYPE beans SYSTEM "spring-beans.dtd" >
<beans>
<bean id="Bookshelf" class="Bookshelf">
<property name="importer">
<ref local="BookImporter" />
</property>
</bean>
<bean id="BookImporter" class="XMLBookImporter">
<property name="sourceName">
<value>books.xml</value>
</property>
</bean>
</beans>

VI - Screenshots

Schéma 1. Avec une dépendance directe, votre code n'est ni réutilisable ni interchangeable.

Schéma 2. L'utilisation d'une interface permet d'abstraire la dépendance et de modifier l'implémentation facilement.

Schéma 3. Le design pattern singleton permet de modifier l'utilisation d'une implémentation globalement.

Schéma 4. La fabrique sert à choisir l'implémentation la plus appropriée à une tâche donnée.

Schéma 5. Un conteneur d'IoC permet d'inverser le contrôle des dépendances et de rendre le code modulable, interchangeable et réutilisable.

L'ouvrage Better, Faster, Lighter Java explique comment tirer parti du framework Spring pour créer de meilleures applications J2EE.

Hivemind est un conteneur d'IoC appartenant au fameux projet Jakarta de la fondation Apache.

PicoContainer a été porté pour Ruby, PHP et la plateforme .NET.

L'inversion de contrôle poursuit certaines idées développées par le Gang of Four dans son ouvrage sur les design patterns.

L'inversion de contrôle peut également être réalisée par injection de dépendance grâce à la programmation orientée aspect.