Today, we’re tackling a major topic with a use case that is not documented by Quarkus but is extremely useful. Quarkus offers extensions with “dev services.” Once included in your project, these extensions automatically launch services when the project starts in dev mode. We will see how to create our own “dev service” for Quarkus applications. The idea is to provide an extension for each of our services to make them easily usable by others.
Use Case
Imagine a company with multiple projects and several teams. It’s common for services to be shared between different projects. Two solutions are usually implemented:
A shared dev instance
A Docker Compose to be run alongside the project in dev mode.
The first solution involves complicated administration with data management across different teams. If one team breaks the service, it impacts the others. During an update, all teams are affected as well.
The second solution is more flexible, as the services run on the developer’s machine. However, it adds an extra step when starting the project in dev mode. It also raises the issue of updating the Docker Compose.
A “dev service” helps solve these problems. Distributed as a Maven extension, they can be kept up to date using a Maven BOM. Quarkus starts them automatically when the project launches, eliminating the need to manually manage Docker containers.
The Extension
Our extension will need to start a Quarkus service with its database. The extension should allow the service to start with default parameters and make them customizable.
Let’s start by creating the extension with the following command:
At the end, the architecture of our extension will look like the following diagram:
Creating the Database for Our Dev Service
Our service needs a database to function. So we will configure our extension to launch a database in a Docker container. Before that, let’s create some configurations for our extension to make it customizable.
packageinfo.techland.demo.extension.deployment;importio.quarkus.runtime.annotations.ConfigPhase;importio.quarkus.runtime.annotations.ConfigRoot;importio.smallrye.config.ConfigMapping;@ConfigMapping(prefix="quarkus.demo")@ConfigRoot(phase=ConfigPhase.BUILD_TIME)publicinterfaceDemoExtensionBuildTimeConfig{/**
* Dev services configuration.
*/DevServicesConfigdevservices();}
This class registers the entry point of our dev service configuration. Our configuration will be accessible in the application.properties file under the quarkus.demo key.
packageinfo.techland.demo.extension.deployment;importjava.util.Map;importio.quarkus.runtime.annotations.ConfigGroup;importio.smallrye.config.WithDefault;@ConfigGrouppublicinterfaceDevServicesConfig{/**
* 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")booleanenabled();/**
* 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")booleanshared();/**
* 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")StringserviceName();/**
* 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
*/DatabaseConfigdb();}
This class configures our dev service with access to the database configuration of the dev service. The database configuration will be accessible under the quarkus.dev.db key. Each entry has a default value to allow the extension to be used without particular configuration.
packageinfo.techland.demo.extension.deployment;importio.quarkus.runtime.annotations.ConfigGroup;importio.smallrye.config.WithDefault;importjava.util.Map;importjava.util.OptionalInt;@ConfigGrouppublicinterfaceDatabaseConfig{/**
* The database password used by the demo service.
*/@WithDefault("demo")Stringusername();/**
* The database password used by the demo service.
*/@WithDefault("demo")Stringpassword();/**
* The database name used by the demo service
*/@WithDefault("demo")Stringname();/**
* The number of reactive connection to use.
*/@WithDefault("20")intreactiveMaxSize();/**
* The port used by the database.
* @return The database port
*/OptionalIntport();/**
* Name of the postgresql image to use to start the database
* @return The image name.
*/@WithDefault("docker.io/postgres")StringimageName();/**
* Environment variables that are passed to the container.
*/Map<String,String>containerEnv();}
The database configuration contains connection information for the PostgreSQL database used by default. Again, we favor convention over configuration by applying default values.
DemoExtensionProcessor
To ask Quarkus to start a container, we will need to configure our DemoExtensionProcessor. Let’s start by adding a createContainer method that will act as a @BuildStep to start and configure our container.
@BuildSteps(onlyIf={GlobalDevServicesConfig.Enabled.class})publicclassDemoExtensionProcessor{/**
* The current configuration. either from running container instance or an instance that is about to start.
*/privatestaticvolatileDevServicesConfigrunningConfiguration;privatestaticvolatileRunningDevServicedemoDatabaseDevService;@BuildSteppublicvoidcreateContainer(DockerStatusBuildItemdockerStatusBuildItem,List<DevServicesSharedNetworkBuildItem>devServicesSharedNetworkBuildItem,DemoBuildTimeConfigbuildTimeConfig,GlobalDevServicesConfigdevServicesConfig,BuildProducer<DevServicesResultBuildItem>devServicesResult){runningConfiguration=buildTimeConfig.devservices();try{List<String>errors=newArrayList<>();DemoDatabaseRunningDevServicenewDatabaseDevService=startDatabaseContainer(dockerStatusBuildItem,!devServicesSharedNetworkBuildItem.isEmpty(),devServicesConfig.timeout.orElse(Duration.of(0,ChronoUnit.SECONDS)),runningConfiguration,errors);// Get the RunningDevService instance.demoDatabaseDevService=newDatabaseDevService.getRunningDevService();}catch(Throwablet){thrownewRuntimeException(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());}}
Our method is injected with the following build items:
DockerStatusBuildItem : Allows retrieving the status of a Docker instance.
DevServicesSharedNetworkBuildItem : Determines if containers are started on a shared network.
DemoBuildTimeConfig : Configuration of our extension.
GlobalDevServicesConfig : Global configuration for dev services provided by Quarkus.
DevServicesResultBuildItem : The build item to produce for referencing our container to Quarkus.
Our method then calls the startDatabaseContainer method, which is responsible for creating an instance of DemoDatabaseRunningDevService. This class provides a RunningDevService, via devServicesResult.produce(demoDatabaseDevService.toBuildItem()), which is returned by our @BuildStep to request Quarkus to launch a container.
privateDemoDatabaseRunningDevServicestartDatabaseContainer(DockerStatusBuildItemdockerStatusBuildItem,booleanuseSharedNetwork,Durationtimeout,DevServicesConfigdevServicesConfig,List<String>errors){if(!devServicesConfig.enabled()){// explicitly disabledLog.debug("Not starting Dev Services for Demo as it has been disabled in the config");returnnull;}if(!dockerStatusBuildItem.isDockerAvailable()){Log.warn("Please get a working docker instance");errors.add("Please get a working docker instance");returnnull;}finalOptional<ContainerAddress>maybeExistingContainer=demoDBDevModeContainerLocator.locateContainer(devServicesConfig.serviceName()+"_db",devServicesConfig.shared(),LaunchMode.current());// Get the image name from the configuration.StringimageName=devServicesConfig.db().imageName();DockerImageNamedockerImageName=DockerImageName.parse(imageName).asCompatibleSubstituteFor(imageName);returnmaybeExistingContainer.map(existingContainer->newDemoDatabaseRunningDevService(newRunningDevService(DemoDatabaseContainer.CONTAINER_NAME,existingContainer.getId(),null,Map.of("url",existingContainer.getHost()+":"+existingContainer.getPort())))).orElseGet(()->newDemoDatabaseRunningDevService(DemoDatabaseContainer.CONTAINER_NAME,useSharedNetwork,timeout,devServicesConfig,dockerImageName,devServicesConfig.serviceName()+"_db"));}
We start with a few checks. If dev services are globally disabled, the launch is canceled, and if Docker is unavailable, an error is returned. The method then checks if another container already exists. This may happen if two Quarkus services use the same extension. This is done using a ContainerLocator:
If a database container is already running, the method returns a RunningDevService with the information from the existing container. Otherwise, a new RunningDevService is returned for the new container.
Our class defines two constructors. The first simply returns the existing RunningDevService in case the container already exists. The second creates a new RunningDevService. The run method first checks if a port is specified in the dev-service configuration. If so, our container will be started on this port; otherwise, a random available port will be allocated (this is the default behavior).
Once the instance of our DemoDatabaseContainer is created, we configure it by passing the environment variables it expects via the withEnv method. Next comes the configuration of the Docker network used. If the user has explicitly specified that the containers should be attached to the shared Docker network create by TestContainer, we use the withNetwork method with the value Network.SHARED. Otherwise, a new network is created for the container.
Finally, we return our RunningDevService, containing the ID of our container along with the configuration that will be useful later.
DemoDatabaseContainer
Now, let’s look at the DemoDatabaseContainer class to finish this first post on creating a DevService with Quarkus.
This class extends GenericContainer and contains the configuration used by TestContainer to start our Docker container. In the configure method, we first check if the container should use the shared network. If so, ConfigureUtil.configureSharedNetwork handles everything. Otherwise, we configure the port (fixed or random), add a label, and specify the port to listen to for service startup completion.
Conclusion
Our database extension is now functional. It provides an implementation of a GenericContainer through a RunningDevService to our DemoExtensionProcessor. Everything is customizable via Quarkus’s standard configuration. All we need to do is to include the extension in a Quarkus application via a Maven import to have the database started in parallel.
In the next article, we’ll see how to launch a service in its own container using this database to expose an API to our Quarkus application.