1. Introduction

In this article, we’ll look at Armeria – a flexible framework for efficiently building microservices. We’ll see what it is, what we can do with it, and how to use it.

At its simplest, Armeria offers us an easy way to build microservice clients and servers that can communicate using a variety of protocols – including REST, gRPC, Thrift, and GraphQL. However, Armeria also offers integrations with many other technologies of many different kinds.

For example, we have support for using Consul, Eureka, or Zookeeper for service discovery, for Zipkin for distributed tracing, or for integrating with frameworks such as Spring Boot, Dropwizard, or RESTEasy

2. Dependencies

Before we can use Armeria, we need to include the latest version in our build, which is 1.29.2 at the time of writing.

JetCache comes with several dependencies that we need, depending on our exact needs. The core dependency for the functionality is in com.linecorp.armeria:armeria.

If we’re using Maven, we can include this in pom.xml:

<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria</artifactId>
    <version>1.29.2</version>
</dependency>

We also have many other dependencies that we can use for integration with other technologies, depending on exactly what we’re doing.

2.1. BOM Usage

Due to the large number of dependencies that Armeria offers, we also have the option to use a Maven BOM for managing all of the versions. We make use of this by adding an appropriate dependency management section to our project:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.linecorp.armeria</groupId>
            <artifactId>armeria-bom</artifactId>
            <version>1.29.2</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Once we’ve done this, we can include whichever Armeria dependencies we need without needing to worry about defining versions for them:

<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria</artifactId>
</dependency>

This doesn’t seem very useful when we’re only using one dependency, but as the number grows this becomes useful very quickly.

3. Running a Server

Once we’ve got the appropriate dependencies, we can start using Armeria. The first thing we’ll look at is running an HTTP Server.

Armeria offers us the ServerBuilder mechanism to configure our server. We can configure this, and then build a Server to launch. The absolute minimum we need for this is:

ServerBuilder sb = Server.builder();
sb.service("/handler", (ctx, req) -> HttpResponse.of("Hello, world!"));

Server server = sb.build();
CompletableFuture<Void> future = server.start();
future.join();

This gives us a working server, running on a random port with a single, hard-coded handler. We’ll see more about how to configure all of this soon.

When we start running our program, the output tells us that the HTTP server is running:

07:36:46.508 [main] INFO com.linecorp.armeria.common.Flags -- verboseExceptions: rate-limit=10 (default)
07:36:46.957 [main] INFO com.linecorp.armeria.common.Flags -- useEpoll: false (default)
07:36:46.971 [main] INFO com.linecorp.armeria.common.Flags -- annotatedServiceExceptionVerbosity: unhandled (default)
07:36:47.262 [main] INFO com.linecorp.armeria.common.Flags -- Using Tls engine: OpenSSL BoringSSL, 0x1010107f
07:36:47.321 [main] INFO com.linecorp.armeria.common.util.SystemInfo -- hostname: k5mdq05n (from 'hostname' command)
07:36:47.399 [armeria-boss-http-*:49167] INFO com.linecorp.armeria.server.Server -- Serving HTTP at /[0:0:0:0:0:0:0:0%0]:49167 - http://127.0.0.1:49167/

Amongst other things, we can now clearly see not only that the server is running but also what address and port it’s listening on.

3.1. Configuring The Server

We have a number of ways that we can configure our server before starting it.

The most useful of these is to specify the port that our server should listen on. Without this, the server will simply pick a randomly available port on startup.

Specifying the port for HTTP is done using the ServerBuilder.http() method:

ServerBuilder sb = Server.builder();
sb.http(8080);

Alternatively, we can specify that we want an HTTPS port using ServerBuilder.https(). However, before we can do this we’ll also need to configure our TLS certificates. Armeria offers all of the usual standard support for this, but also offers a helper for automatically generating and using a self-signed certificate:

ServerBuilder sb = Server.builder();
sb.tlsSelfSigned();
sb.https(8443);

3.2. Adding Access Logging

By default, our server won’t do any form of logging of incoming requests. This is often fine. For example, if we’re running our services behind a load balancer or other form of proxy that itself might do the access logging.

However, if we want to, then we can add logging support to our service directly. This is done using the ServerBuilder.accessLogWriter() method. This takes an AccessLogWriter instance, which is a SAM interface if we wish to implement it ourselves.

Armeria provides us with some standard implementations that we can use as well, with some standard log formats – specifically, the Apache Common Log and Apache Combined Log formats:

// Apache Common Log format
sb.accessLogWriter(AccessLogWriter.common(), true);
// Apache Combined Log format
sb.accessLogWriter(AccessLogWriter.combined(), true);

Armeria will write these out using SLF4J, utilizing whichever logging backend we have already configured for our application:

07:25:16.481 [armeria-common-worker-kqueue-3-2] INFO com.linecorp.armeria.logging.access -- 0:0:0:0:0:0:0:1%0 - - 17/Jul/2024:07:25:16 +0100 "GET /#EmptyServer$$Lambda/0x0000007001193b60 h1c" 200 13
07:28:37.332 [armeria-common-worker-kqueue-3-3] INFO com.linecorp.armeria.logging.access -- 0:0:0:0:0:0:0:1%0 - - 17/Jul/2024:07:28:37 +0100 "GET /unknown#FallbackService h1c" 404 35

4. Adding Service Handlers

Once we have a server, we need to add handlers to it for it to be of any use. Out of the box, Armeria comes with support for adding standard HTTP request handlers in various forms. We can also add handlers for gRPC, Thrift, or GraphQL requests, though we need additional dependencies to support those.

4.1. Simple Handlers

The simplest way to register handlers is to use the ServerBuilder.service() method. This takes a URL pattern and anything that implements the HttpService interface and serves up this whenever a request comes in matching the provided URL pattern:

sb.service("/handler", handler);

The HttpService interface is a SAM interface, meaning we’re able to implement it either with a real class or directly in place with a lambda:

sb.service("/handler", (ctx, req) -> HttpResponse.of("Hello, world!"));

Our handler must implement the HttpResponse HttpService.serve(ServiceRequestContext, HttpRequest) method – either explicitly in a subclass or implicitly as a lambda. Both the ServiceRequestContext and HttpRequest parameters exist to give access to different aspects of the incoming HTTP request, and the HttpResponse return type represents the response sent back to the client.

4.2. URL Patterns

Armeria allows us to mount our services using a variety of different URL patterns, giving us the flexibility to access our handlers however we need.

The most straightforward way is to use a simple string – /handler, for example – which represents this exact URL path.

However, we can also use path parameters using either curly-brace or colon-prefix notation:

sb.service("/curly/{name}", (ctx, req) -> HttpResponse.of("Hello, " + ctx.pathParam("name")));
sb.service("/colon/:name", (ctx, req) -> HttpResponse.of("Hello, " + ctx.pathParam("name")));

Here, we’re able to use ServiceRequestContext.pathParam() to get the value that was actually present in the incoming request for the named path parameter.

We can also use glob matches to match an arbitrary structured URL but without explicit path parameters. When we do this, we must use a prefix of “*glob:*” to indicate what we’re doing, and then we can use “*” to represent a single URL segment, and “**” to represent an arbitrary number of URL segments – including zero:

ssb.service("glob:/base/*/glob/**", 
  (ctx, req) -> HttpResponse.of("Hello, " + ctx.pathParam("0") + ", " + ctx.pathParam("1")));

This will match URLs of “*/base/a/glob“, “/base/a/glob/b” or even “/base/a/glob/b/c/d/e” but not “/base/a/b/glob/c*“. We can also access our glob patterns as path parameters, named after their position. ctx.pathParam(“0”) matches the “*” portion of this URL, and ctx.pathParam(“1”) matches the “**” portion of the URL.

Finally, we can use regular expressions to gain more precise control over what’s matched. This is done using the “*regex:*” prefix, after which the entire URL pattern is a regex to match against the incoming requests:

sb.service("regex:^/regex/[A-Za-z]+/[0-9]+$",
  (ctx, req) -> HttpResponse.of("Hello, " + ctx.path()));

When using regexes, we can also provide names to capturing groups to make them available as path params:

sb.service("regex:^/named-regex/(?<name>[A-Z][a-z]+)$",
  (ctx, req) -> HttpResponse.of("Hello, " + ctx.pathParam("name")));

This will make our URL match the provided regex, and expose a path parameter of “name” corresponding to our group – a single capital letter followed by 1-or-more lowercase letters.

4.3. Configuring Handler Mappings

We’ve so far seen how to do simple handler mappings. Our handlers will react to any calls to the given URL, regardless of HTTP method, headers, or anything else.

We can be much more specific in how we want to match incoming requests using a fluent API. This can allow us to only trigger handlers for very specific calls. We do this using the ServerBuilder.route() method:

sb.route()
  .methods(HttpMethod.GET)
  .path("/get")
  .produces(MediaType.PLAIN_TEXT)
  .matchesParams("name")
  .build((ctx, req) -> HttpResponse.of("Hello, " + ctx.path()));

This will only match GET requests that are able to accept text/plain responses and, which have a query parameter of name. We’ll also automatically get the correct errors when an incoming request doesn’t match – HTTP 405 Method Not Allowed if the request wasn’t a GET request, and HTTP 406 Not Acceptable if the request couldn’t accept text/plain responses.

5. Annotated Handlers

As we’ve seen, in addition to adding handlers directly, Armeria allows us to provide an arbitrary class with appropriately annotated methods and automatically map these methods to handlers. This can make writing complex servers much easier to manage.

These handlers are mounted using the ServerBuilder.annotatedService() method, providing an instance of our handler:

sb.annotatedService(new AnnotatedHandler());

Exactly how we construct this is up to us, meaning we can provide it with any dependencies necessary for it to work.

Within this class, we must have methods annotated with @Get@Post, @Put, @Delete or any of the other appropriate annotations. These annotations take as a parameter the URL mapping to use – following the exact same rules as before – and indicate that the annotated method is our handler:

@Get("/handler")
public String handler() {
    return "Hello, World!";
}

Note that we don’t have to follow the same method signature here as we did before. Instead, we can require arbitrary method parameters to be mapped onto the incoming request, and the response type will be mapped into an HttpResponse type.

5.1. Handler Parameters

Any parameters to our method of types ServiceRequestContext, HttpRequest, RequestHeaders, QueryParams or Cookies will be automatically provided from the request. This allows us to get access to details from the request in the same way as normal handlers:

@Get("/handler")
public String handler(ServiceRequestContext ctx) {
    return "Hello, " + ctx.path();
}

However, we can make this even easier. Armeria allows us to have arbitrary parameters annotated with @Param and these will automatically be populated from the request as appropriate:

@Get("/handler/{name}")
public String handler(@Param String name) {
    return "Hello, " + name;
}

If we compile our code with the -parameters flag, the name used will be derived from the parameter name. If not, or if we want a different name, we can provide it as a value to the annotation.

This annotation will provide our method with both path and query parameters. If the name used matches a path parameter, then this is the value provided. If not, a query parameter is used instead.

By default, all parameters are mandatory. If they can’t be provided from the request, then the handler won’t match. We can change this by using an Optional<> for the parameter, or else by annotating it with @Nullable or @Default.

5.2. Request Bodies

In addition to providing path and query parameters to our handler, we can also receive the request body. Armeria has a few ways to manage this, depending on what we need.

Any parameters of type byte[] or HttpData will be provided with the full, unmodified request body that we can do with as we wish:

@Post("/byte-body")
public String byteBody(byte[] body) {
    return "Length: " + body.length;
}

Alternatively, any String or CharSequence parameter that isn’t annotated to be used in some other way will be provided with the full request body, but in this case, it will have been decoded based on the appropriate character encoding:

@Post("/string-body")
public String stringBody(String body) {
    return "Hello: " + body;
}

Finally, if the request has a JSON-compatible content type then any parameter that’s not a byte[], HttpData, String, AsciiString, CharSequence or directly of type Object, and isn’t annotated to be used in some other way will have the request body deserialized into it using Jackson.

@Post("/json-body")
public String jsonBody(JsonBody body) {
    return body.name + " = " + body.score;
}

record JsonBody(String name, int score) {}

However, we can go a step further than this. Armeria gives us the option to write custom request converters. These are classes that implement the RequestConverterFunction interface:

public class UppercasingRequestConverter implements RequestConverterFunction {
    @Override
    public Object convertRequest(ServiceRequestContext ctx, AggregatedHttpRequest request,
        Class<?> expectedResultType, ParameterizedType expectedParameterizedResultType)
        throws Exception {

        if (expectedResultType.isAssignableFrom(String.class)) {
            return request.content(StandardCharsets.UTF_8).toUpperCase();
        }

        return RequestConverterFunction.fallthrough();
    }
}

Our converter can then do anything it needs to with full access to the incoming request to produce the desired value. If we can’t do this—because the request doesn’t match the parameter, for example—then we return RequestConverterFunction.fallthrough() to cause Armeria to carry on with the default processing.

We then need to ensure the request converter is used. This is done using the @RequestConverter annotation, attached to either the handler class, handler method, or the parameter in question:

@Post("/uppercase-body")
@RequestConverter(UppercasingRequestConverter.class)
public String uppercaseBody(String body) {
    return "Hello: " + body;
}

5.3. Responses

In much the same way as requests, we can also return arbitrary values from our handler function to be used as the HTTP response.

If we directly return an HttpResponse object, then this will be the complete response. If not, Armeria will convert the actual return value into the correct type.

By standard, Armeria is capable of a number of standard conversions:

  • null as an empty response body with an HTTP 204 No Content status code.
  • byte[] or HttpData as raw bytes with an application/octet-stream content type.
  • Anything implementing CharSequence – which includes String – as UTF-8 text content with a text/plain content type.
  • Anything implementing JsonNode from Jackson as JSON with an application/json content type.

In addition, if the handler method is annotated with @ProducesJson or @Produces(“application/json”) then any return value will be converted to JSON using Jackson:

@Get("/json-response")
@ProducesJson
public JsonBody jsonResponse() {
    return new JsonBody("Baeldung", 42);
}

Further to this, we can also write our own custom response converters similar to how we wrote our custom request converter. These implement the ResponseConverterFunction interface. This is called with the return value from our handler function and must return an HttpResponse object:

public class UppercasingResponseConverter implements ResponseConverterFunction {
    @Override
    public HttpResponse convertResponse(ServiceRequestContext ctx, ResponseHeaders headers,
        @Nullable Object result, HttpHeaders trailers) {
        if (result instanceof String) {
            return HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8,
              ((String) result).toUpperCase(), trailers);
        }

        return ResponseConverterFunction.fallthrough();
    }
}

As before, we can do anything we need to produce the desired response. If we’re unable to do so – e.g. because the return value is of the wrong type – then the call to ResponseConverterFucntion.fallthrough() ensures that the standard processing is used instead.

Similar to request converters, we need to annotate our function with @ResponseConverter to tell it to use our new response converter:

@Post("/uppercase-response")
@ResponseConverter(UppercasingResponseConverter.class)
public String uppercaseResponse(String body) {
    return "Hello: " + body;
}

We can apply this to either the handler method or the class as a whole

5.4. Exceptions

In addition to being able to convert arbitrary responses to an appropriate HTTP response, we can also handle exceptions however we wish.

By default, Armeria will handle a few well-known exceptions. IllegalArgumentException produces an HTTP 400 Bad Request, and HttpStatusException and HttpResponseException are converted into the HTTP response they represent. Anything else will produce an HTTP 500 Internal Server Error response.

However, as with return values from our handler function, we can also write converters for exceptions. These implement the ExceptionHandlerFunction, which takes the thrown exception as input and returns the HTTP response for the client:

public class ConflictExceptionHandler implements ExceptionHandlerFunction {
    @Override
    public HttpResponse handleException(ServiceRequestContext ctx, HttpRequest req, Throwable cause) {
        if (cause instanceof IllegalStateException) {
            return HttpResponse.of(HttpStatus.CONFLICT);
        }

        return ExceptionHandlerFunction.fallthrough();
    }
}

As before, this can do whatever it needs to produce the correct response or return ExceptionHandlerFunction.fallthrough() to fall back to the standard handling.

And, as before, we wire this in using the @ExceptionHandler annotation on either our handler class or method:

@Get("/exception")
@ExceptionHandler(ConflictExceptionHandler.class)
public String exception() {
    throw new IllegalStateException();
}

6. GraphQL

So far, we’ve examined how to set up RESTful handlers with Armeria. However, it can do much more than this, including GraphQL, Thrift, and gRPC.

In order to use these additional protocols, we need to add some extra dependencies. For example, adding a GraphQL handler requires that we add the com.linecorp.armeria:armeria-graphql dependency to our project:

<dependency>
    <groupId>com.linecorp.armeria</groupId>
    <artifactId>armeria-graphql</artifactId>
</dependency>

Once we’ve done this, we can expose a GraphQL schema using Armeria by using the GraphqlService:

sb.service("/graphql",
  GraphqlService.builder().graphql(buildSchema()).build());

This takes a GraphQL instance from the GraphQL java library, which we can construct however we wish, and exposes it on the specified endpoint.

7. Running a Client

In addition to writing server components, Armeria allows us to write clients that can communicate with these (or any) servers.

In order to connect to HTTP services, we use the WebClient class that comes with the core Armeria dependency. We can use this directly with no configuration to easily make outgoing HTTP calls:

WebClient webClient = WebClient.of();
AggregatedHttpResponse response = webClient.get("http://localhost:8080/handler")
  .aggregate()
  .join();

The call here to WebClient.get() will make an HTTP GET request to the provided URL, which then returns a streaming HTTP response. We then call HttpResponse.aggregate() to get a CompletableFuture for the fully resolved HTTP response once it’s complete.

Once we’ve got the AggregatedHttpResponse, we can then use this to access the various parts of the HTTP response:

System.out.println(response.status());
System.out.println(response.headers());
System.out.println(response.content().toStringUtf8());

If we wish, we can also create a WebClient for a specific base URL:

WebClient webClient = WebClient.of("http://localhost:8080");
AggregatedHttpResponse response = webClient.get("/handler")
  .aggregate()
  .join();

This is especially beneficial when we need to provide the base URL from configuration, but our application can understand the structure of the API we’re calling underneath this.

We can also use this client to make other requests. For example, we can use the WebClient.post() method to make an HTTP POST request, providing the request body as well:

WebClient webClient = WebClient.of();
AggregatedHttpResponse response = webClient.post("http://localhost:8080/uppercase-body", "baeldung")
  .aggregate()
  .join();

Everything else about this request is exactly the same, including how we handle the response.

7.1. Complex Requests

We’ve seen how to make simple requests, but what about more complex cases? The methods that we’ve seen so far are actually just wrappers around the execute() method, which allows us to provide a much more complicated representation of an HTTP request:

WebClient webClient = WebClient.of("http://localhost:8080");

HttpRequest request = HttpRequest.of(
  RequestHeaders.builder()
    .method(HttpMethod.POST)
    .path("/uppercase-body")
    .add("content-type", "text/plain")
    .build(),
  HttpData.ofUtf8("Baeldung"));
AggregatedHttpResponse response = webClient.execute(request)
  .aggregate()
  .join();

Here we can see how to specify all the different parts of the outgoing HTTP request in as much detail as we need.

We also have some helper methods to make this easier. For example, instead of using add() to specify arbitrary HTTP headers, we can use methods such as contentType(). These are more obvious to use, but also more type-safe:

HttpRequest request = HttpRequest.of(
  RequestHeaders.builder()
    .method(HttpMethod.POST)
    .path("/uppercase-body")
    .contentType(MediaType.PLAIN_TEXT_UTF_8)
    .build(),
  HttpData.ofUtf8("Baeldung"));

We can see here that the contentType() method requires a MediaType object rather than a plain string, so we know we’re passing in the correct values.

7.2. Client Configuration

There are also a number of configuration parameters that we can use to tune the client itself. We can configure these by using a ClientFactory when we construct our WebClient.

ClientFactory clientFactory = ClientFactory.builder()
  .connectTimeout(Duration.ofSeconds(10))
  .idleTimeout(Duration.ofSeconds(60))
  .build();
WebClient webClient = WebClient.builder("http://localhost:8080")
  .factory(clientFactory)
  .build();

Here, we configure our underlying HTTP client to have a 10-second timeout when connecting to a URL and to close an open connection in our underlying connection pool after 60 seconds of inactivity.

8. Conclusion

In this article, we’ve given a brief introduction to Armeria. This library can do much more, so why not try it out and see?

All of the examples are available over on GitHub.