Quarkus Extension Dev Service Personnalisé - Partie I

Aujourd’hui, on s’attaque à un gros morceau avec un cas d’usage non documenté par Quarkus, mais qui est vraiment très pratique. Quarkus propose des extensions avec des “dev services”. Ces extensions, une fois incluses dans votre projet, lancent des services automatiquement au démarrage du projet en mode dev. Nous allons voir comment créer notre propre “dev service” pour nos applications Quarkus. L’idée est de fournir une extension pour chacun de nos services afin de les rendre utilisables facilement par des tiers.

Cas d’usage

Imaginons une entreprise avec plusieurs projets et plusieurs équipes. Il arrive souvent que des services soient communs à plusieurs projets. Deux solutions sont généralement mises en place.

  • Une instance de dev partagée
  • Un docker compose à lancer en parallèle du projet en mode dev.

La première solution implique une administration compliquée avec une gestion des données entres plusieurs équipes. Si une équipe casse le service, cela impact les autres équipes. Lors d’une mise à jour, l’ensemble des équipes sont également impactées.

La seconde solution est plus flexible, les services s’exécutant sur le poste du développeur. Cependant, cela implique une étape supplémentaire au lancement du projet en mode dev. Cela pose aussi la question de la mise à jour du docker compose.

Un “dev service” permet de résoudre ces problèmes. Distribuer sous la forme d’une extension Maven, ils peuvent être maintenus à jour via l’utilisation d’un bom Maven. Quarkus les démarrent automatiquement au démarrage du projet, éliminant le besoin de gérer manuellement des conteneurs docker.

L’extension

Notre extension devra pouvoir démarrer un service Quarkus avec sa base de données. L’extension devra permettre d’exécuter le service avec des paramètres par défaut et de permettre de les personnaliser.

Commençons par créer l’extension via la commande :

1
2
3
mvn io.quarkus.platform:quarkus-maven-plugin:3.14.4:create-extension -N \
    -DgroupId=info.techland \
    -DextensionId=demo-extension

Cela va nous créer une extension avec l’arborescence suivante :

demo-extension
|__ deployment
|__ integration-tests
|__ runtime
|__ pom.xml

À la fin l’architecture de notre extension ressemblera au schéma suivant :

Schéma de notre extension pour la base de données
Schéma de notre extension pour la base de données

Création de la base de données de notre dev service

Notre service a besoin d’une base de données pour fonctionner. Nous allons donc configurer notre extension pour lancer une base de données dans un conteneur Docker. Avant cela commençons par créer quelques configurations pour notre extension afin de la rendre personnalisable.

DemoExtensionBuildTimeConfig

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package info.techland.demo.extension.deployment;

import io.quarkus.runtime.annotations.ConfigPhase;
import io.quarkus.runtime.annotations.ConfigRoot;
import io.smallrye.config.ConfigMapping;


@ConfigMapping(prefix = "quarkus.demo")
@ConfigRoot(phase = ConfigPhase.BUILD_TIME)
public interface DemoExtensionBuildTimeConfig {

    /**
     * Dev services configuration.
     */
    DevServicesConfig devservices();

}

Cette classe enregistre le point d’entrée de la configuration de notre dev service. Notre configuration sera accessible dans l’application.properties sous la clé quarkus.demo.

DevServiceConfig

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package info.techland.demo.extension.deployment;

import java.util.Map;

import io.quarkus.runtime.annotations.ConfigGroup;
import io.smallrye.config.WithDefault;

@ConfigGroup
public interface DevServicesConfig {

    /**
     * If DevServices has been explicitly enabled or disabled. DevServices is generally enabled
     * by default, unless there is an existing configuration present.
     * <p>
     * When DevServices is enabled Quarkus will attempt to automatically configure and start
     * a demo instance when running in Dev or Test mode and when Docker is running.
     */
    @WithDefault("true")
    boolean enabled();


    /**
     * Indicates if the demo server managed by Quarkus Dev Services is shared.
     * When shared, Quarkus looks for running containers using label-based service discovery.
     * If a matching container is found, it is used, and so a second one is not started.
     * Otherwise, Dev Services for demo starts a new container.
     * <p>
     * The discovery uses the {@code quarkus-dev-service-redis} label.
     * The value is configured using the {@code service-name} property.
     * <p>
     * Container sharing is only used in dev mode.
     */
    @WithDefault("true")
    boolean shared();

    /**
     * The value of the {@code quarkus-dev-service-demo} label attached to the started container.
     * This property is used when {@code shared} is set to {@code true}.
     * In this case, before starting a container, Dev Services for demo looks for a container with the
     * {@code quarkus-dev-service-demo} label
     * set to the configured value. If found, it will use this container instead of starting a new one. Otherwise, it
     * starts a new container with the {@code quarkus-dev-service-demo} label set to the specified value.
     * <p>
     * This property is used when you need multiple shared demo servers.
     */
    @WithDefault("demo")
    String serviceName();

    /**
     * Environment variables that are passed to the container.
     */
    Map<String, String> containerEnv();

    /**
     * Configuration for the postgresql database use by demo service.
     * @return The database configuration
     */
    DatabaseConfig db();
}

Cette classe configure notre dev service avec l’accès à la configuration de notre base de données du dev service. La configuration de notre base de données sera accessible sous la clé quarkus.dev.db. Chaque entrée à une valeur par défaut afin de permettre l’utilisation de l’extension sans configuration particulière.

DatabaseConfig

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package info.techland.demo.extension.deployment;

import io.quarkus.runtime.annotations.ConfigGroup;
import io.smallrye.config.WithDefault;

import java.util.Map;
import java.util.OptionalInt;

@ConfigGroup
public interface DatabaseConfig {

    /**
     * The database password used by the demo service.
     */
    @WithDefault("demo")
    String username();

    /**
     * The database password used by the demo service.
     */
    @WithDefault("demo")
    String password();

    /**
     * The database name used by the demo service
     */
    @WithDefault("demo")
    String name();

    /**
     * The number of reactive connection to use.
     */
    @WithDefault("20")
    int reactiveMaxSize();

    /**
     * The port used by the database.
     * @return The database port
     */
    OptionalInt port();

    /**
     * Name of the postgresql image to use to start the database
     * @return The image name.
     */
    @WithDefault("docker.io/postgres")
    String imageName();

    /**
     * Environment variables that are passed to the container.
     */
    Map<String, String> containerEnv();
}

La configuration de la base de données contient les informations de connexion à la base de données PostgreSQL utilisée par défaut. Encore une fois, nous privilégions les conventions sur la configuration en appliquant des valeurs par défaut.

DemoExtensionProcessor

Pour demander à Quarkus de démarrer un conteneur, nous allons devoir configurer notre DemoExtensionProcessor. Commençons par ajouter une méthode createContainer qui fera office de @BuildStep pour démarrer et configurer notre conteneur.

 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
37
38
39
40
  @BuildSteps(onlyIf = {GlobalDevServicesConfig.Enabled.class})
  public class DemoExtensionProcessor {
  
    /**
     * The current configuration. either from running container instance or an instance that is about to start.
     */
    private static volatile DevServicesConfig runningConfiguration;
    private static volatile RunningDevService demoDatabaseDevService;

    @BuildStep
    public void createContainer(DockerStatusBuildItem dockerStatusBuildItem,
                                List<DevServicesSharedNetworkBuildItem> devServicesSharedNetworkBuildItem,
                                DemoBuildTimeConfig buildTimeConfig,
                                GlobalDevServicesConfig devServicesConfig,
                                BuildProducer<DevServicesResultBuildItem> devServicesResult) {

      runningConfiguration = buildTimeConfig.devservices();

      try {
          List<String> errors = new ArrayList<>();
          DemoDatabaseRunningDevService newDatabaseDevService = startDatabaseContainer(
                  dockerStatusBuildItem,
                  !devServicesSharedNetworkBuildItem.isEmpty(),
                  devServicesConfig.timeout.orElse(Duration.of(0, ChronoUnit.SECONDS)),
                  runningConfiguration,
                  errors
          );


          // Get the RunningDevService instance.
          demoDatabaseDevService = newDatabaseDevService.getRunningDevService();
      } catch (Throwable t) {
          throw new RuntimeException(t);
      }
      Log.info("Dev Services for Demo started.");

      // Call to toBuildItem method to get the DevServicesResultBuildItem need by the BuildStep producer.
      devServicesResult.produce(demoDatabaseDevService.toBuildItem());
    }
  }

Notre méthode se voit injecter les builds items suivants :

  • DockerStatusBuildItem : Permet de récupérer le statut d’une instance docker
  • DevServicesSharedNetworkBuildItem : Détermine si les conteneurs sont démarrés sur un réseau partagé
  • DemoBuildTimeConfig : La configuration de notre extension
  • GlobalDevServicesConfig : La configuration globale des devservices fournie par Quarkus
  • DevServicesResultBuildItem : Le buildItem à produire pour référencer notre conteneur auprès de Quarkus

Notre méthode appelle ensuite la méthode startDatabaseContainer chargée de créer une instance de DemoDatabaseRunningDevService. Cette dernière est en charge de fournir un RunningDevService, via devServicesResult.produce(demoDatabaseDevService.toBuildItem()), qui sera retourné par notre @BuildStep pour demander à Quarkus de lancer un conteneur.

startDatabaseContainer

 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

private DemoDatabaseRunningDevService startDatabaseContainer(DockerStatusBuildItem dockerStatusBuildItem, boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, List<String> errors) {
    if (!devServicesConfig.enabled()) {
        // explicitly disabled
        Log.debug("Not starting Dev Services for Demo as it has been disabled in the config");
        return null;
    }


    if (!dockerStatusBuildItem.isDockerAvailable()) {
        Log.warn("Please get a working docker instance");
        errors.add("Please get a working docker instance");
        return null;
    }

    final Optional<ContainerAddress> maybeExistingContainer = demoDBDevModeContainerLocator.locateContainer(
            devServicesConfig.serviceName() + "_db",
            devServicesConfig.shared(),
            LaunchMode.current());

    // Get the image name from the configuration.
    String imageName = devServicesConfig.db().imageName();
    DockerImageName dockerImageName = DockerImageName.parse(imageName).asCompatibleSubstituteFor(imageName);

    return maybeExistingContainer
            .map(existingContainer -> new DemoDatabaseRunningDevService(new RunningDevService(DemoDatabaseContainer.CONTAINER_NAME, existingContainer.getId(),
                    null, Map.of("url", existingContainer.getHost() + ":" + existingContainer.getPort()))))
            .orElseGet(() -> new DemoDatabaseRunningDevService(DemoDatabaseContainer.CONTAINER_NAME, useSharedNetwork, timeout, devServicesConfig,
                    dockerImageName, devServicesConfig.serviceName() + "_db"));
}

On commence par quelques vérifications. Si les devservices sont désactivés de manière globale, on annule le lancement et si docker n’est pas disponible, on retourne une erreur. La méthode vérifie ensuite si un autre conteneur existe déjà. Cela peut être le cas, si deux services Quarkus utilise la même extension. Cela se fait grâce à un ContainerLocator:

1
  private static final ContainerLocator demoDBDevModeContainerLocator = new ContainerLocator(DemoDatabaseContainer.DEV_SERVICE_LABEL, POSTGRES_INTERNAL_PORT);

Si un conteneur de notre base de données est déjà lancé, alors la méthode retourne un RunningDevService avec les informations du conteneur existant. Sinon un nouveau RunningDevService pour le nouveau conteneur est retourné.

DemoDatabaseRunningDevService

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class DemoDatabaseRunningDevService {

    private DemoDatabaseContainer databaseContainer;
    private final DevServicesResultBuildItem.RunningDevService runningDevServiceSupplier;

    public DemoDatabaseRunningDevService(String feature, boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, DockerImageName
            dockerImageName, String serviceName) {
        runningDevServiceSupplier = this.run(feature, useSharedNetwork, timeout, devServicesConfig, dockerImageName, serviceName);
    }

    public DemoDatabaseRunningDevService(DevServicesResultBuildItem.RunningDevService runningDevService) {
        runningDevServiceSupplier = runningDevService;
        databaseContainer = null;
    }

    public DevServicesResultBuildItem.RunningDevService  run(String feature, boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, DockerImageName
            dockerImageName, String serviceName) {

        if(devServicesConfig.db().port().isPresent()) {
            databaseContainer = new DemoDatabaseContainer(dockerImageName, devServicesConfig.db().port().getAsInt(), useSharedNetwork, serviceName);
        } else {
            databaseContainer = new DemoDatabaseContainer(dockerImageName, useSharedNetwork, serviceName);
        }

        Optional.ofNullable(timeout).ifPresent(databaseContainer::withStartupTimeout);
        databaseContainer.withEnv(devServicesConfig.db().containerEnv());


        databaseContainer.withEnv("POSTGRES_USER", devServicesConfig.db().username());
        databaseContainer.withEnv("POSTGRES_PASSWORD", devServicesConfig.db().password());
        databaseContainer.withEnv("POSTGRES_DB",devServicesConfig.db().name());
        databaseContainer.withNetwork(useSharedNetwork ? Network.SHARED : Network.newNetwork());
        databaseContainer.start();

        return new DevServicesResultBuildItem.RunningDevService(feature, databaseContainer.getContainerId(),
                databaseContainer::close,
                Map.of(
                    "url", databaseContainer.getHost() + ":" + databaseContainer.getPort(),
                    "url-internal", databaseContainer.getContainerName().substring(1) + ":" + POSTGRES_INTERNAL_PORT
                )
        );

    }

    public Optional<GenericContainer<DemoDatabaseContainer>> getDatabaseContainer() {
        return Optional.ofNullable(databaseContainer);
    }

    public DevServicesResultBuildItem.RunningDevService getRunningDevService() {
        return runningDevServiceSupplier;
    }
}

Notre classe définit deux constructeurs. Un renvoyant juste le RunningDevService existant dans le cas où le conteneur existe déjà. Le deuxième, créant un nouveau RunningDevService. Notre méthode run commence par vérifier si un port est précisé dans la configuration du dev-service. Si c’est le cas, notre conteneur sera démarré sur ce port, sinon, un port aléatoire disponible sera alloué (comportement par défaut).

Une fois l’instance de notre DemoDatabaseConteneur créée, nous le configurons en lui passant les variables d’environnement qu’il attend grâce à la méthode withEnv. Vient ensuite la configuration du réseau docker utilisé. Dans le cas où l’utilisateur à expressement spécifié que les conteneurs devait être attachés au réseau partagé créé par TestContainer, nous utilisons la méthode withNetwork avec la valeur Network.SHARED. Dans le cas contraire, un nouveau réseau est créé pour le conteneur.

Enfin, nous retournons notre RunninDevService contenant l’ID de notre conteneur ainsi que de la configuration qui nous sera utile par la suite.

DemoDatabaseContainer

Il ne nous reste plus qu’à voir la classe DemoDatabaseContainer pour finir ce premier post sur la création d’un DevService avec Quarkus.

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import io.quarkus.devservices.common.ConfigureUtil;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;

public class DemoDatabaseContainer extends GenericContainer<DemoDatabaseContainer> {


    public static final String CONTAINER_NAME="demo-db";
    private static final String DEV_SERVICE_LABEL="quarkus-dev-service-db";
    private final String serviceName;
    private final Integer fixedExposedPort;
    public final static int POSTGRES_INTERNAL_PORT = 5432;
    private final boolean useSharedNetwork;
    private String hostname;

    public DemoDatabaseContainer(DockerImageName image, boolean useSharedNetwork, String serviceName) {
        this(image, null, useSharedNetwork, serviceName);
    }

    public DemoDatabaseContainer(DockerImageName image, Integer exposedPort, boolean useSharedNetwork, String serviceName) {
        super(image);
        this.fixedExposedPort = exposedPort;
        this.useSharedNetwork = useSharedNetwork;
        this.serviceName = serviceName;
    }

    @Override
    protected void configure() {
        super.configure();

        if (this.useSharedNetwork) {
            hostname = ConfigureUtil.configureSharedNetwork(this, "demo");
            return;
        }

        if(this.fixedExposedPort != null) {
          addFixedExposedPort(this.fixedExposedPort, POSTGRES_INTERNAL_PORT);
        } else {
            addExposedPort(POSTGRES_INTERNAL_PORT);
        }

        withLabel(DEV_SERVICE_LABEL, serviceName);

        super.setWaitStrategy(Wait.forListeningPorts(POSTGRES_INTERNAL_PORT));
    }

    @Override
    public String getHost() {
        if(this.useSharedNetwork) {
            return hostname;
        }
        return super.getHost();
    }

    public int getPort() {
        if (useSharedNetwork) {
            return POSTGRES_INTERNAL_PORT;
        }
        if (fixedExposedPort != null) {
            return fixedExposedPort;
        }
        return getFirstMappedPort();
    }
}

Cette classe étend de GenericContainer et contient la configuration utilisée par TestContainer pour démarrer notre conteneur Docker. Dans la méthode configure, on commence par regarder si le conteneur doit utiliser le Shared Network. Si c’est le cas ConfigureUtil.configureSharedNetwork se charge de tout configurer correctement. Dans le cas contraire, on configure le port (fixe ou aléatoire), on ajoute un label et on précise sur quel port écouter pour savoir quand le service a terminé de démarrer.

Conclusion

Notre extension de base de données est maintenant fonctionnelle. Cette dernière fournit une implémentation d’un GénericContainer au travers d’un RunningDevService à notre DemoExtensionProcessor. Le tout est personnalisable via la configuration classique de Quarkus. Il nous suffit d’inclure l’extension dans une application quarkus via un import Maven pour avoir la base de données démarrée en parallèle.

Dans un prochain article, nous verrons comment lancer un service dans son propre conteneur utilisant cette base de données pour exposer une API à notre application Quarkus.

0%