Quarkus Extension Dev Service Personnalisé - Partie II

Dans le précédent article, nous avons vu comment lancer une base de données automatiquement grâce à une extension Quarkus. Nous allons maintenant voir comment démarrer notre service de la même manière et lui faire utiliser la base de données que nous venons de déployer. Je ne reviendrai pas sur les points abordés dans le précédent article. Je vous invite donc à le lire avant de continuer.

On prend les mêmes et on recommence.

La création de notre conteneur pour notre service se passe de la même manière que la base de données. On va donc commencer par ajouter les configurations nécessaires à notre DevServicesConfig :

 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
    /**
     * The container image name to use, for container based DevServices providers.
     * If you want to use a specific version of demo, use:
     * {@code demo:<version>}.
     */
    @WithDefault("scandinave/demo")
    String imageName();

    /**
     * Optional fixed port the dev service will listen to.
     * <p>
     * If not defined, the port will be chosen randomly.
     */
    OptionalInt port();

    /**
     * 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>
     */
    @WithDefault("demo")
    String serviceName();

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

On commence par ajouter une option pour le nom de l’image docker qui servira à démarrer notre conteneur. De cette manière, l’utilisateur pourra choisir une version spécifique s’il le souhaite. Le port est par défaut aléatoire, mais pourra également être précisé. Enfin, le label appliqué au conteneur pourra aussi être personnalisé.

DemoExtensionProcessor

Dans notre DemoExtensionProcessor, nous ajoutons le code permettant d’initialiser le conteneur de notre service.

 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
  @BuildSteps(onlyIf = {GlobalDevServicesConfig.Enabled.class})
  public class DemoExtensionProcessor {
  
    /** stuff from previous article 
     * ...
     */

    private static volatile RunningDevService demoApiDevService;

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

      runningConfiguration = buildTimeConfig.devservices();

      try {
           // DemoApiRunningDevService newAPIDevService = startDemoContainer...
           DemoApiRunningDevService newAPIDevService = startDemoContainer(
                    dockerStatusBuildItem,
                    !devServicesSharedNetworkBuildItem.isEmpty(),
                    devServicesConfig.timeout.orElse(Duration.of(0, ChronoUnit.SECONDS)),
                    runningConfiguration,
                    newDatabaseDevService,
                    errors
            );
          // Get the RunningDevService instance.
          demoApiDevService = newAPIDevService.getRunningDevService();
      } catch (Throwable t) {
          throw new RuntimeException(t);
      }
      Log.info("Dev Services for Demo started.");

      devServicesResult.produce(demoApiDevService.toBuildItem());
    }
  }

Notre méthode startDemoContainer prends en plus en paramètre le résultat de l’appel à la méthode startDatabaseContainer qui contient une instance d’un DevRunningDevService. Dans ce dernier, nous avions enregistré l’URL d’accès à notre base de données. Je vous avais dit que cela servirait plus tard.

1
2
3
4
5
6
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
        )
);

startDemoContainer

 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

private DemoApiRunningDevService startDemoContainer(DockerStatusBuildItem dockerStatusBuildItem, boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, DemoDatabaseRunningDevService runningService, 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;
    }

    String dbUrl = Optional.ofNullable(runningService.getRunningDevService().getConfig()).orElseThrow(() -> new IllegalStateException("Database does not start properly")).get("url-internal");

    final Optional<ContainerAddress> maybeExistingContainer = demoAPIDevModeContainerLocator.locateContainer(
            devServicesConfig.serviceName(),
            devServicesConfig.shared(),
            LaunchMode.current());

    String imageName = devServicesConfig.imageName();
    DockerImageName dockerImageName = DockerImageName.parse(imageName).asCompatibleSubstituteFor(imageName);


    return maybeExistingContainer
            .map(existingContainer -> new DemoApiRunningDevService(new RunningDevService(DemoApiContainer.CONTAINER_NAME, existingContainer.getId(),
                    null, Map.of("url", existingContainer.getHost() + ":" + existingContainer.getPort()))))
            .orElseGet(() -> new DemoApiRunningDevService(useSharedNetwork, timeout, devServicesConfig, dbUrl, dockerImageName, runningService));
}

On commence par les mêmes vérifications concernant la présence de docker et l’activation des dev services puis nous récupérons l’URL interne docker de connexion à la base de données depuis notre instance de RunningDevService. La méthode vérifie ensuite si autre conteneur existe déjà grâce à un nouveau ContainerLocator:

1
    private static final ContainerLocator demoAPIDevModeContainerLocator = new ContainerLocator(DemoApiContainer.DEV_SERVICE_LABEL, DEMO_INTERNAL_PORT);

Si un conteneur de notre service 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é. Ce nouveau RunningDevService prend en paramètre l’URL de notre base de données.

DemoApiRunningDevService

 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
public class DemoApiRunningDevService {

    private DemoApiContainer demoContainer;
    private final DevServicesResultBuildItem.RunningDevService runningDevServiceSupplier;

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

    public DemoApiRunningDevService(DevServicesResultBuildItem.RunningDevService runningDevService) {
        runningDevServiceSupplier = runningDevService;
        demoContainer = null;
    }


    public DevServicesResultBuildItem.RunningDevService run(boolean useSharedNetwork, Duration timeout, DevServicesConfig devServicesConfig, String dbUrl, DockerImageName dockerImageName, DemoDatabaseRunningDevService runningService) {
        if (devServicesConfig.port().isPresent()) {
            try (DemoApiContainer container = new DemoApiContainer(dockerImageName, devServicesConfig.port().getAsInt(), useSharedNetwork, devServicesConfig.shared(), devServicesConfig.serviceName())) {
                demoContainer = container;
            }
        } else {
            try (DemoApiContainer container = new DemoApiContainer(dockerImageName, useSharedNetwork, devServicesConfig.shared(), devServicesConfig.serviceName())) {
                demoContainer = container;
            }
        }

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


        demoContainer.withEnv("POSTGRES_USER", devServicesConfig.db().username());
        demoContainer.withEnv("POSTGRES_PASSWORD", devServicesConfig.db().password());
        demoContainer.withEnv("DB_URL", dbUrl + "/" + devServicesConfig.db().name());
        if (runningService.getDatabaseContainer().isPresent()) { // Wait for database container to start. If container is null, it means that a database was already started so no need to wait.
            demoContainer.withNetwork(runningService.getDatabaseContainer().get().getNetwork());
            demoContainer.dependsOn(runningService.getDatabaseContainer().get());
        }

        demoContainer.start();

        return new DevServicesResultBuildItem.RunningDevService(DemoApiContainer.CONTAINER_NAME, demoContainer.getContainerId(),
                demoContainer::close, Map.of("url", demoContainer.getHost() + ":" + demoContainer.getPort()));

    }

    public Optional<GenericContainer<DemoApiContainer>> getDatabaseContainer() {
        return Optional.ofNullable(demoContainer);
    }

    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 DemoApiConteneur créée, nous le configurons en lui passant les variables d’environnement qu’il attend grâce à la méthode withEnv. Nous utilisons pour cela notre devServiceConfig pour récupérer les identifiants de connexion à la base de données puis l’URL passée en paramètre. Ensuite, nous configurons le conteneur sur le même réseau que la base de données et pour attendre que celle-ci ait fini de démarrer.

Enfin, nous retournons notre RunninDevService contenant l’ID de notre conteneur ainsi que de la configuration contenant l’URL d’accès à notre API qui nous sera également utile pour la suite.

DemoApiContainer

Il ne nous reste plus qu’à voir la classe DemoApiContainer pour finir ce second 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
66
67
68
69
70
71
72
73
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 DemoApiContainer extends GenericContainer<DemoApiContainer> {

    public static final String CONTAINER_NAME = "demo";
    public static final String DEV_SERVICE_LABEL="quarkus-dev-service-demo";
    private final Integer exposedPort;
    public static final int DEMO_INTERNAL_PORT = 8600;
    private final boolean useSharedNetwork;
    private final String serviceName;
    private String hostname;
    private final boolean sharedContainer;

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

    public DemoApiContainer(DockerImageName image, Integer exposedPort, boolean useSharedNetwork, boolean isShared, String serviceName) {
        super(image);
        this.exposedPort = exposedPort;
        this.useSharedNetwork = useSharedNetwork;
        this.sharedContainer = isShared;
        this.serviceName = serviceName;


    }

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

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

        if(this.exposedPort != null) {
          addFixedExposedPort(this.exposedPort, DEMO_INTERNAL_PORT);
          if(useSharedNetwork) {
              // expose random port for which we are able to ask Testcontainers for the actual mapped port at runtime
              // as from the host's perspective Testcontainers actually expose container ports on random host port
              addExposedPort(DEMO_INTERNAL_PORT);
          }
        } else {
            addExposedPort(DEMO_INTERNAL_PORT);
        }

        withLabel(DEV_SERVICE_LABEL, serviceName);

        // Tell the dev service how to know the container is ready
        waitingFor(Wait.forLogMessage(".*demo.*started.*", 1));
    }

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

    public int getPort() {
        if (useSharedNetwork) {
            return DEMO_INTERNAL_PORT;
        }
        if (exposedPort != null) {
            return exposedPort;
        }
        return getFirstMappedPort();
    }
}

Je ne m’attarderai pas sur cette classe qui est identique à celle de notre DemoDatabaseContainer. Seule l’attente de l’événement indiquant la fin du démarrage du processus dans le conteneur est différente. Ici, nous attendons un log dans notre application contenant les mots demo et started

Conclusion

Notre extension lance maintenant un service “dockerisé” avec sa base de données. Le tout configuré automatiquement pour fonctionner ensemble. Comme nous avions déjà inclus l’extension lors de la précédente étape, nous n’avons rien de plus à faire pour profiter de cette mise à jour.

Dans un prochain article, nous verrons comment dialoguer avec notre API grâce à l’URL sauvegardée dans notre RunningDevService et comment fournir un SDK permettant d’exploiter cette API via une simple injection de dépendances dans notre application cliente.

0%