-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Filter design doc
This document describes the design choices made in the new filter API and implementation in Micronaut HTTP server 4.
There has been a filter API since version 1. It looks like this:
public interface HttpFilter extends Ordered {
Publisher<? extends HttpResponse<?>> doFilter(HttpRequest<?> request, FilterChain chain);
}
The basic usage is like this:
public class MyFilter implements HttpServerFilter {
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, FilterChain chain) {
System.out.println("Request: " + request);
return Mono.from(chain.proceed(request)).doOnSuccess(response -> {
System.out.println("Response: " + response);
});
}
}
This design is very similar to that of the servlet API, except it's reactive. You do the request processing, call into a filter chain, get a response back, do the response processing, and finally return the response.
While the old API was very powerful, this power was rarely needed. Most filters only modify the request or the response. Accommodating for the full power of the old API leads to bad performance due to deep call stacks and the requirement to use reactive code. We were also limited in what convenience features we could provide to filters.
The design goals of the new filter API were thus:
- Make use of reactive APIs optional, allow for alternate threading models (i.e. blocking code)
- Reduce call stack depth where possible
- Make filters annotation-driven with flexible argument binders (e.g.
@Body
) - Reduce clutter
- Retain the power of the old API
Goal 3 is fairly simple, so I won't go into it, but it is a prerequisite for achieving the other goals as well.
The main change made by the new filter API is to decompose filters into their individual pieces. A HttpFilter
as
shown above consists of three parts:
- The request processing
- The
FilterChain.proceed
call - The response processing
The filter has full control in steps 1 and 3: It can update or replace the request and response. It can call proceed
on another thread, at another time, or not at all (skipping step 2). It has (almost) full freedom.
In the new API, step 2 is realized through a FilterContinuation
. A continuation allows calling downstream filters and
controllers, and intercepting the response. FilterContinuation
improves on FilterChain
in that it allows for
optional blocking use and more flexible response types. A blocking filter could look like this:
@RequestFilter
@ExecuteOn(BLOCKING)
public HttpResponse<?> filter(HttpRequest<?> request, FilterContinuation<HttpResponse<?>> continuation) {
System.out.println("Request: " + request);
HttpResponse<?> response = continuation.proceed();
System.out.println("Response: " + response);
return response;
}
This approach allows us to achieve design goal 1, making reactive code optional, while still maintaining the full power
of the old API. The filter can still be reactive if preferred, by changing the FilterContinuation<HttpResponse<?>>
to
a FilterContinuation<Publisher<HttpResponse<?>>>
, which will work like the old FilterChain
.
The next step is to declutter filters. Let's say we have this simple filter:
@RequestFilter
@ExecuteOn(BLOCKING)
public HttpResponse<?> filter(HttpRequest<?> request, FilterContinuation<HttpResponse<?>> continuation) {
System.out.println("Request: " + request);
return continuation.proceed();
}
This pattern, where only the request is used, is very common. We can make a seemingly simple change:
@RequestFilter
@ExecuteOn(BLOCKING)
public void filter(HttpRequest<?> request) {
System.out.println("Request: " + request);
}
If the framework sees that the request filter does not have a continuation argument, it will add a "virtual" proceed
call when the filter method is done, calling any further filters and the controller. But internally, the implementation
now has much more room to optimize. No longer does it have to call downstream filters recursively. To give a pseudocode
example, we can go from:
Iterator<Filter> itr = filters.iterator();
HttpResponse<?> runNext() {
if (itr.hasNext()) {
FilterContinuation<HttpResponse<?>> continuation = this::runNext; // recursively call next filter
return itr.next().run(request, continuation);
} else {
return controller.run(request);
}
}
to this:
for (Filter filter : filters) {
filter.run(request);
}
return controller.run(request);
This improves performance considerably, and reduces call stack depth. At the same time, this optimization is transparent to the user: They can take their filter, add the continuation to it again, and it will behave as before (though maybe slower). We've achieved goals 2 and 4, improving performance and reducing clutter, again without compromising the power of the API.
Analogously to the request filter, let's consider this filter:
@RequestFilter
@ExecuteOn(BLOCKING)
public HttpResponse<?> filter(FilterContinuation<HttpResponse<?>> continuation) {
HttpResponse<?> response = continuation.proceed();
System.out.println("Response: " + response);
return response;
}
We can take a similar decluttering approach, and simplify this to:
@ResponseFilter
@ExecuteOn(BLOCKING)
public void filter(HttpResponse<?> response) {
System.out.println("Response: " + response);
}
To distinguish this from the request filter, this method is annotated as @ResponseFilter
. But other than that, this
response filter behaves just like code in a request filter after a continuation. Again, the framework can take
advantage of this to shrink call stacks and improve performance.
With these two annotations, we can split our initial example:
public class MyFilter implements HttpServerFilter {
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, FilterChain chain) {
System.out.println("Request: " + request);
return Mono.from(chain.proceed(request)).doOnSuccess(response -> {
System.out.println("Response: " + response);
});
}
}
into two pieces:
@ServerFilter
public class MyFilter {
@RequestFilter
public void filter(HttpRequest<?> request) {
System.out.println("Request: " + request);
}
@ResponseFilter
public void filter(HttpResponse<?> response) {
System.out.println("Response: " + response);
}
}
This version is not only easier to read, it's also much more performant.
Not every old-style filter can easily be split into two methods. Sometimes, it is necessary to carry some variable between the request and the response state:
public class MyFilter implements HttpServerFilter {
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, FilterChain chain) {
long start = System.nanoTime();
return Mono.from(chain.proceed(request)).doOnSuccess(response -> {
long end = System.nanoTime();
System.out.println("Request took " + (end - start) + "ns");
});
}
}
There are a few ways to tackle this. The simplest is to just use a single filter with a continuation, and not split up:
@RequestFilter
@ExecuteOn(BLOCKING)
public HttpResponse<?> filter(HttpRequest<?> request, FilterContinuation<HttpResponse<?>> continuation) {
long start = System.nanoTime();
HttpResponse<?> response = continuation.proceed();
long end = System.nanoTime();
System.out.println("Request took " + (end - start) + "ns");
return response;
}
However, this reintroduces the recursion that we want to avoid. It may be more performant to use a request attribute or other mechanism to transfer the state between the request and response filter.
Filters can be assigned an order and will run from highest to lowest precedence.
@Order(HIGHEST_PRECEDENCE)
public class A implements HttpServerFilter {
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, FilterChain chain) {
System.out.println("Request A");
return Mono.from(chain.proceed(request)).doOnSuccess(response -> {
System.out.println("Response A");
});
}
}
@Order(LOWEST_PRECEDENCE)
public class B implements HttpServerFilter {
public Publisher<MutableHttpResponse<?>> doFilter(HttpRequest<?> request, FilterChain chain) {
System.out.println("Request B");
return Mono.from(chain.proceed(request)).doOnSuccess(response -> {
System.out.println("Response B");
});
}
}
This will print:
Request A
Request B
Response B
Response A
You can see the recursion clearly there: Filter B runs "in between" the request and response processing of filter A,
inside the proceed
call.
Let's migrate that example to split filters.
@ServerFilter
@Order(HIGHEST_PRECEDENCE)
public class A {
@RequestFilter
void request() {
System.out.println("Request A");
}
@ResponseFilter
void response() {
System.out.println("Response A");
}
}
@ServerFilter
@Order(HIGHEST_PRECEDENCE)
public class B {
@RequestFilter
void request() {
System.out.println("Request B");
}
@ResponseFilter
void response() {
System.out.println("Response B");
}
}
As expected, the output is the same:
Request A
Request B
Response B
Response A
If you think about filters iteratively, this can be counterintuitive. The request filters run from highest precedence to lowest precedence, but for the response filters, it's actually the lowest precedence filter that outputs its message first.
This behavior makes sense if you consider how response filters came to be, as described above. A response filter is
actually the same as a request filter with an implicit continuation.proceed()
call at the start. So the filters with
the highest precedence are, in a way, still running first; it's just that they call proceed
immediately, delegating
to the other filters before it's their turn.