Une Architecture Plugin Avec Java

Aujourd’hui, nous allons parler d’une fonctionnalité présente dans Java depuis la version 6 et très peu utilisée, les Service Provider Interface (SPI). Nous verrons également comment créer une architecture utilisant les plugins avec SpringBoot.

SPI, Késako ?

Derrière les SPI, il y a une réponse à un problème bien connu qui est “Comment découpler la déclaration de l’implémentation d’un service ?” Dans les langages objets, cela passe traditionnellement par une interface. Celle-ci fait office de contrat que doivent respecter les implémentations. Le code client fait appel à une interface et via un mécanisme d’injection de dépendance, l’implémentation correspondante est appelée. La beauté de la chose est que le client ne connaît que l’interface au moment de la compilation du code. Il n’y a donc pas de dépendance entre le client et l’implémentation. La conséquence est un code plus maintenable et la possibilité de remplacer une implémentation par une autre de manière transparente.

Cependant, un problème arrive rapidement lorsque l’on souhaite délivrer une application utilisant des implémentations que l’utilisateur peut créer lui-même. Dans ce cas les différentes implémentations ne peuvent pas être embarquées lors de la compilation. C’est ce problème que les SPI permettent de résoudre en Java. La possibilité d’ajouter des implémentations sous forme de Jar sans avoir à recompiler le code source de l’application.

Comment cela fonctionne ?

Pour qu’une SPI puisse fonctionner, il faut 4 composantes :

  • Une Interface déclarant le contrat à respecter.
  • Un service permettant de découvrir les implémentations de cette interface
  • Une ou plusieurs implémentations
  • Un client utilisant le service pour accéder aux fonctionnalités des implémentations.

Exemple.

Prenons pour l’exemple, la création d’un système de plugin pour l’application. Nous allons pour cela créer 3 projets maven :

  • services-spi (notre interface et le service)
  • services (les implémentations)
  • client (le consommateur de notre service)

Interface et Service

Nous allons donc commencer par créer une interface commune à tous nos plugins dans le module maven services-spi :

1
2
3
4
5
6
7
8
9
package info.techland.myapp.services.spi.pluginProvider

import java.util.List;

public interface Plugin {

    String getName();
    String getDescription();
}

Nos plugins auront juste un nom et une description. Ajoutons le service permettant d’accéder à cette interface.

 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
35
36
package info.techland.myapp.services.spi;

import info.techland.myapp.services.spi.pluginProvider.Plugin;

public class PluginService {

    private final ServiceLoader<Plugin> loader;

    private static PluginService instance = null;

    private PluginService() {
        loader = ServiceLoader.load(Plugin.class);
    }

    public static PluginService getInstance() {
        if (instance == null) {
            instance = new PluginService();
        }

        return instance;
    }

    public Optional<Plugin> getPlugin(String name) {
        return loader.stream()
                .map(ServiceLoader.Provider::get)
                .filter(plugin -> plugin.getName().equals(name))
                .findFirst();

    }

    public List<Plugin> getPlugins() {
        return loader.stream()
            .map(ServiceLoader.Provider::get)
            .toList();
    }
}

Comme vous pouvez le voir, notre service est un singleton. De cette manière, on ne recrée pas une instance de ServiceLoader à chaque appel. Ce ServiceLoader est instancié via la méthode load(Class class) et prends en paramètre notre interface. Il fournit ensuite un stream permettant d’itérer sur la liste des implémentations trouvées.

Implémentations

Créons maintenant deux implémentations différentes dans le module services :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package info.techland.myapp.services.plugins.foo

public class Foo implements Plugin {

    private final String name = "Foo";
    private final String description = "A really cool foo plugin";

    public Foo() {

    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public String getDescription() {
        return this.description;
    }
}

et

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package info.techland.myapp.services.plugins.bar

public class Bar implements Plugin {

    private final String name = "Bar";
    private final String description = "Another really cool bar plugin";

    public Bar() {

    }

    @Override
    public String getName() {
        return this.name;
    }

    @Override
    public String getDescription() {
        return this.description;
    }
}

Jusqu’ici rien de bien particulier. Nos deux plugins implémentent notre interface Plugin. Afin de permettre à Java de découvrir ces implémentations, nous allons maintenant devoir déclarer un fichier dans META-INF/services.

Ce fichier doit se nommer d’après le nom de l’interface implémentée. Dans notre cas : info.techland.myapp.services.spi.pluginProvider.Plugin

Il doit contenir la liste des implémentations disponibles. Dans notre cas :

1
2
info.techland.myapp.services.plugins.foo.Foo
info.techland.myapp.services.plugins.bar.Bar

Client

Il ne reste plus qu’à faire appel à notre service dans notre client. Nous allons utiliser un client SpringBoot pour cela. Pour pouvoir injecter notre service provider dans notre application, il suffit de définir une configuration :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
package info.techland.myapp.api

@Configuration
public class SpiConfiguration {

    @Bean
    public PluginService getPluginService() {
        return PluginService.getInstance();
    }
}

Maintenant, nous pouvons l’utiliser dans notre application Spring. Par exemple dans un controller.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package info.techland.myapp.api

@RestController
public class PluginController {

    private final PluginService pluginService;


    @Autowired
    public SearchController(PluginService pluginService) {
        this.pluginService = pluginService;
    }
    
    @GetMapping("/plugins")
    public List<PluginDTO> search() {
        return this.pluginService.getPlugins().stream().map(plugin -> new PluginDTO(plugin.getName())).toList();
    }
}

Une dernière configuration est nécessaire pour permettre à Spring de charger des Jar supplémentaires au démarrage. Il suffit de créer un fichier loader.properties à côté du fichier application.properties.

1
2
loader.path=./lib
loader.main=info.techland.myapp.api.ApiApplication

Mise en oeuvre.

Nous pouvons maintenant compiler notre code via la commande mvn clean package depuis le projet parent. Une fois cela fait, prendre le jar api/target/api-1.0.0-SNAPSHOT.jar et le placer dans un dossier de son choix.

1
2
3
mkdir ~/springboot-spi
cp ./api/target/api-1.0.0-SNAPSHOT.jar ~/sprinboot-spi
cd ~/springboot-spi

Il est ensuite possible de lancer l’application avec :

1
java -cp api-1.0.0-SNAPSHOT.jar org.springframework.boot.loader.PropertiesLauncher

La classe PropertiesLauncher permet de dire à SpringBoot de prendre en compte notre fichier loader.properties.

Si vous allez sur l’url http://localhost:8080/plugins, aucuns résultats ne devrait être affichés.

Ajoutons maintenant le jar ./services/services-1.0.0-SNAPSHOT.jar dans un dossier lib à côté du jar de notre application et relançons la commande.

1
2
3
mkdir ~/springboot-spi/lib
cp ./services/services-1.0.0-SNAPSHOT.jar ~/sprinboot-spi/lib
cd ~/springboot-spi

Résultat :

1
2
3
4
[
  {"name":"Foo","description":"A really cool foo plugin","links":[]},
  {"name":"Bar","description":"Another really cool bar plugin","links":[]}
]

L’appel à notre endpoint nous retourne la liste des deux plugins. Nous venons d’ajouter deux implémentations à notre application sans avoir besoin de la recompiler.

Conclusion

Nous venons de voir comment créer une application extensible avec Java. Il s’agit d’un outil très puissant pour laisser à l’utilisateur le choix des implémentations à utiliser.

Le code source est disponible ici : https://github.com/scandinave/springboot-spi

0%