Insight Into Developing Quarkus-Based Microservices
Quarkus
Quarkus is an open-source CDI-based framework introduced by Red Hat. It supports the development of fully reactive microservices, provides a fast startup, and has a small memory footprint. Below was our overall experience using Quarkus:
- It helped with a quicker and more pleasant development process.
- Optimized Serverless deployments for low memory usage and fast startup times
- Allowed us to utilize both blocking (imperative) and non-blocking (reactive) libraries and APIs
- Worked well with continuous testing to facilitate test-driven development
- Allowed support to test the JUnit test cases, which we have developed using test-driven development approach
Quarkus Supports Native Builds
Quarkus supports native builds for an application deployment which contains the application code, required libraries, Java APIs, and a reduced version of a VM. The smaller VM base improves the startup time of the application.
To generate a native build using a Java Maven project, one can leverage Docker or podman with GraalVM:
mvn clean install -Dnative -Dquarkus.native.container-build=true -Dmaven.test.skip=true
The native executable is lightweight and performance optimized.
Common Build and Runtime Errors
Quarkus, being fairly new, lacks sufficient support in the community and documentation. Below were some of the errors/issues we encountered during development and their resolution.
Build Errors
UnresolvedElementException
com.oracle.graal.pointsto.constraints.UnresolvedElementException: Discovered unresolved type during parsing: xxx
This error is caused by missing classes at the image build time. Since the native image runtime does not include the facilities to load new classes, all code needs to be available and compiled at build time. So any class that is referenced but missing is a potential problem at run time.
Solution
The best practice is to provide all dependencies to the build process. If you are absolutely sure that the class is 100% optional and will not be used at run time, then you can override the default behavior of failing the build process by finding a missing class with the — allow-incomplete-classpath option to native-image.
Runtime Errors
Random/SplittableRandom
com.oracle.graal.pointsto.constraints.UnsupportedFeatureException: Detected an instance of Random/SplittableRandom class in the image heap
These errors are caused when you try to initialize these classes in a static block. Embedding instances of Random and SplittableRandom in native images cause these errors. These classes are meant to provide random values and are typically expected to get a fresh seed in each run. Embedding them in a native image results in the seed value that was generated at build-time to be cached in the native image, thus breaking that expectation.
Solution
We were able to resolve these by using below different ways:
- By avoiding build time initialization of classes holding static fields that reference (directly or transitively) instances of Random or SplittableRandomclasses. The simplest way to achieve this is to pass — initialize-at-run-time=<ClassName>to native-image and see if it works. Note that even if this works, it might impact the performance of the resulting native image since it might prevent other classes from being build-time initialized as well.
- Register classes holding static fields that directly reference instances of Random or SplittableRandom classes to be reinitialized at run-time. This way, the referenced instance will be re-created at run-time, solving the issue.
- Reset the value of fields (static or not) referencing (directly or transitively) instances of Random or SplittableRandom to null in the native-image heap.
ClassNotFoundException/InstantiationException/IllegalArgumentException
These errors can occur when a native image builder is not informed about some reflective calls or a resource to be loaded at run time. Or if there is a third-party/custom library that includes some, ahead-of-time incompatible code.
Solution
In order to resolve these exceptions, add the complaining class in reflect-config.json
{
{
"name": "com.foo.bar.Person",
"allDeclaredMethods": true,
"allDeclaredConstructors": true
}
}
Reflection Issues With Native Builds
When building a native executable, GraalVM operates with a closed-world assumption. Native builds with GraaVM analyzes the call tree and remove all the classes/methods/fields that are not used directly. The elements used via reflection are not part of the call tree, so they are dead code eliminated. In order to include these elements in the native executable, we’ll need to register them for reflection explicitly. JSON libraries typically use reflection to serialize the objects to JSON, and not registering these classes for reflection causes errors like the below:
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.acme.jsonb.Person and no properties discovered to create BeanSerializer (to avoid an exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS)
We resolved these by adding below annotation:
@RegisterForReflection
public class MyClass {
...
}
If the class is in a third-party jar, you can do it by using an empty class that will host the @RegisterForReflection
for it:
@RegisterForReflection(targets={ MyClassRequiringReflection.class, MySecondClassRequiringReflection.class})
public class MyReflectionConfiguration {
...
}
Note that MyClassRequiringReflection
and MySecondClassRequiringReflection
will be registered for reflection but not MyReflectionConfiguration
. This feature is handy when using third-party libraries using object mapping features (such as Jackson or GSON):
@RegisterForReflection(targets = {User.class, UserImpl.class})
public class MyReflectionConfiguration {
...
}
We can use a configuration file to register classes for reflection. As an example, in order to register all methods of class com.test.MyClass
for reflection, we create reflection-config.json (the most common location is within src/main/resources).
[
{
"name" : "com.test.MyClass",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true,
"allDeclaredFields" : true,
"allPublicFields" : true
}
]
Integration With DynamoDB-Enhanced Client
Another aspect of using Serverless architecture was the use of DynamoDB. Although there are ways to connect simple DynamoDB clients to do all operations, it does require a lot of code writing which brings verbosity and a lot of boilerplate code to the project. We considered using DynamoDBMapper but figured we couldn't use it with Quarkus since it doesn't support Java SDK1. Enhanced DynamoDB Client in Java SDK2 is the substitute Java SDK1 DynamoDBMapper, which worked well with Quarkus, although there were a few issues setting it up for classes when using native images.
Annotated Java beans for creating TableSchema
apparently didn’t work with native images. Mappings got lost in translation due to reflection during the native build. To resolve this, we used static table schema mappings using builder pattern, which actually is faster compared to bean annotations since it doesn't require costly bean introspection:
TableSchema<Customer> customerTableSchema =
TableSchema.builder(Customer.class)
.newItemSupplier(Customer::new)
.addAttribute(String.class, a -> a.name("id")
.getter(Customer::getId)
.setter(Customer::setId)
.tags(primaryPartitionKey()))
.addAttribute(Integer.class, a -> a.name("email")
.getter(Customer::getEmail)
.setter(Customer::setEmail)
.tags(primarySortKey()))
.addAttribute(String.class, a -> a.name("name")
.getter(Customer::getCustName)
.setter(Customer::setCustName)
.addAttribute(Instant.class, a -> a.name("registrationDate")
.build();
Quarkus has extensions for commonly used libraries which simplifies the use of that library in an application by providing some extra features which help in development, testing, and configuration for a native build.
Recently, Quarkus released an extension for an enhanced client.
This extension will resolve the above-mentioned issue related to native build and annotated Java beans for creating TableSchema caused by the use of reflection in AWS SDK. To use this extension in the Quarkus project, add the following dependency in the pom file:
<dependency>
<groupId>io.quarkiverse.amazonservices</groupId>
<artifactId>quarkus-amazon-dynamodb</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.amazonservices</groupId>
<artifactId>quarkus-amazon-dynamodb-enhanced</artifactId>
</dependency>
We have three options to select an HTTP client 2 for a sync DynamoDB client and 1 for an async DynamoDB client; the default is a URL HTTP client, and for that need to import the following dependency.
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
</dependency>
If we want an Apache HTTP client instead of a URL client, we can configure it by using the following property and dependencies:
quarkus.dynamodb.sync-client.type=apache
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>apache-client</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-apache-httpclient</artifactId>
</dependency>
For an async client, the following dependency can be used:
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>netty-nio-client</artifactId>
</dependency>
Dev and test services for DynamoDB are enabled by default which uses docker to start and stop those dev services; these services help in dev and test by running a local DynamoDB instance; we can configure the following property to stop them if we don’t want to use them or don’t have Docker to run them:
quarkus.dynamodb.devservices.enabled=false
We can directly inject enhanced clients into our application, annotate the model with corresponding partitions, sort, and secondary partitions, and sort keys if required.
@Inject
DynamoDbEnhancedClient client;
@Produces
@ApplicationScoped
public DynamoDbTable<Fruit> mappedTable() {
return client.table("Fruit", TableSchema.fromClass(Fruit.class))
}
@DynamoDbBean
public class Fruit {
private String name;
private String description;
@DynamoDbPartitionKey
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
Achieving Security With Native Builds — SSL connections
Developing microservices architecture will, at some point, require you to call a microservice from another microservice. Quarkus provides its own Rest client configuration to efficiently do so but calling those services securely (using SSL) becomes complicated when using native images. By default, Quarkus will embed the default trust store ($GRAALVM_HOME/Contents/Home/lib/security/cacerts) in the native Docker image. The function.zip generated with the native build won’t embed the default trust store, which causes issues when the function is deployed to AWS.
- We had to add an additional directory zip.native under src/main to add the certificates to function.zip.
- zip.native contains
cacerts
and custombootstrap.sh
(shown below) cacerts
is the trust store and it holds all the certificates needed.bootstrap.sh
should embed this trust store within the native function.zip.
# bootstrap.sh
#!/usr/bin/env bash
./runner -Djava.library.path=./
-Djavax.net.ssl.trustStore=./cacerts
-Djavax.net.ssl.trustStorePassword=changeit
Health Checks and Fault Tolerance with Quarkus
Health Checks and Fault Tolerance are crucial for microservices since these help in enabling Failover strategy in applications. We leveraged the quarkus-smallrye-health extension, which provides support to build health checks out of the box. We had overridden the HealthCheck class and added dependency health checks for dependent AWS components like DynamoDB to check for health. Below is one of the sample responses from the health checks with HTTP status 200:
{
"status": "UP",
"checks": [
{
"name": "Database connections health check",
"status": "UP"
}
]
}
Along with these health checks, we used fault tolerance for microservice-to-microservice calls. In a case called microservice is down or not responding, max retry and timeouts were configured using quarks-small rye-fault-tolerance. After max retries, if the dependent service still doesn't respond, we used method fallbacks to generate static responses.
import org.eclipse.microprofile.faulttolerance.Retry;
import org.eclipse.microprofile.faulttolerance.Timeout;
import org.eclipse.microprofile.faulttolerance.Fallback;
...
public class FooResource {
...
@GET
@Retry(maxRetries = 3)
@Timeout(1000)
public List<Foo> getFoo() {
...
}
@Fallback(fallbackMethod = "fallbackRecommendations")
public List<Foo> fallbackforGetFoo() {
...
}
public List<Foo> fallbackRecommendations() {
...
return Collections.singletonList(fooRepository.getFooById(1));
}
}
Conclusion
Overall, Quarkus is an excellent growing framework that offers a lot of options for developing serverless microservices using native images. It optimizes Java and makes it efficient for containers, cloud, and serverless environments with memory consumption optimization and a fast first response time.