New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Override HttpRequestDecoder.createMessage()
for perfomance
#4863
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Left some preliminary comments
@jrhee17 This PR is not ready to be reviewed. Sorry about that 🙇 |
Thanks @echo304 for this 🙇 Looking forward to some performance boost! |
|
||
@Override | ||
protected HttpMessage createMessage(String[] initialLine) throws Exception { | ||
return new DefaultHttpRequest( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After some investigation, I found the logic that converts Netty request to Armeria headers.
armeria/core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
Lines 627 to 649 in 92e7017
public static RequestHeaders toArmeria( | |
ChannelHandlerContext ctx, HttpRequest in, | |
ServerConfig cfg, String scheme, RequestTarget reqTarget) throws URISyntaxException { | |
final io.netty.handler.codec.http.HttpHeaders inHeaders = in.headers(); | |
final RequestHeadersBuilder out = RequestHeaders.builder(); | |
out.sizeHint(inHeaders.size()); | |
out.method(firstNonNull(HttpMethod.tryParse(in.method().name()), HttpMethod.UNKNOWN)) | |
.scheme(scheme) | |
.path(reqTarget.toString()); | |
// Add the HTTP headers which have not been consumed above | |
toArmeria(inHeaders, out); | |
if (!out.contains(HttpHeaderNames.HOST)) { | |
// The client violates the spec that the request headers must contain a Host header. | |
// But we just add Host header to allow the request. | |
// https://datatracker.ietf.org/doc/html/rfc7230#section-5.4 | |
final String defaultHostname = cfg.defaultVirtualHost().defaultHostname(); | |
final int port = ((InetSocketAddress) ctx.channel().localAddress()).getPort(); | |
out.add(HttpHeaderNames.HOST, defaultHostname + ':' + port); | |
} | |
return out.build(); | |
} |
At the very beginning, I thought that we can implements io.netty.handler.codec.http.HttpRequest
and create our own HttpRequest implementation to replace DefaultHttpRequest
.
But with this design, we still need ArmeriaHttpUtil.toArmeria
some part of the logic because this class(HttpServerCodec) depends on netty's interface.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought that we can implements io.netty.handler.codec.http.HttpRequest and create our own HttpRequest implementation to replace DefaultHttpRequest.
I think it's the way to go. 👍 We can probably create RequestHeadersBuilder
in it and passes it in Http1RequestDecoder
.
we still need ArmeriaHttpUtil.toArmeria some part of the logic because this class(HttpServerCodec) depends on netty's interface.
Yeah, we can apply the logic at once in the Http1RequestDecoder
or we can just use the logic inside of the HttpHeaders
that is returned by HttpMessage.headers()
.
Does this make sense? 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just want to double check. ✅
Is it what we want to improve/replace?
armeria/core/src/main/java/com/linecorp/armeria/server/Http1RequestDecoder.java
Lines 173 to 175 in 361c115
// Convert the Netty HttpHeaders into Armeria RequestHeaders. | |
final RequestHeaders headers = | |
ArmeriaHttpUtil.toArmeria(ctx, nettyReq, cfg, scheme.toString(), reqTarget); |
It's a little bit difficult to understand task than I expected so I wanted to make sure I'm on the right direction. 😏
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, exactly. 👍
We can define our own DefaultHttpRequest
and return it in createMessage
:
@Override
protected HttpMessage createMessage(String[] initialLine) throws Exception {
return new ArmeriaDefaultHttpRequest(HttpMethod.valueOf(initialLine[0]), initialLine[1]);
}
static final class ArmeriaDefaultHttpRequest implements HttpRequest {
private final HttpMethod method;
private final RequestHeadersBuilder builder;
private final HttpHeaders headers;
ArmeriaDefaultHttpRequest(HttpMethod method, String uri) {
this.method = method;
builder = RequestHeaders.builder(
com.linecorp.armeria.common.HttpMethod.valueOf(method.name()), uri);
headers = new ArmeriaHttpHeaders(builder);
}
@Override
public HttpMethod method() {
return method;
}
@Override
public HttpHeaders headers() {
return headers;
}
...
}
We also need ArmeriaHttpHeaders
class and it will prevents adding not allowed headers:
final class ArmeriaHttpHeaders extends HttpHeaders {
private final RequestHeadersBuilder builder;
ArmeriaHttpHeaders(RequestHeadersBuilder builder) {
this.builder = builder;
}
@Override
public String get(String name) {
return builder.get(name);
}
@Override
public Integer getInt(CharSequence name) {
return builder.getInt(name);
}
@Override
public HttpHeaders add(String name, Object value) {
// We can add a logic that prevents adding invalid headers that is defined in ArmeriaHttpUtil.toArmeria()
return builder.add(name, value);
}
...
}
In Http1RequestDecoder
, we will bring the RequestHeadersBuilder
from the Netty request and add a host header if needed:
if (nettyReq instanceof ArmeriaDefaultHttpRequest) {
final RequestHeadersBuilder builder =
((ArmeriaDefaultHttpRequest) nettyReq).requestHeadersBuilder();
}
This is what I have imagined. 😉
Please let me know if it works or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now I started to understand the way we have to go. 😏
Let me clarify what you're saying:
with ArmeriaHttpHeaders
which extends io.netty.handler.codec.http.HttpHeaders
,
we can store actual data inside HttpHeaders
with RequestHeadersBuilder builder
.
And we can build com.linecorp.armeria.common.RequestHeaders
inside Http1RequestDecoder
with ((ArmeriaDefaultHttpRequest) nettyReq).requestHeadersBuilder();
And hopefully, RequestHeadersBuilder builder
already contains most of the data that we need, many redundant process inside toArmeria
can be removed. (especially, here 👇 )
armeria/core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
Lines 681 to 721 in 323fb45
public static void toArmeria(io.netty.handler.codec.http.HttpHeaders inHeaders, HttpHeadersBuilder out) { | |
final Iterator<Entry<CharSequence, CharSequence>> iter = inHeaders.iteratorCharSequence(); | |
// Choose 8 as a default size because it is unlikely we will see more than 4 Connection headers values, | |
// but still allowing for "enough" space in the map to reduce the chance of hash code collision. | |
final CaseInsensitiveMap connectionDisallowedList = | |
toLowercaseMap(inHeaders.valueCharSequenceIterator(HttpHeaderNames.CONNECTION), 8); | |
StringJoiner cookieJoiner = null; | |
while (iter.hasNext()) { | |
final Entry<CharSequence, CharSequence> entry = iter.next(); | |
final AsciiString aName = HttpHeaderNames.of(entry.getKey()).toLowerCase(); | |
if (HTTP_TO_HTTP2_HEADER_DISALLOWED_LIST.contains(aName) || | |
connectionDisallowedList.contains(aName)) { | |
final CharSequence value = entry.getValue(); | |
if (!maybeWebSocketUpgrade(aName, value)) { | |
continue; | |
} | |
} | |
// https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.2 makes a special exception for TE | |
if (aName.equals(HttpHeaderNames.TE)) { | |
toHttp2HeadersFilterTE(entry, out); | |
continue; | |
} | |
// Cookies must be concatenated into a single octet string. | |
// https://datatracker.ietf.org/doc/html/rfc7540#section-8.1.2.5 | |
final CharSequence value = entry.getValue(); | |
if (aName.equals(HttpHeaderNames.COOKIE)) { | |
if (cookieJoiner == null) { | |
cookieJoiner = new StringJoiner(COOKIE_SEPARATOR); | |
} | |
COOKIE_SPLITTER.split(value).forEach(cookieJoiner::add); | |
} else { | |
out.add(aName, convertHeaderValue(aName, value)); | |
} | |
} | |
if (cookieJoiner != null && cookieJoiner.length() != 0) { | |
out.add(HttpHeaderNames.COOKIE, cookieJoiner.toString()); | |
} | |
} |
Is it correct?
But my concern is that implementing own ArmeriaHttpHeaders
is pretty huge task. (Well, obviously this is unavoidable)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it correct?
That's correct. 😄
But my concern is that implementing own ArmeriaHttpHeaders is pretty huge task. (Well, obviously this is unavoidable)
Yes, it is. But we don't have to implement all of the methods. It's only used in the pipeline so we can implement the methods that are called in the handler. 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🙆♂️
Let me show you some code as soon as possible even if it's not completed!
- Custom Headers for Armeria which extends `HttpHeaders` - It delegates most of the header value storing to given `RequestHeadersBuilder`
- Custom `HttpRequest` that replaces `DefaultHttpRequest` from `HttpServerCodec`
Gentle ping, @echo304. Looking forward to this improvement! 🤞 |
# Conflicts: # core/src/main/java/com/linecorp/armeria/internal/common/ArmeriaHttpUtil.java
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good overall. 😉
final RequestHeaders headers = | ||
ArmeriaHttpUtil.toArmeria(ctx, nettyReq, cfg, scheme.toString(), reqTarget); | ||
final RequestHeaders headers; | ||
if (nettyReq instanceof ArmeriaDefaultHttpRequest) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could probably do
assert nettyReq instanceof ArmeriaDefaultHttpRequest
|
||
final ArmeriaDefaultHttpRequest armeriaDefaultHttpRequest = (ArmeriaDefaultHttpRequest) nettyReq; | ||
final HttpHeaders inHeaders = armeriaDefaultHttpRequest.headers(); | ||
|
||
builder.sizeHint(inHeaders.size()); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't need to set the sizeHint:
final ArmeriaDefaultHttpRequest armeriaDefaultHttpRequest = (ArmeriaDefaultHttpRequest) nettyReq; | |
final HttpHeaders inHeaders = armeriaDefaultHttpRequest.headers(); | |
builder.sizeHint(inHeaders.size()); |
import io.netty.handler.codec.http.HttpStatusClass; | ||
import io.netty.handler.codec.http.HttpVersion; | ||
|
||
public final class HttpServerCodec extends CombinedChannelDuplexHandler<HttpRequestDecoder, HttpResponseEncoder> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you use this class instead of Netty's one from here?
armeria/core/src/main/java/com/linecorp/armeria/server/WebSocketSessionHandler.java
Line 127 in b78d951
ctx.pipeline().remove(HttpServerCodec.class); |
@@ -0,0 +1,199 @@ | |||
package com.linecorp.armeria.internal.common; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
missing copyright
import io.netty.handler.codec.http.HttpHeaders; | ||
import io.netty.util.AsciiString; | ||
|
||
public final class ArmeriaHttpHeaders extends HttpHeaders { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can put this method in the package com.linecorp.armeria.server
and remove the public modifier.
core/src/main/java/com/linecorp/armeria/server/ArmeriaDefaultHttpRequest.java
Outdated
Show resolved
Hide resolved
import io.netty.handler.codec.http.HttpVersion; | ||
import io.netty.util.internal.ObjectUtil; | ||
|
||
public class ArmeriaDefaultHttpRequest extends DefaultHttpMessage implements HttpRequest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
public class ArmeriaDefaultHttpRequest extends DefaultHttpMessage implements HttpRequest { | |
final class ArmeriaDefaultHttpRequest extends DefaultHttpMessage implements HttpRequest { |
We can remove the public modifiers in this class.
* @param method the HTTP method of the request | ||
* @param uri the URI or path of the request | ||
*/ | ||
public ArmeriaDefaultHttpRequest(HttpVersion httpVersion, HttpMethod method, String uri) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's remove the constructor which isn't used.
* @param uri the URI or path of the request | ||
* @param givenHeaders the Headers for this Request | ||
*/ | ||
public ArmeriaDefaultHttpRequest(HttpVersion httpVersion, HttpMethod method, String uri, HttpHeaders givenHeaders) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ditto
} | ||
|
||
// @Override | ||
// public String toString() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can use MoreObjects.toStringHelper()
for toString()
.
splitByCommaAndAdd(connectionDisallowedList, lowerCased); | ||
} | ||
|
||
if (HTTP_TO_HTTP2_HEADER_DISALLOWED_LIST.contains(asciiName) || |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this line of code, this headers won't contain Transfer-Encoding
header.
As a result, below test failed because it considers corresponding request as if endOfStream
.
final boolean endOfStream = contentEmpty && !HttpUtil.isTransferEncodingChunked(nettyReq); |
So I'm trying to solve these kind of regression without copying another header.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops, I missed this comment. If so, I think we can remove the Transfer-Encoding
header in Http1RequestDecoder
after isTransferEncodingChunked
is executed. WDYT?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@minwoox Yeah, you're right.
I tried another approach that moved isTransferEncodingChunked
up before filtering out disallowed headers.
a2a2f48#diff-a3306f0289f199b1657bb0e32849fd97502c82a91318adc373f8e750a96f84ecR187-R188
builder = RequestHeaders.builder( | ||
com.linecorp.armeria.common.HttpMethod.valueOf(method.name()), uri); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But I couldn't figure out 1)why the request is mapped with DefaultFullHttpRequest and 2)the location where it's mapped.
@minwoox Can you provide some advice regarding it?
It seems like an exception is raised while converting the method. We need to fix it:
builder = RequestHeaders.builder( | |
com.linecorp.armeria.common.HttpMethod.valueOf(method.name()), uri); | |
final com.linecorp.armeria.common.HttpMethod armeriaMethod = | |
com.linecorp.armeria.common.HttpMethod.tryParse(method.name()); | |
builder = RequestHeaders.builder(armeriaMethod != null ? armeriaMethod | |
: com.linecorp.armeria.common.HttpMethod.UNKNOWN, | |
uri); |
import io.netty.handler.codec.http.HttpVersion; | ||
import io.netty.util.internal.ObjectUtil; | ||
|
||
final class ArmeriaDefaultHttpRequest extends DefaultHttpMessage implements HttpRequest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a specific reason to extend DefaultHttpMessage
? That internally creates DefaultHttpHeaders
again. Duplicate headers may result in performance degression.
final class ArmeriaDefaultHttpRequest extends DefaultHttpMessage implements HttpRequest { | |
final class ArmeriaHttp1Request implements HttpRequest { |
*/ | ||
public ArmeriaHttpHeaders(RequestHeadersBuilder builder, HttpHeaders headers) { | ||
this.builder = builder; | ||
headers.forEach(e -> add(e.getKey(), e.getValue())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This copies the Netty headers to Armeria Headers. Unfortunately, it does not match with the goal of this PR.
there is no need to copy original netty header.
What do you think of lazily creating or mutating fields when necessary?
private final HttpHeaders nettyHeaders;
@Nullable
private RequestHeaders armeriaHeaders;
public ArmeriaHttpHeaders(RequestHeadersBuilder builder, HttpHeaders headers) {
this.headers = headers;
}
@Override
public String get(String name) {
if (armeriaHeaders != null) {
return armeriaHeaders.get(name);
}
if (needsValidation(name)) {
// Create Armeria headers and return;
}
// Use Netty Headers as is.
return headers.get(name);
}
public List<Entry<String, String>> entries() {
// Create Armeria Headers if null
}
public Iterator<Entry<String, String>> iterator() {
// Create Armeria Headers if null
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for pointing out!
Let me take a look into it :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've reviewed your idea and bring some thoughts.
Firstly, I totally agree that current approach doesn't resolve the problem of copying headers 🤦
And specifically against your advice, Http1RequestDecoder
has plenty of logic that utilize RequestHeaders
. So I think we need to convert(create) HttpHeaders
to RequestHeaders
most of the time even with 'lazy initializing' strategy. Which means it goes back to copying headers situation again.
I'm not sure if 'lazy initialization' strategy could provide significant value. 🤔 Correct me if I'm wrong.
With above idea, I'm afraid we need to copy headers somewhere in our system anyway.
This copies the Netty headers to Armeria Headers. Unfortunately, it does not match with the goal of this PR.
there is no need to copy original netty header.
What do you think of lazily creating or mutating fields when necessary?
private final HttpHeaders nettyHeaders; @Nullable private RequestHeaders armeriaHeaders; public ArmeriaHttpHeaders(RequestHeadersBuilder builder, HttpHeaders headers) { this.headers = headers; } @Override public String get(String name) { if (armeriaHeaders != null) { return armeriaHeaders.get(name); } if (needsValidation(name)) { // Create Armeria headers and return; } // Use Netty Headers as is. return headers.get(name); } public List<Entry<String, String>> entries() { // Create Armeria Headers if null } public Iterator<Entry<String, String>> iterator() { // Create Armeria Headers if null }
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4863 +/- ##
============================================
+ Coverage 74.01% 74.04% +0.02%
- Complexity 20799 21068 +269
============================================
Files 1803 1825 +22
Lines 76617 77775 +1158
Branches 9772 9937 +165
============================================
+ Hits 56708 57587 +879
- Misses 15289 15513 +224
- Partials 4620 4675 +55 ☔ View full report in Codecov by Sentry. |
This PR has been stale for a while - I've added a commit to get it moving forward. Changes include:
Let me know if anything doesn't make sense 🙇 |
@@ -696,6 +693,8 @@ public static HttpHeaders toArmeria(io.netty.handler.codec.http.HttpHeaders inHe | |||
|
|||
/** | |||
* Converts the specified Netty HTTP/1 headers into Armeria HTTP/2 headers. | |||
* Functionally, this method is expected to behavior in the same way as | |||
* {@link #purgeHttp1OnlyHeaders(io.netty.handler.codec.http.HttpHeaders, HttpHeadersBuilder)}. | |||
*/ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note that I haven't migrated this method to use purgeHttp1OnlyHeaders
since this is probably slower for other code paths using this method without zero-copy
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this PR is ready for review by other maintainers - changes look good to me for merging
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've also put a patch that
- hides static methods in ArmeriaHttpUtil
- changes ArmeriaHttpHeadersFactory to enum
Thanks!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, @echo304!
I'm looking forward to seeing benchmark results. What do you think of sending a PR to https://github.com/TechEmpower/FrameworkBenchmarks/blob/master/frameworks/Java/armeria/pom.xml#L19 when a new version is released?
I ran ./gradlew :benchmarks:jmh:jmh \
-Pjmh.includes='com.linecorp.armeria.core.HttpServerBenchmark.plainText' -Pjmh.params='protocol=H1C' -Pjmh.profilers="async:libPath=$HOME/Projects/async-profiler/build/lib/libasyncProfiler.dylib;output=flamegraph;dir=$HOME/result" base
feat
I don't think there is a significant difference in the simple case. Having said this, Assuming there are more custom headers (which I think better represents the real world) I suppose the performance improvement is more obvious. |
Motivation:
By overriding
HttpRequestDecoder.createMessage()
, performance can be improved because there is no need to copy original netty header.Modifications:
Result:
HttpRequestDecoder.createMessage()
for perfomance #4853