Decorating a client
Table of contents
Implementing DecoratingHttpClientFunction and DecoratingRpcClientFunction
Extending SimpleDecoratingHttpClient and SimpleDecoratingRpcClient
A 'decorating client' (or a 'decorator') is a client that wraps another client to intercept an outgoing request or an incoming response. As its name says, it is an implementation of the decorator pattern. Client decoration takes a crucial role in Armeria. A lot of core features such as logging, metrics and distributed tracing are implemented as decorators and you will also find it useful when separating concerns.
There are basically two ways to write a decorating client:
- Implementing
DecoratingHttpClientFunction
andDecoratingRpcClientFunction
- Extending
SimpleDecoratingClient
Implementing DecoratingHttpClientFunction
and DecoratingRpcClientFunction
DecoratingHttpClientFunction
and DecoratingRpcClientFunction
are functional interfaces that
greatly simplify the implementation of a decorating client.
They enable you to write a decorating client with a single lambda expression:
import com.linecorp.armeria.common.HttpRequest;
import com.linecorp.armeria.common.HttpResponse;
ClientBuilder cb = Clients.builder(...);
...
cb.decorator((delegate, ctx, req) -> {
auditRequest(req);
return delegate.execute(ctx, req);
});
MyService.Iface client = cb.build(MyService.Iface.class);
Extending SimpleDecoratingHttpClient
and SimpleDecoratingRpcClient
If your decorator is expected to be reusable, it is recommended to define a new top-level class that extends
SimpleDecoratingHttpClient
or SimpleDecoratingRpcClient
depending on whether
you are decorating an HttpClient
or an RpcClient
:
import com.linecorp.armeria.client.HttpClient;
import com.linecorp.armeria.client.SimpleDecoratingHttpClient;
public class AuditClient extends SimpleDecoratingHttpClient {
public AuditClient(HttpClient delegate) {
super(delegate);
}
@Override
public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception {
auditRequest(req);
return unwrap().execute(ctx, req);
}
}
ClientBuilder cb = Clients.builder(...);
...
// Using a lambda expression:
cb.decorator(delegate -> new AuditClient(delegate));
The order of decoration
The decorators are executed in reverse order of the insertion. The following example shows which order the decorators are executed by printing the messages.
import com.linecorp.armeria.client.WebClient;
ClientBuilder cb = Clients.builder(...);
// #2 decorator
cb.decorator((delegate, ctx, req) -> {
// This is called from #1 decorator.
System.err.println("Secondly, executed.");
return delegate.execute(ctx, req);
});
// #1 decorator.
// No matter decorator() or option() is used, decorators are executed in reverse order of the insertion.
cb.option(ClientOption.DECORATION, ClientDecoration.of((delegate, ctx, req) -> {
System.err.println("Firstly, executed");
return delegate.execute(ctx, req); // The delegate, here, is #2 decorator.
// This will call execute(ctx, req) method on #2 decorator.
});
WebClient myClient = cb.build(WebClient.class);
The following diagram describes how an HTTP request and HTTP response are gone through decorators:
If the client is a Thrift client and RPC decorators are used, HTTP decorators and RPC decorators are separately grouped and executed in reverse order of the insertion:
ClientBuilder cb = Clients.builder(...);
// #2 HTTP decorator.
cb.decorator((delegate, ctx, httpReq) -> {
System.err.println("Fourthly, executed.");
...
});
// #2 RPC decorator.
cb.rpcDecorator((delegate, ctx, rpcReq) -> {
System.err.println("Secondly, executed.");
...
});
// #1 HTTP decorator.
cb.decorator((delegate, ctx, httpReq) -> {
System.err.println("Thirdly, executed.");
...
});
// #1 RPC decorator.
cb.rpcDecorator((delegate, ctx, rpcReq) -> {
System.err.println("Firstly, executed.");
...
});
An RPC request is converted into an HTTP request before it's sent to a server. Therefore, RPC decorators are applied before the RPC request is converted and HTTP decorators are applied after the request is converted into the HTTP request, as described in the following diagram:
If the decorator modifies the response (e.g. DecodingClient
) or spawns more requests
(e.g. RetryingClient
), the outcome may be different depending on the order of the decorators.
Let's look at the following example that DecodingClient
and ContentPreviewingClient
are used together:
import com.linecorp.armeria.client.encoding.DecodingClient;
import com.linecorp.armeria.client.logging.ContentPreviewingClient;
ClientBuilder cb = Clients.builder(...);
cb.decorator(DecodingClient.newDecorator());
// ContentPreviewingClient should be applied after DecodingClient.
cb.decorator(ContentPreviewingClient.newDecorator(1000));
DecodingClient
decodes the content of HTTP responses. ContentPreviewingClient
is
Enabling content previews
of the HTTP response by setting it to the RequestLog
.
Because it's evaluated after DecodingClient
from the point of view of an
HTTP response, which means that the response content is set after it's decoded, you will see the decoded
response content preview. If the two decorators are added in the opposite order, you will get the encoded
preview because ContentPreviewingClient
is evaluated first for the HTTP response.
For RetryingClient
, please check out
RetryingClient
with logging.
Modifying responses
We can modify the responses in a decorator via the HttpResponse.mapHeaders()
,
HttpResponse.mapData()
, HttpResponse.mapInformational()
or mapTrailers(Function)
methods, or, if more advanced/stateful
processing is necessary, by wrapping the HttpResponse
in a FilteredHttpResponse
.
Using transforming methods
import com.linecorp.armeria.client.ClientBuilder;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.common.HttpData;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicBoolean;
ClientBuilder cb = Clients.builder("http");
cb.decorator((delegate, ctx, req) -> {
final AtomicBoolean initialHttpData = new AtomicBoolean(true);
return delegate
.execute(ctx, req)
.mapHeaders(headers -> headers.toBuilder().add("x-foo", "foobar").build())
.mapTrailers(trailers -> ...)
.mapInformational(informationalHeader -> ...)
.mapData((httpData) -> {
HttpData result = httpData;
if (initialHttpData.get()) {
initialHttpData.set(false);
byte[] ascii = "{\"foo\": \"foobar\",".getBytes(StandardCharsets.US_ASCII);
byte[] combined = Arrays.copyOf(ascii, ascii.length + httpData.length() - 1);
System.arraycopy(httpData.array(), 1, combined, ascii.length, httpData.length());
result = HttpData.wrap(combined);
}
return result;
});
});
Using FilteredHttpResponse
:
A couple of caveats to note here.
- Informational response headers may occur zero or more times.
HttpData
will almost always just be a part of the whole response data unless the response has been passed throughHttpResponse.aggregate()
.
import com.linecorp.armeria.client.ClientBuilder;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.common.FilteredHttpResponse;
import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpHeaders;
import com.linecorp.armeria.common.HttpObject;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.ResponseHeaders;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
ClientBuilder cb = Clients.builder(...);
cb.decorator((delegate, ctx, req) -> {
HttpResponse response = delegate.execute(ctx, req);
return new FilteredHttpResponse(response) {
boolean initialHttpData = true;
protected HttpObject filter(HttpObject obj) {
if (obj instanceof ResponseHeaders) {
// Can receive multiple ResponseHeaders whose status is informational.
ResponseHeaders responseHeaders = (ResponseHeaders) obj;
if (responseHeaders.status().isInformational()) {
return obj;
}
// Add a "x-foo" header into the response.
return responseHeaders.toBuilder()
.add("x-foo", "foobar")
.build();
} else if (obj instanceof HttpData) {
HttpData httpData = (HttpData) obj;
// Example of how to insert attribute into json payload.
// initial '{' in response is assumed to be first byte
if (initialHttpData) {
initialHttpData = false;
byte[] ascii = "{\"foo\": \"foobar\",".getBytes(StandardCharsets.US_ASCII);
byte[] combined = Arrays.copyOf(ascii, ascii.length + httpData.length() - 1);
System.arraycopy(httpData.array(), 1, combined, ascii.length, httpData.length());
return HttpData.wrap(combined);
}
} else {
// Http trailers.
assert obj instanceof HttpHeaders;
}
return obj;
}
};
});
Reacting to or logging responses
If modification is not required, e.g. when handling quota responses from the service, and it doesn't need to
look at the HttpData
, it can be achieved in a more performant way via
ClientRequestContext.log().whenAvailable(RequestLogProperty).thenAccept(...)
. Accessing the
ClientRequestContext
can be done via either wrapping in a decorator (and using the ctx or by using
ClientRequestContextCaptor
:
import com.linecorp.armeria.client.ClientRequestContextCaptor;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.common.logging.RequestLogProperty;
HttpResponse res;
try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
res = webClient.get(path);
captor.get().log().whenAvailable(RequestLogProperty.RESPONSE_HEADERS)
.thenAccept(log -> {
System.err.println(log.responseHeaders());
});
}
See Structured Logging to learn more about the properties you can retrieve.