1. Overview
Netty is an NIO-based client-server framework that gives Java developers the power to operate on the network layers. Using this framework, developers can build their own implementation of any known protocol, or even custom protocols.
For a basic understanding of the framework, introduction to Netty is a good start.
In this tutorial, we'll see how to implement an HTTP/2 server and client in Netty.
2. What is HTTP/2?
As the name suggests, HTTP version 2 or simply HTTP/2, is a newer version of the Hypertext Transfer Protocol.
Around the year 1989, when the internet was born, HTTP/1.0 came into being. In 1997, it was upgraded to version 1.1. However, it wasn't until 2015 that it saw a major upgrade, version 2.
As of writing this, HTTP/3 is also available, though not yet supported by default by all browsers.
HTTP/2 is still the latest version of the protocol that is widely accepted and implemented. It differs significantly from the previous versions with its multiplexing and server push features, among other things.
Communication in HTTP/2 happens via a group of bytes called frames, and multiple frames form a stream.
In our code samples, we'll see how Netty handles the exchange of HEADERS, DATA and SETTINGS frames.
3. The Server
Now let's see how we can create an HTTP/2 server in Netty.
3.1. SslContext
Netty supports APN negotiation for HTTP/2 over TLS. So, the first thing we need to create a server is an SslContext:
SelfSignedCertificate ssc = new SelfSignedCertificate(); SslContext sslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()) .sslProvider(SslProvider.JDK) .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) .applicationProtocolConfig( new ApplicationProtocolConfig(Protocol.ALPN, SelectorFailureBehavior.NO_ADVERTISE, SelectedListenerFailureBehavior.ACCEPT, ApplicationProtocolNames.HTTP_2)) .build();
Here, we created a context for the server with a JDK SSL provider, added a couple of ciphers, and configured the Application-Layer Protocol Negotiation for HTTP/2.
This means that our server will only support HTTP/2 and its underlying protocol identifier h2.
3.2. Bootstrapping the Server with a ChannelInitializer
Next, we need a ChannelInitializer for our multiplexing child channel, so as to set up a Netty pipeline.
We'll use the earlier sslContext in this channel to initiate the pipeline, and then bootstrap the server:
public final class Http2Server { static final int PORT = 8443; public static void main(String[] args) throws Exception { SslContext sslCtx = // create sslContext as described above EventLoopGroup group = new NioEventLoopGroup(); try { ServerBootstrap b = new ServerBootstrap(); b.option(ChannelOption.SO_BACKLOG, 1024); b.group(group) .channel(NioServerSocketChannel.class) .handler(new LoggingHandler(LogLevel.INFO)) .childHandler(new ChannelInitializer() { @Override protected void initChannel(SocketChannel ch) throws Exception { if (sslCtx != null) { ch.pipeline() .addLast(sslCtx.newHandler(ch.alloc()), Http2Util.getServerAPNHandler()); } } }); Channel ch = b.bind(PORT).sync().channel(); logger.info("HTTP/2 Server is listening on https://127.0.0.1:" + PORT + '/'); ch.closeFuture().sync(); } finally { group.shutdownGracefully(); } } }
As part of this channel's initialization, we're adding an APN handler to the pipeline in a utility method getServerAPNHandler() that we've defined in our own utility class Http2Util:
public static ApplicationProtocolNegotiationHandler getServerAPNHandler() { ApplicationProtocolNegotiationHandler serverAPNHandler = new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_2) { @Override protected void configurePipeline(ChannelHandlerContext ctx, String protocol) throws Exception { if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { ctx.pipeline().addLast( Http2FrameCodecBuilder.forServer().build(), new Http2ServerResponseHandler()); return; } throw new IllegalStateException("Protocol: " + protocol + " not supported"); } }; return serverAPNHandler; }
This handler is, in turn, adding a Netty provided Http2FrameCodec using its builder and a custom handler called Http2ServerResponseHandler.
Our custom handler extends Netty's ChannelDuplexHandler and acts as both an inbound as well as an outbound handler for the server. Primarily, it prepares the response to be sent to the client.
For the purpose of this tutorial, we'll define a static Hello World response in an io.netty.buffer.ByteBuf – the preferred object to read and write bytes in Netty:
static final ByteBuf RESPONSE_BYTES = Unpooled.unreleasableBuffer( Unpooled.copiedBuffer("Hello World", CharsetUtil.UTF_8));
This buffer will be set as a DATA frame in our handler's channelRead method and written to the ChannelHandlerContext:
@Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof Http2HeadersFrame) { Http2HeadersFrame msgHeader = (Http2HeadersFrame) msg; if (msgHeader.isEndStream()) { ByteBuf content = ctx.alloc().buffer(); content.writeBytes(RESPONSE_BYTES.duplicate()); Http2Headers headers = new DefaultHttp2Headers().status(HttpResponseStatus.OK.codeAsText()); ctx.write(new DefaultHttp2HeadersFrame(headers).stream(msgHeader.stream())); ctx.write(new DefaultHttp2DataFrame(content, true).stream(msgHeader.stream())); } } else { super.channelRead(ctx, msg); } }
And that's it, our server is ready to dish out Hello World.
For a quick test, start the server and fire a curl command with the –http2 option:
curl -k -v --http2 https://127.0.0.1:8443
Which will give a response similar to:
> GET / HTTP/2 > Host: 127.0.0.1:8443 > User-Agent: curl/7.64.1 > Accept: */* > * Connection state changed (MAX_CONCURRENT_STREAMS == 4294967295)! < HTTP/2 200 < * Connection #0 to host 127.0.0.1 left intact Hello World* Closing connection 0
4. The Client
Next, let's have a look at the client. Of course, its purpose is to send a request and then handle the response obtained from the server.
Our client code will comprise of a couple of handlers, an initializer class to set them up in a pipeline, and finally a JUnit test to bootstrap the client and bring everything together.
4.1. SslContext
But again, at first, let's see how the client's SslContext is set up. We'll write this as part of setting up of our client JUnit:
@Before public void setup() throws Exception { SslContext sslCtx = SslContextBuilder.forClient() .sslProvider(SslProvider.JDK) .ciphers(Http2SecurityUtil.CIPHERS, SupportedCipherSuiteFilter.INSTANCE) .trustManager(InsecureTrustManagerFactory.INSTANCE) .applicationProtocolConfig( new ApplicationProtocolConfig(Protocol.ALPN, SelectorFailureBehavior.NO_ADVERTISE, SelectedListenerFailureBehavior.ACCEPT, ApplicationProtocolNames.HTTP_2)) .build(); }
As we can see, it's pretty much similar to the server's SslContext, just that we are not providing any SelfSignedCertificate here. Another difference is that we are adding an InsecureTrustManagerFactory to trust any certificate without any verification.
Importantly, this trust manager is purely for demo purposes and should not be used in production. To use trusted certificates instead, Netty's SslContextBuilder offers many alternatives.
We'll come back to this JUnit at the end to bootstrap the client.
4.2. Handlers
For now, let's take a look at the handlers.
First, we'll need a handler we'll call Http2SettingsHandler, to deal with HTTP/2's SETTINGS frame. It extends Netty's SimpleChannelInboundHandler:
public class Http2SettingsHandler extends SimpleChannelInboundHandler<Http2Settings> { private final ChannelPromise promise; // constructor @Override protected void channelRead0(ChannelHandlerContext ctx, Http2Settings msg) throws Exception { promise.setSuccess(); ctx.pipeline().remove(this); } }
The class is simply initializing a ChannelPromise and flagging it as successful.
It also has a utility method awaitSettings that our client will use in order to wait for the initial handshake completion:
public void awaitSettings(long timeout, TimeUnit unit) throws Exception { if (!promise.awaitUninterruptibly(timeout, unit)) { throw new IllegalStateException("Timed out waiting for settings"); } }
If the channel read does not happen in the stipulated timeout period, then an IllegalStateException is thrown.
Second, we'll need a handler to deal with the response obtained from the server, we'll name it Http2ClientResponseHandler:
public class Http2ClientResponseHandler extends SimpleChannelInboundHandler { private final Map<Integer, MapValues> streamidMap; // constructor }
This class also extends SimpleChannelInboundHandler and declares a streamidMap of MapValues, an inner class of our Http2ClientResponseHandler:
public static class MapValues { ChannelFuture writeFuture; ChannelPromise promise; // constructor and getters }
We added this class to be able to store two values for a given Integer key.
The handler also has a utility method put, of course, to put values in the streamidMap:
public MapValues put(int streamId, ChannelFuture writeFuture, ChannelPromise promise) { return streamidMap.put(streamId, new MapValues(writeFuture, promise)); }
Next, let's see what this handler does when the channel is read in the pipeline.
Basically, this is the place where we get the DATA frame or ByteBuf content from the server as a FullHttpResponse and can manipulate it in the way we want.
In this example, we'll just log it:
@Override protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse msg) throws Exception { Integer streamId = msg.headers().getInt(HttpConversionUtil.ExtensionHeaderNames.STREAM_ID.text()); if (streamId == null) { logger.error("HttpResponseHandler unexpected message received: " + msg); return; } MapValues value = streamidMap.get(streamId); if (value == null) { logger.error("Message received for unknown stream id " + streamId); } else { ByteBuf content = msg.content(); if (content.isReadable()) { int contentLength = content.readableBytes(); byte[] arr = new byte[contentLength]; content.readBytes(arr); logger.info(new String(arr, 0, contentLength, CharsetUtil.UTF_8)); } value.getPromise().setSuccess(); } }
At the end of the method, we flag the ChannelPromise as successful to indicate proper completion.
As the first handler we described, this class also contains a utility method for our client's use. The method makes our event loop wait until the ChannelPromise is successful. Or, in other words, it waits till the response processing is complete:
public String awaitResponses(long timeout, TimeUnit unit) { Iterator<Entry<Integer, MapValues>> itr = streamidMap.entrySet().iterator(); String response = null; while (itr.hasNext()) { Entry<Integer, MapValues> entry = itr.next(); ChannelFuture writeFuture = entry.getValue().getWriteFuture(); if (!writeFuture.awaitUninterruptibly(timeout, unit)) { throw new IllegalStateException("Timed out waiting to write for stream id " + entry.getKey()); } if (!writeFuture.isSuccess()) { throw new RuntimeException(writeFuture.cause()); } ChannelPromise promise = entry.getValue().getPromise(); if (!promise.awaitUninterruptibly(timeout, unit)) { throw new IllegalStateException("Timed out waiting for response on stream id " + entry.getKey()); } if (!promise.isSuccess()) { throw new RuntimeException(promise.cause()); } logger.info("---Stream id: " + entry.getKey() + " received---"); response = entry.getValue().getResponse(); itr.remove(); } return response; }
4.3. Http2ClientInitializer
As we saw in the case of our server, the purpose of a ChannelInitializer is to set up a pipeline:
public class Http2ClientInitializer extends ChannelInitializer { private final SslContext sslCtx; private final int maxContentLength; private Http2SettingsHandler settingsHandler; private Http2ClientResponseHandler responseHandler; private String host; private int port; // constructor @Override public void initChannel(SocketChannel ch) throws Exception { settingsHandler = new Http2SettingsHandler(ch.newPromise()); responseHandler = new Http2ClientResponseHandler(); if (sslCtx != null) { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(sslCtx.newHandler(ch.alloc(), host, port)); pipeline.addLast(Http2Util.getClientAPNHandler(maxContentLength, settingsHandler, responseHandler)); } } // getters }
In this case, we are initiating the pipeline with a new SslHandler to add the TLS SNI Extension at the start of the handshaking process.
Then, it's the responsibility of the ApplicationProtocolNegotiationHandler to line up a connection handler and our custom handlers in the pipeline:
public static ApplicationProtocolNegotiationHandler getClientAPNHandler( int maxContentLength, Http2SettingsHandler settingsHandler, Http2ClientResponseHandler responseHandler) { final Http2FrameLogger logger = new Http2FrameLogger(INFO, Http2ClientInitializer.class); final Http2Connection connection = new DefaultHttp2Connection(false); HttpToHttp2ConnectionHandler connectionHandler = new HttpToHttp2ConnectionHandlerBuilder().frameListener( new DelegatingDecompressorFrameListener(connection, new InboundHttp2ToHttpAdapterBuilder(connection) .maxContentLength(maxContentLength) .propagateSettings(true) .build())) .frameLogger(logger) .connection(connection) .build(); ApplicationProtocolNegotiationHandler clientAPNHandler = new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_2) { @Override protected void configurePipeline(ChannelHandlerContext ctx, String protocol) { if (ApplicationProtocolNames.HTTP_2.equals(protocol)) { ChannelPipeline p = ctx.pipeline(); p.addLast(connectionHandler); p.addLast(settingsHandler, responseHandler); return; } ctx.close(); throw new IllegalStateException("Protocol: " + protocol + " not supported"); } }; return clientAPNHandler; }
Now all that is left to do is to bootstrap the client and send across a request.
4.4. Bootstrapping the Client
Bootstrapping of the client is similar to that of the server up to a point. After that, we need to add a little bit more functionality to handle sending the request and receiving the response.
As mentioned previously, we'll write this as a JUnit test:
@Test public void whenRequestSent_thenHelloWorldReceived() throws Exception { EventLoopGroup workerGroup = new NioEventLoopGroup(); Http2ClientInitializer initializer = new Http2ClientInitializer(sslCtx, Integer.MAX_VALUE, HOST, PORT); try { Bootstrap b = new Bootstrap(); b.group(workerGroup); b.channel(NioSocketChannel.class); b.option(ChannelOption.SO_KEEPALIVE, true); b.remoteAddress(HOST, PORT); b.handler(initializer); channel = b.connect().syncUninterruptibly().channel(); logger.info("Connected to [" + HOST + ':' + PORT + ']'); Http2SettingsHandler http2SettingsHandler = initializer.getSettingsHandler(); http2SettingsHandler.awaitSettings(60, TimeUnit.SECONDS); logger.info("Sending request(s)..."); FullHttpRequest request = Http2Util.createGetRequest(HOST, PORT); Http2ClientResponseHandler responseHandler = initializer.getResponseHandler(); int streamId = 3; responseHandler.put(streamId, channel.write(request), channel.newPromise()); channel.flush(); String response = responseHandler.awaitResponses(60, TimeUnit.SECONDS); assertEquals("Hello World", response); logger.info("Finished HTTP/2 request(s)"); } finally { workerGroup.shutdownGracefully(); } }
Notably, these are the extra steps we took with respect to the server bootstrap:
- First, we waited for the initial handshake, making use of Http2SettingsHandler‘s awaitSettings method
- Second, we created the request as a FullHttpRequest
- Third, we put the streamId in our Http2ClientResponseHandler‘s streamIdMap, and called its awaitResponses method
- And at last, we verified that Hello World is indeed obtained in the response
In a nutshell, here's what happened – the client sent a HEADERS frame, initial SSL handshake took place, and the server sent the response in a HEADERS and a DATA frame.
5. Conclusion
In this tutorial, we saw how to implement an HTTP/2 server and client in Netty using code samples to get a Hello World response using HTTP/2 frames.
We hope to see a lot more improvements in Netty API for handling HTTP/2 frames in the future, as it is still being worked upon.
As always, source code is available over on GitHub.