Featured image for Quarkus + Java 17

Quarkus is a Kubernetes-native Java framework made for Java virtual machines (JVMs) and native compilation, optimizing Java for containers. Java 17, the latest long-term support release of standard Java, became generally available on September 14, 2021. If you'd like to experiment to see how Quarkus works with the new language features that have rolled out since Java 11, this article offers a technical preview to help you get started.

Set up your development environment

In order to try the examples in this article, you will need:

  • An IDE.
  • JDK 17+ installed with JAVA_HOME configured appropriately.
  • Apache Maven 3.8.1+.

To create a native executable, you will also need:

  • GraalVM version 21.3.0 installed and configured appropriately. Be sure to install support for Java 17.

  • A working container runtime, such as Podman or any other OCI-compliant tool.

All the code for this sample project is available on GitHub.

Bootstrap the Quarkus project

To understand how several new Java features work with Quarkus, you will implement sample-app. This simple application exposes its own REST API based on data consumed from the Bored API, a public-facing service that suggests hobby activities you can pursue.

The easiest way to create this new Quarkus project is to open a terminal and run the following command:

mvn "io.quarkus:quarkus-maven-plugin:create" \
  -DprojectGroupId="com.redhat.developers" \
  -DprojectArtifactId="sample-app" \
  -DprojectVersion="1.0-SNAPSHOT" \
  -DclassName="HobbyResource" \
  -Dpath="actions"

If you are a Windows user, simply inline the command as follows:

mvn "io.quarkus:quarkus-maven-plugin:create" -DprojectGroupId="com.redhat.developers" -DprojectArtifactId="sample-app" -DprojectVersion="1.0-SNAPSHOT" -DclassName="HobbyResource" -Dpath="actions"

The generated project contains an endpoint, HobbyResource. To finish the setup, go inside the sample-app directory. Let's add some Quarkus extensions by running:

mvn quarkus:add-extension -Dextensions="quarkus-resteasy-jsonb, quarkus-container-image-jib, quarkus-rest-client,quarkus-smallrye-fault-tolerance, quarkus-smallrye-openapi"

The project is now using the following Quarkus extensions:

  • quarkus-resteasy-jsonb to create JSON REST services using JSON-B serialization.
  • quarkus-smallrye-openapi to document the exposed API.
  • quarkus-rest-client to consume REST services exposed via the Bored API.
  • quarkus-smallrye-fault-tolerance to make the application resilient.
  • quarkus-container-image-jib to generate container images.

Use sealed classes for API models

Sometimes, you want to retain only certain details from data consumed from an external API before exposing those further to end users. It's useful in such cases to tailor a data model as a class hierarchy and serve only what is necessary for further consumption. sample-app does this with a class hierarchy that handles the details of activities consumed from the Bored API, as illustrated in Figure 1.

Illustration of the class hierarchy for the sample application. License under MIT License.
Figure 1. Class hierarchy for the sample application.

The BasicHobby class contains fundamental details about an activity and restricts inheritance only to the PricedHobby class. BasicHobby can therefore be declared as a sealed class that permits inheritance to PricedHobby:

public sealed class BasicHobby permits PricedHobby {

    public String key;
    public String activity;
    public String type;
    public int participants;

    @JsonbCreator
    public static BasicHobby empty() {
        return new BasicHobby();
    }
}

Furthermore, PricedHobby should also be a sealed class, as it contains all the basic details about an activity and its price, but with inheritance restricted to CompleteHobby:

public sealed class PricedHobby extends BasicHobby permits CompleteHobby {
    public String randomId = UUID.randomUUID()
            .toString().substring(0, 4);
    public double price;

    @JsonbCreator
    public static PricedHobby empty() {
        return new PricedHobby();
    }
}

If CompleteHobby should not allow any further inheritance, the final keyword should precede it. For the moment, because CompleteHobby does not have any subclasses but the API model can evolve, you can use the non-sealed keyword:

public non-sealed class CompleteHobby extends PricedHobby {
    public String link;
    public double accessibility;
}

With this model now in place, you can create the com.redhat.developers.ActivityService interface used to consume data from the Bored API. This interface should be annotated with @RegisterRestClient and contain only the last part of the path:

@RegisterRestClient
@Path("/api/activity")
public interface ActivityService {

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    BasicHobby getActivityByType(@QueryParam("type") String type);

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    PricedHobby getActivity();


    @GET
    @Produces(MediaType.APPLICATION_JSON)
    CompleteHobby getActivityByAccessibility(@QueryParam("minaccessibility") double minaccessibility, @QueryParam("maxaccessibility") double maxaccessibility);

}

Configure the URL in src/main/resources/application.properties:

com.redhat.developers.ActivityService/mp-rest/url=https://www.boredapi.com

Control API response using yield in switch expressions

Occasionally, API endpoint implementations can return different response codes based on the parameters received. For instance, consider a scenario where the sample-app API does not support searching for an activity by type, except recreational and drawing activities. However, the drawing activity type has no content in the Bored API. In case the consumed API supports other activity types, the response should be 501 Not Implemented status, or else it will return that the service is not available.

This scenario can be handled in a method implementation by using a switch expression that evaluates activity type:

@Path("actions")
public class HobbyResource {

    private static final Logger LOGGER = LoggerFactory.getLogger(HobbyResource.class);


    @RestClient
    @Inject
    ActivityService service;


    @GET
    @Path("{type}")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getHobbyByType(@PathParam("type") String type) {
       return switch(type){
           case "recreational" -> Response.status(OK)
                        .entity(service.getActivityByType(type)).build();
           case "drawing" -> Response.status(NO_CONTENT)
                       .entity(BasicHobby.empty()).build();
           default -> {
               BasicHobby hobby = service.getActivityByType(type);
               yield ((hobby.participants > 0) ? Response.status(NOT_IMPLEMENTED).build()
                       : invokeServiceUnavailable(type));
           }
        };
    }


    private Response invokeServiceUnavailable(String type) {
        LOGGER.debug(String.format("Type specified is not supported %s", type));
        return Response.status(SERVICE_UNAVAILABLE).entity(BasicHobby.empty()).build();
    }
}

For drawing and recreational cases we know the target type, so a single expression is used to the right of the case ->.  As the default scenario requires a block of code, a yield statement is used to yield a value, which becomes the value of the enclosing switch expression. Since Java 13, it's been possible to use a yield statement in place of a break statement with a value.

Using records when implementing fault tolerance

When you're consuming an external API, it's always good to have an alternative solution to avoid implementing a single point of failure. To see how this can work, introduce an error in the example implementation by adding an s to the end of the URL for the consumed service in src/main/resources/application.properties:

com.redhat.developers.ActivityService/mp-rest/url=https://www.boredapis.com

If you were to invoke any of the endpoints after making this change, they would throw an UnknownHostException, as nothing is currently hosted at boredapis.com. In order to prevent continuous invocation of a failed endpoint, you must annotate the endpoints with @CircuitBreaker, @Timeout, and/or @Retry:

@RegisterRestClient
@Path("/api/activity")
public interface ActivityService {

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @CircuitBreaker(failureRatio=0.75, delay = 1000 )
    @Timeout(150)
    @Retry(maxRetries = 4, delay = 100)
    BasicHobby getActivityByType(@QueryParam("type") String type);
}

The failure can be handled further using the @Fallback annotation. You could even offer a response while the API is unavailable by implementing a custom handler class:

@RegisterRestClient
@Path("/api/activity")
public interface ActivityService {

    @GET
    @Produces(MediaType.APPLICATION_JSON)
    @CircuitBreaker(failureRatio=0.75, delay = 1000 )
    @Timeout(150)
    @Retry(maxRetries = 4, delay = 100)
    @Fallback(DefaultBasicHobby.class)
    BasicHobby getActivityByType(@QueryParam("type") String type);

}

If you were using Java 8 or 11, DefaultBasicHobby would have been a public static class or an inner class of the ActivityService interface. But with Java 17, we can use records to implement DefaultBasicHobby inside ActivityService:

   record DefaultBasicHobby() implements FallbackHandler<BasicHobby> {
        @Override
        public BasicHobby handle(ExecutionContext executionContext) {
            return BasicHobby.empty();
        }
    }

Generate container images using Jib

When you created the sample-app project using the Maven plugin, Dockerfiles were also generated in src/main/docker. However, those were tailored to create container images that include JDK 11. The easiest way to generate a container image that includes JDK 17 without rewriting the Dockerfiles is by using quarkus-container-image-jib. This extension builds container images and is powered by Jib.

If you wanted to generate a container image build and push it to an image registry, you could try the following configuration:

quarkus.container-image.builder=jib

#base image to be used when a container image is being produced 
#for the jar build
quarkus.jib.base-jvm-image=openjdk:17-jdk-alpine


#registry and image details
quarkus.container-image.registry=quay.io
quarkus.container-image.group=anasandbox
quarkus.container-image.tag=jvm-1.0

#automatically pushes the image when packaging the app
quarkus.container-image.push=true

Packaging the application would produce the container image and push it to the registry:

./mvnw clean package

But Quarkus is a Kubernetes-native Java framework, so you can also compile to native and build a native image. The native executable for our application will contain the application code, all required libraries, Java APIs, and a reduced version of a VM.

For the native image build, replace the quarkus.jib.base-jvm-image configuration with quarkus.jib.base-native-image:

quarkus.container-image.builder=jib

#base image to be used when a container image is being produced 
#for the native build
quarkus.jib.base-native-image=registry.access.redhat.com/ubi8/ubi-minimal:8.5


#registry and image details
quarkus.container-image.registry=quay.io
quarkus.container-image.group=anasandbox
quarkus.container-image.tag=native-1.0

#automatically pushes the image when packaging the app
quarkus.container-image.push=true

If your operating system is not based on Linux, when packaging to native you should also specify the docker image to use for the local build:

./mvnw package -Pnative \
-Dquarkus.native.container-build=true \
-Dquarkus.native.builder-image=quay.io/quarkus/ubi-quarkus-native-image:21.3-java17

This step is necessary to produce a build for a Linux binary. Without it, you'll get the following error when packaging or running the container:

The native binary produced by the build is not a Linux binary and therefore cannot be used in a Linux container image.

Conclusion

Upgrading an application to JDK 17 is just the beginning. You can speed up your integration checks when upgrading by using Quarkus Dev Services to automatically provision services in development and test mode. Moreover, when you develop with Quarkus Continuous Testing enabled, you will be able to work while tests run immediately after code changes have been saved.

The best way to try Quarkus with Java 17 is by using them together in different scenarios. If you are interested in Quarkus examples with other Java 11+ language features or learning how to tailor smaller container images, please leave a comment below.

Last updated: October 6, 2022