DEV Community

Cover image for Native-image with Quarkus
Nicolas Frankel
Nicolas Frankel

Posted on • Originally published at blog.frankel.ch

Native-image with Quarkus

So far, we have looked at how well Spring Boot and Micronaut integrate GraalVM native image extension. In this post, I'll focus on Quarkus:

A Kubernetes Native Java stack tailored for OpenJDK HotSpot and GraalVM, crafted from the best of breed Java libraries and standards.

Creating a new project

Just as Spring Boot and Micronaut, Quarkus provides options to create new projects:

  1. A dedicated quarkus CLI
  2. A Web UI

    Quarkus offers a definite improvement over its competitors. Every dependency has a detailed contextual menu that allows:

    • Copying the command to add the dependency via quarkus
    • Copying the command to add the dependency via Maven
    • Copying the Maven POM dependency snippet
    • Copying the Maven BOM dependency snippet
    • Copying the Maven BOM dependency snippet
    • Opening the related dependency's guide

    Note that the menu displays Gradle-related commands instead if you choose Gradle as the build tool.

  3. A Maven plugin: I like that no external dependencies are necessary beyond one's build tool of choice.

Bean configuration

Quarkus relies on JSR 330. However, it deviates from the specification: it lists both limitations and non-standard features.

For example, with Quarkus, you can skip the @Produces annotation on a producer method if it's already annotated with one of the scope annotations, e.g., @Singleton. Here's the code to create the message digest:

class MarvelFactory {

    @Singleton
    fun digest(): MessageDigest = MessageDigest.getInstance("MD5")
}
Enter fullscreen mode Exit fullscreen mode

Controller configuration

A lot of Quarkus relies on Jakarta EE specifications. As such, the most straightforward path to creating controllers is JAX-RS. We can create a "controller" with the following code:

@Path("/")
class MarvelController {

    @GET
    fun characters() = Response.accepted()
}
Enter fullscreen mode Exit fullscreen mode

Because the developers of Quarkus also worked on Vert.x, the former also offers a plugin that integrates the latter. Vert.x is full reactive and provides the concept of routes. With Quarkus, you can annotate methods to mark them as routes. One can migrate the above code to routes:

@Singleton
class MarvelController {

     @Routes
     fun characters() = Response.accepted()
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, one can prefer programmatic route registration:

@Singleton
class MarvelRoutes {

    fun get(@Observes router: Router) {       // 1
        router.get("/").handler {
            it.response()
                .setStatusCode(200)
                .send()                       // 2
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Observe the Router "event": it's fired once at startup time.
  2. Send the empty response; return a Future<Void>

Note that the programmatic way requires as many annotations as the annotation way in this particular case. However, the former requires two annotations, while the latter requires one per route plus one.

Non-blocking HTTP client

Micronaut integrates with many HTTP client flavors via plugins. In this project, I chose to use Mutiny.

There are other reactive programming libraries out there. In the Java world, we can mention Project Reactor and Rx Java.

So, what makes Mutiny different from these two well-known libraries? The API!

As said above, asynchronous is hard to grasp for most developers, and for good reasons. Thus, the API must not require advanced knowledge or add cognitive overload. It should help you design your logic and still be intelligible in 6 months.

-- https://smallrye.io/smallrye-mutiny/pages/philosophy#what-makes-mutiny-different

Here's a glimpse into a subset of the Mutiny API:

Mutiny API class diagram

At first, I was not fond of Mutiny. I mean, we already have enough reactive clients: Project Reactor, RxJava2, etc.

Then, I realized the uniqueness of its approach. Reactive programming is pretty tricky because of all available options. But Mutiny is designed around a fluent API that leverages the type system to narrow down compatible options at compile-time. It gently helps you write the result you want, even with a passing API knowledge.

I'm now convinced to leave it a chance.
Let's use Mutiny to make a request:

val client = WebClient.create(vertx)                      // 1
client.getAbs("https://gateway.marvel.com:443/v1/public/characters")  // 2
      .send()                                             // 3
      .onItem()                                           // 4
      .transform { it.bodyAsString() }                    // 5
      .await()                                            // 6
      .indefinitely()                                     // 7
Enter fullscreen mode Exit fullscreen mode
  1. Create the client by wrapping a Vertx instance. Quarkus provides one and can inject it for you
  2. Create a new instance of a GET HTTP request
  3. Send the request asynchronously. Nothing has happened at this point yet.
  4. When it receives the response...
  5. ... transform its body to a String
  6. Wait...
  7. ... forever, until it gets the response.

To get parameters from the coming request and forward them is straightforward with the routing context:

router.get("/").handler { rc ->
    client.getAbs("https://gateway.marvel.com:443/v1/public/characters")
        .queryParamsWith(rc.request())
        .send()
        .onItem()
        .transform { it.bodyAsString()) }
        .await()
        .indefinitely()

fun HttpRequest<Buffer>.queryParamsWith(request: HttpServerRequest) =
    apply {
        arrayOf("limit", "offset", "orderBy").forEach { param ->
            request.getParam(param)?.let {
                addQueryParam(param, it)
            }
        }
    }
Enter fullscreen mode Exit fullscreen mode

Parameterization

Like Spring Boot and Micronaut, Quarkus allows parameterizing one's application in multiple ways:

  • System properties
  • Environment variables
  • .env file in the current working directory
  • Configuration file in $PWD/config/application.properties
  • Configuration file application.properties in classpath
  • Configuration file META-INF/microprofile-config.properties in classpath

Note that it's not possible to use command-line parameters for parameterization.

Unlike its siblings, the web client requires you to split the URL into three components, host, port, and whether to use SSL.

app.marvel.server.ssl=true
app.marvel.server.host=gateway.marvel.com
app.marvel.server.port=443
Enter fullscreen mode Exit fullscreen mode

Because of this, we need to be a bit creative regarding the configuration classes:

@Singleton                                                                    // 1
data class ServerProperties(
    @ConfigProperty(name = "app.marvel.server.ssl") val ssl: Boolean,       // 2
    @ConfigProperty(name = "app.marvel.server.host") val host: String,        // 2
    @ConfigProperty(name = "app.marvel.server.port") val port: Int            // 2
)

@Singleton                                                                    // 1
data class MarvelProperties(
    val server: ServerProperties,                                             // 3
    @ConfigProperty(name = "app.marvel.apiKey") val apiKey: String,           // 2
    @ConfigProperty(name = "app.marvel.privateKey") val privateKey: String    // 2
)
Enter fullscreen mode Exit fullscreen mode
  1. Configuration classes are regular CDI beans.
  2. Quarkus uses the Microprofile Configuration specification. @ConfigProperty sets the property key to read from. It's unwieldy to repeat the same prefix on all keys. Thus, Microprofile offers the @ConfigProperties to set the prefix on the class. However, such a class needs a zero-arg constructor, which doesn't work with Kotlin's data classes.
  3. Inject the other config class and benefit from a nested structure

Testing

Like its siblings, Quarkus offers its dedicated annotation for tests, @QuarkusTest. It also provides @NativeImageTest, which allows running the test in a native-image context. The idea is to define your JVM test in a class annotated with the former and create a subclass annotated with the latter. This way, the test will run both in a JVM context and a native one. Note that I'm not sure it worked in my setup.

But IMHO, the added value of Quarkus in a testing context lies in how it defines a reusable resource abstraction.

QuarkusTestResourceLifecycleManager class diagram

With only one interface and one annotation, one can define a resource, e.g., a mock server, start it before tests and stop it after. Let's do that:

class MockServerResource : QuarkusTestResourceLifecycleManager {

    private val mockServer = MockServerContainer(
        DockerImageName.parse("mockserver/mockserver")
    )

    override fun start(): Map<String, String> {
        mockServer.start()
        val mockServerClient = MockServerClient(
            mockServer.containerIpAddress,
            mockServer.serverPort
        )
        val sample = this::class.java.classLoader.getResource("sample.json")
                                                ?.readText()
        mockServerClient.`when`(
            HttpRequest.request()
                .withMethod("GET")
                .withPath("/v1/public/characters")
        ).respond(
            HttpResponse()
                .withStatusCode(200)
                .withHeader("Content-Type", "application/json")
                .withBody(sample)
        )
        return mapOf(
            "app.marvel.server.ssl" to "false",
            "app.marvel.server.host" to mockServer.containerIpAddress,
            "app.marvel.server.port" to mockServer.serverPort.toString()
        )
    }

    override fun stop() = mockServer.stop()
}
Enter fullscreen mode Exit fullscreen mode

Now, we can use this server inside our test:

@QuarkusTest
@QuarkusTestResource(MockServerResource::class)
class QuarkusApplicationTest {

    @Test
    fun `should deserialize JSON payload from server and serialize it back again`() {
        val model = given()                                             // 1
            .`when`()
            .get("/")
            .then()
            .statusCode(200)
            .contentType(ContentType.JSON)
            .and()
            .extract()
            .`as`(Model::class.java)
        assertNotNull(model)
        assertNotNull(model.data)
        assertEquals(1, model.data.count)
        assertEquals("Anita Blake", model.data.results.first().name)
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Quarkus integrates the RestAssured API. It uses some of Kotlin's keywords, so we need to escape them with back-ticks.

I like how it decouples the test from its dependencies.

Docker and GraalVM integration

When one scaffolds a new project, Quarkus creates different ready-to-use Dockerfile:

  • A legacy JAR
  • A layered JAR approach: dependencies are added to the image first so that if any of them changes, the build reuses their layer
  • A native image
  • A native image with a distroless parent

The good thing is that one can configure any of these templates to suit one's needs. The downside is that it requires a local Docker install. Moreover, templates are just templates - you can change them entirely.

If you don't like this approach, Quarkus provides an integration point with Jib. In the rest of this section, we will keep using Docker files.

To create a GraalVM native binary, one uses the following command:

./mvnw package -Pnative -Dquarkus.native.container-build=true
Enter fullscreen mode Exit fullscreen mode

You can find the resulting native executable in the target folder.

To wrap it in a Docker container, use:

docker build -f src/main/docker/Dockerfile.native -t native-quarkus .
Enter fullscreen mode Exit fullscreen mode

Note that the Docker container would fail to start if you ran the first command on a non-Linux platform. To fix this issue, one needs to add an option:

 ./mvnw package -Pnative -Dquarkus.native.container-build=true \
                         -Dquarkus.container-image.build=true
Enter fullscreen mode Exit fullscreen mode

quarkus.container-image.build=true instructs Quarkus to create a container-image using the final application artifact (which is the native executable in this case).

-- https://quarkus.io/guides/building-native-image#using-the-container-image-extensions

For a smaller image, we can use the distroless distribution.

The result is the following:

REPOSITORY                 TAG       IMAGE ID         CREATED         SIZE
native-quarkus-distroless  latest    7a13aef3bcd2     2 hours ago     67.9MB
native-quarkus             latest    6aba7346d987     2 hours ago     148MB
Enter fullscreen mode Exit fullscreen mode

Let's dive:

┃ ● Layers ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Cmp   Size  Command
    2.4 MB  FROM 58f4b2390f4a511                                       #1
     18 MB  bazel build ...                                            #2
    2.3 MB  bazel build ...                                            #2
    113 kB  #(nop) COPY file:b8552793e0627404932d516d478842f7f9d5d5926 #3
     45 MB  COPY target/*-runner /application # buildkit               #4
Enter fullscreen mode Exit fullscreen mode
  1. Parent distroless image
  2. Add system libraries, obviously via the Bazel build system
  3. One more system library
  4. Our native executable

We can now run the container:

docker run -it -p8080:8080 native-quarkus-distroless
Enter fullscreen mode Exit fullscreen mode

And the following URLs work as expected:

curl localhost:8080
curl 'localhost:8080?limit=1'
curl 'localhost:8080?limit=1&offset=50'
Enter fullscreen mode Exit fullscreen mode

Conclusion

Quarkus brings an exciting take to the table. Unlike Micronaut, it doesn't generate additional bytecode during each compilation. The extra code is only generated when one generates the native image via the Maven command. Moreover, relying on Dockerfiles allows you to configure them to your heart's content if you happen to have a Docker daemon available.

However, the Kotlin integration is lacking. You have to downgrade your model representation to allow Quarkus to hydrate your data classes, moving from val to var and setting default values. Additionally, one needs to set Jackson annotations on each field. Finally, configuration properties don't work well with data classes.

As with Micronaut, if Kotlin is a must-have for you, then you'd better choose Spring Boot over Quarkus. Otherwise, give Quarkus a try.

Thanks Sébastien Blanc and Clément Escoffier for their review.

The complete source code for this post can be found:

To go further:

Originally published at A Java Geek on December 11th, 2021

Top comments (0)