Native-image with Micronaut

Nicolas Fränkel - Nov 21 '21 - - Dev Community

Last week, I wrote a native web app that queried the Marvel API using Spring Boot. This week, I want to do the same with the Micronaut framework.

Creating a new project

Micronaut offers two options to create a new project:

  1. A web UI:

    Micronaut Launch Web UI

    As for Spring Initializr, it provides several features: preview the project before you download it, share the configuration and an API.

    I do like that you can check the impact that the added features have on the POM.

  2. A Command-Line Interface:

    In parallel to the webapp, you can install the CLI on different systems. Then you can use the mn command to create new projects.

In both options, you can configure the following parameters:

  • The build tool, Maven, Gradle, or Gradle with the Kotlin DSL
  • The language, Java, Kotlin, or Groovy
  • Micronaut's version
  • A couple of metadata
  • Dependencies

The application's code is on GitHub. You can clone and adapt it, but as far as I know, it's not designed with extension in mind (yet?).

Bean configuration

Micronaut's bean configuration relies on JSR 330. The JSR defines a couple of annotations, e.g., @Singleton and @Inject, in the jakarta.inject package. Developers use them, and the service provider implements the specification.

@Singleton and its sibling @ApplicationScoped are meant to be used on our code. Our sample app needs to create an instance of java.security.MessageDigest, which cannot be annotated. To solve this problem, JSR 330 provides the @Factory annotation:

@Factory                                                  // 1
class BeanFactory {

  @Singleton                                              // 2
  fun messageDigest() = MessageDigest.getInstance("MD5")  // 3
}
Enter fullscreen mode Exit fullscreen mode
  1. Bean-generating class
  2. Regular scope annotation
  3. Generate a message digest singleton

Micronaut also provides an automated discovery mechanism. Unfortunately, it doesn't work in Kotlin. You need to point to the package Micronaut explicitly should scan:

fun main(args: Array<String>) {
    Micronaut.build().args(*args)
             .packages("ch.frankel.blog")
             .start()
}
Enter fullscreen mode Exit fullscreen mode

Controller configuration

Micronaut copied the @Controller annotation from Spring. You can use it in the same way. Likewise, annotate functions with the relevant HTTP method annotation.

@Controller
class MarvelController() {

    @Get
    fun characters() = HttpResponse.accepted<Unit>()
}
Enter fullscreen mode Exit fullscreen mode

Non-blocking HTTP client

Micronaut provides two HTTP clients: a declarative one and a low-level one. Both of them are non-blocking.

The declarative client is for simple use-cases, while the low-level is for more complex ones. Passing parameters belongs to the complex category, so I chose the low-level one. Here's a sample of its API:

Micronaut Client API class diagram

The usage is straightforward:

val request = HttpRequest.GET<Unit>("https://gateway.marvel.com:443/v1/public/characters")
client.retrieve(request, String::class.java)
Enter fullscreen mode Exit fullscreen mode

Remember that we should get parameters from the request to the application and propagate them to the request we make to the Marvel API. Micronaut can automatically bind such query parameters to method parameters with the @QueryValue annotation for the first part.

@Get
fun characters(
    @QueryValue limit: String?,
    @QueryValue offset: String?,
    @QueryValue orderBy: String?
)
Enter fullscreen mode Exit fullscreen mode

It's not possible to use Kotlin's string interpolation as these parameters are optional. Fortunately, Micronaut provides an UriBuilder abstraction, which follows the Builder pattern principles.

Micronaut URI Builder class diagram

We can use it like this:

val uri = UriBuilder
            .of("${properties.serverUrl}/v1/public/characters")
            .queryParamsWith(
                mapOf(
                    "limit" to limit,
                    "offset" to offset,
                    "orderBy" to orderBy
                )
            ).build()

fun UriBuilder.queryParamsWith(params: Map<String, String?>) = apply {
    params.entries
        .filter { it.value != null }
        .forEach { queryParam(it.key, it.value) }
}
Enter fullscreen mode Exit fullscreen mode

Parameterization

Like Spring, Micronaut can bind application properties to Kotlin data classes. In Micronaut, the file is named application.yml. The file already exists and contains the micronaut.application.name key. We only need to add the additional data. I chose to put it under the same parent key, but there's no such constraint.

micronaut:
  application:
    name: nativeMicronaut
    marvel:
      serverUrl: https://gateway.marvel.com:443
Enter fullscreen mode Exit fullscreen mode

To bind, we need the help of two annotations:

@ConfigurationProperties("micronaut.application.marvel")   //1
data class MarvelProperties
                       @ConfigurationInject constructor(   //2
    val serverUrl: String,
    val apiKey: String,
    val privateKey: String
)
Enter fullscreen mode Exit fullscreen mode
  1. Bind the property class to the property file prefix
  2. Allow using a data class. The @ConfigurationInject needs to be set on the constructor: it's a sign that the team could improve Kotlin integration in Micronaut.

Testing

Micronaut tests are based on the @MicronautTest annotation.

@MicronautTest
class MicronautNativeApplicationTest
Enter fullscreen mode Exit fullscreen mode

We defined the properties of the above data class as non-nullable strings. Hence, we need to pass the value when the test starts. For that, Micronaut provides the TestPropertyProvider interface:

Test property provider class diagram

We can leverage it to pass property values:

@MicronautTest
class MicronautNativeApplicationTest : TestPropertyProvider {

    override fun getProperties() = mapOf(
        "micronaut.application.marvel.apiKey" to "dummy",
        "micronaut.application.marvel.privateKey" to "dummy",
        "micronaut.application.marvel.serverUrl" to "defined-later"
    )
}
Enter fullscreen mode Exit fullscreen mode

The next step is to set up Testcontainers. Integration is provided out-of-the-box for popular containers, e.g., Postgres, but not with the mock server. We have to write code to handle it.

@MicronautTest
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)             // 1
class MicronautNativeApplicationTest {

    companion object {

        @Container
        val mockServer = MockServerContainer(
            DockerImageName.parse("mockserver/mockserver")
        ).apply { start() }                                 // 2
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. By default, one server is created for each test method. We want one per test class.
  2. Don't forget to start it explicitly!

At this point, we can inject both the client and the embedded server:

@MicronautTest
@Testcontainers
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class MicronautNativeApplicationTest : TestPropertyProvider {

    @Inject
    private lateinit var client: HttpClient                                    // 1

    @Inject
    private lateinit var server: EmbeddedServer                                // 2

    companion object {

        @Container
        val mockServer = MockServerContainer(
            DockerImageName.parse("mockserver/mockserver")
        ).apply { start() }
    }

    override fun getProperties() = mapOf(
        "micronaut.application.marvel.apiKey" to "dummy",
        "micronaut.application.marvel.privateKey" to "dummy",
        "micronaut.application.marvel.serverUrl" to
            "http://${mockServer.containerIpAddress}:${mockServer.serverPort}" // 3
    )

    @Test
    fun `should deserialize JSON payload from server and serialize it back again`() {
        val mockServerClient = MockServerClient(
            mockServer.containerIpAddress,                                     // 3
            mockServer.serverPort                                              // 3
        )
        val sample = this::class.java.classLoader.getResource("sample.json")
                                                 ?.readText()                  // 4

        mockServerClient.`when`(
            HttpRequest.request()
                .withMethod("GET")
                .withPath("/v1/public/characters")
        ).respond(
            HttpResponse()
                .withStatusCode(200)
                .withHeader("Content-Type", "application/json")
                .withBody(sample)
        )

        // With `retrieve` you just get the body and can assert on it
        val body = client.toBlocking().retrieve(                               // 5
            server.url.toExternalForm(),
            Model::class.java                                                  // 6
        )
        assertEquals(1, body.data.count)
        assertEquals("Anita Blake", body.data.results.first().name)
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Inject the reactive client
  2. Inject the embedded server, i.e., the application
  3. Retrieve the IP and the port from the mock server
  4. Use Kotlin to read the sample file - there's no provided abstraction as in Spring
  5. We need to block as the client is reactive
  6. There's no JSON assertion API. The easiest path is to deserialize in a Model class, and then assert the object's state.

Docker and GraalVM integration

As with Spring, Micronaut provides two ways to create native images:

  1. On the local machine.

    It requires a local GraalVM installation with native-image.

    mvn package -Dpackaging=native-image
    
  2. In Docker. It requires a local Docker installation.

    mvn package -Dpackaging=docker-native
    

    Note that if you don't use a GraalVM JDK, you need to activate the graalvm profile.

    mvn package -Dpackaging=docker-native -Pgraalvm
    

With the second approach, the result is the following:

REPOSITORY             TAG       IMAGE ID         CREATED          SIZE
native-micronaut       latest    898f73fb44b0     33 seconds ago   85.3MB
Enter fullscreen mode Exit fullscreen mode

The layers are the following:

┃ ● Layers ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Cmp   Size  Command
    5.6 MB  FROM e6b8cc5e282829d                                                #1
     12 MB  RUN /bin/sh -c ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/  #2
    3.5 MB  |1 EXTRA_CMD=apk update && apk add libstdc++ /bin/sh -c if [[ -n "  #3
     64 MB  #(nop) COPY file:106f24caede12d6d28c6c90d9a3ae33f78485ad71e4157125  #4
Enter fullscreen mode Exit fullscreen mode
  1. Parent image
  2. Alpine glibc
  3. Additional packages
  4. Our native binary

Miscellaneous comments

I'm pretty familiar with Spring Boot, much less with Micronaut.
Here are several miscellaneous comments.

  • Maven wrapper:

    When creating a new Maven project, Micronaut also configures the Maven wrapper.

  • Documentation matrix:

    Micronaut guides each offer a configuration matrix. You choose both the language and the build tool, and you'll read the guide in the exact desired flavor.

    Micronaut guide choice matrix screenshot

    I wish more polyglot multi-platform frameworks' documentation would offer such a feature.

  • Configurable packaging:

    Micronaut parameterizes the Maven's POM packaging so you can override it, as in the above native image generation. It's very clever!

    It's the first time that I have come upon this approach. I was so surprised when I created the project that I removed it (at first). Keep it.

  • Code generation:

    Last but not least, Micronaut bypasses traditional reflection at runtime. To achieve that, it generates additional code at compile-time. The trade-off is slower build time vs. faster runtime.

    With Kotlin, I found an additional issue. Micronaut generates the additional code with kapt. Unfortunately, kapt has been pushed to maintenance mode. Indeed, if you use a JDK with a version above 8, you'll see warnings when compiling.

    Integration of kapt with IntelliJ is poor at best. While all guides mention how to configure it, i.e., enable annotation processing, it didn't work for me. I had to rebuild the application using the command line to be able to view the changes. It makes the development lifecycle much slower.

    The team is working toward KSP support, but it's an undergoing effort.

Conclusion

Micronaut achieves the same result as Spring Boot. The Docker image's size is about 20% smaller. It's also more straightforward, with fewer layers, and based on Linux Alpine.

Kotlin works with Micronaut, but it doesn't feel "natural". If you value Kotlin benefits overall, you'd better choose Spring Boot. Otherwise, keep Micronaut but favor Java to avoid frustration.

Many thanks to Ivan Lopez for his review of this post.

The complete source code for this post can be found on GitHub:

To go further:

Originally published at A Java Geek on November 21th, 2021

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .