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:
-
A 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.
-
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
}
- Bean-generating class
- Regular scope annotation
- 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()
}
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>()
}
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:
The usage is straightforward:
val request = HttpRequest.GET<Unit>("https://gateway.marvel.com:443/v1/public/characters")
client.retrieve(request, String::class.java)
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?
)
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.
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) }
}
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
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
)
- Bind the property class to the property file prefix
- 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
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:
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"
)
}
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
}
}
- By default, one server is created for each test method. We want one per test class.
- 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)
}
}
- Inject the reactive client
- Inject the embedded server, i.e., the application
- Retrieve the IP and the port from the mock server
- Use Kotlin to read the sample file - there's no provided abstraction as in Spring
- We need to block as the client is reactive
- 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:
-
On the local machine.
It requires a local GraalVM installation with
native-image
.
mvn package -Dpackaging=native-image
-
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
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
- Parent image
- Alpine glibc
- Additional packages
- 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.
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:
- Micronaut Launch
- Defining Beans
- Micronaut HTTP client
- Creating your first Micronaut Graal application
Originally published at A Java Geek on November 21th, 2021
Top comments (0)