Перейти к основному содержимому

HTTP/2 в Netty

· 10 мин. чтения

1. Обзор

Netty — это клиент-серверная среда на основе NIO, которая дает разработчикам Java возможность работать на сетевых уровнях. Используя эту структуру, разработчики могут создавать собственные реализации любого известного протокола или даже пользовательских протоколов.

Для базового понимания фреймворка знакомство с Netty — хорошее начало.

В этом руководстве мы увидим, как реализовать сервер и клиент HTTP/2 в Netty .

2. Что такое HTTP/2 ?

Как следует из названия, HTTP версии 2 или просто HTTP/2 — это более новая версия протокола передачи гипертекста.

Примерно в 1989 году, когда зародился Интернет, появился HTTP/1.0. В 1997 году он был обновлен до версии 1.1. Однако только в 2015 году он увидел серьезное обновление, версию 2.

На момент написания этой статьи также был доступен HTTP/3 , хотя он еще не поддерживается по умолчанию всеми браузерами.

HTTP/2 по-прежнему является последней версией протокола, которая широко принята и реализована. Он значительно отличается от предыдущих версий, среди прочего, функциями мультиплексирования и проталкивания на сервер.

Связь в HTTP/2 происходит через группу байтов, называемых фреймами, а несколько фреймов образуют поток.

В наших примерах кода мы увидим, как Netty обрабатывает обмен кадрами HEADERS , DATA и SETTINGS .

3. Сервер

Теперь давайте посмотрим, как мы можем создать сервер HTTP/2 в Netty.

3.1. SSLContext

Netty поддерживает согласование APN для HTTP/2 через TLS . Итак, первое, что нам нужно для создания сервера — это 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();

Здесь мы создали контекст для сервера с провайдером JDK SSL, добавили пару шифров и настроили согласование протокола прикладного уровня для HTTP/2.

Это означает, что наш сервер будет поддерживать только HTTP/2 и его базовый идентификатор протокола h2 .

3.2. Начальная загрузка сервера с помощью ChannelInitializer

Затем нам нужен ChannelInitializer для нашего мультиплексирующего дочернего канала, чтобы настроить конвейер Netty.

Мы будем использовать более ранний sslContext в этом канале для запуска конвейера, а затем загрузим сервер:

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();
}
}
}

В рамках инициализации этого канала мы добавляем обработчик APN в конвейер в служебном методе getServerAPNHandler() , который мы определили в нашем собственном служебном классе 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;
}

Этот обработчик, в свою очередь, добавляет предоставленный Netty Http2FrameCodec, используя его построитель и пользовательский обработчик с именем Http2ServerResponseHandler .

Наш пользовательский обработчик расширяет ChannelDuplexHandler Netty и действует как входящий, так и исходящий обработчик для сервера. Прежде всего, он подготавливает ответ для отправки клиенту.

Для целей этого руководства мы определим статический ответ Hello World в io.netty.buffer.ByteBuf — предпочтительном объекте для чтения и записи байтов в Netty:

static final ByteBuf RESPONSE_BYTES = Unpooled.unreleasableBuffer(
Unpooled.copiedBuffer("Hello World", CharsetUtil.UTF_8));

Этот буфер будет установлен как фрейм DATA в методе channelRead нашего обработчика и записан в 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);
}
}

Вот и все, наш сервер готов раздавать Hello World.

Для быстрого теста запустите сервер и запустите команду curl с параметром –http2 :

curl -k -v --http2 https://127.0.0.1:8443

Что даст ответ, похожий на:

> 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. Клиент

Далее давайте посмотрим на клиента. Конечно, его цель — отправить запрос, а затем обработать ответ, полученный от сервера.

Наш клиентский код будет состоять из пары обработчиков, класса инициализатора для их настройки в конвейере и, наконец, теста JUnit для начальной загрузки клиента и объединения всего.

4.1. SSLContext

Но опять же, сначала посмотрим, как у клиента настроен SslContext . Мы напишем это как часть настройки нашего клиента 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();
}

Как мы видим, он очень похож на S slContext сервера , только мы не предоставляем здесь никакой SelfSignedCertificate . Еще одно отличие состоит в том, что мы добавляем InsecureTrustManagerFactory , чтобы доверять любому сертификату без какой-либо проверки.

Важно отметить, что этот менеджер доверия предназначен исключительно для демонстрационных целей и не должен использоваться в рабочей среде . Чтобы вместо этого использовать доверенные сертификаты, SslContextBuilder от Netty предлагает множество альтернатив.

Мы вернемся к этому JUnit в конце, чтобы запустить клиент.

4.2. Обработчики

А пока давайте взглянем на обработчики.

Во- первых, нам понадобится обработчик, который мы назовем Http2SettingsHandler для обработки фрейма SETTINGS HTTP/2 . Он расширяет SimpleChannelInboundHandler Netty :

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);
}
}

Класс просто инициализирует ChannelPromise и помечает его как успешное.

У него также есть служебный метод awaitSettings , который наш клиент будет использовать для ожидания завершения начального рукопожатия:

public void awaitSettings(long timeout, TimeUnit unit) throws Exception {
if (!promise.awaitUninterruptibly(timeout, unit)) {
throw new IllegalStateException("Timed out waiting for settings");
}
}

Если чтение канала не происходит в течение установленного периода ожидания, генерируется исключение IllegalStateException .

Во- вторых, нам понадобится обработчик для обработки ответа, полученного от сервера , назовем его Http2ClientResponseHandler :

public class Http2ClientResponseHandler extends SimpleChannelInboundHandler {

private final Map<Integer, MapValues> streamidMap;

// constructor
}

Этот класс также расширяет SimpleChannelInboundHandler и объявляет streamidMap MapValues , внутренний класс нашего Http2ClientResponseHandler :

public static class MapValues {
ChannelFuture writeFuture;
ChannelPromise promise;

// constructor and getters
}

Мы добавили этот класс, чтобы иметь возможность хранить два значения для данного целочисленного ключа.

У обработчика также есть служебный метод put , конечно, для помещения значений в streamidMap :

public MapValues put(int streamId, ChannelFuture writeFuture, ChannelPromise promise) {
return streamidMap.put(streamId, new MapValues(writeFuture, promise));
}

Далее давайте посмотрим, что делает этот обработчик, когда канал читается в конвейере.

По сути, это место, где мы получаем фрейм DATA или содержимое ByteBuf с сервера в виде FullHttpResponse и можем манипулировать им по своему усмотрению.

В этом примере мы просто зарегистрируем это:

@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();
}
}

В конце метода мы помечаем ChannelPromise как успешное, чтобы указать правильное завершение.

Как и первый описанный нами обработчик, этот класс также содержит служебный метод для использования нашим клиентом. Этот метод заставляет наш цикл событий ждать, пока ChannelPromise не будет успешным. Или, другими словами, он ждет завершения обработки ответа:

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

Как мы видели в случае с нашим сервером, цель ChannelInitializer — настроить конвейер:

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
}

В этом случае мы инициируем конвейер с новым SslHandler для добавления расширения TLS SNI в начале процесса установления связи.

Затем ApplicationProtocolNegotiationHandler отвечает за выстраивание обработчика соединения и наших пользовательских обработчиков в конвейере:

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;
}

Теперь все, что осталось сделать, это загрузить клиент и отправить запрос.

4.4. Начальная загрузка клиента

Начальная загрузка клиента до определенного момента аналогична загрузке сервера. После этого нам нужно добавить немного больше функций для обработки отправки запроса и получения ответа.

Как упоминалось ранее, мы напишем это как тест JUnit:

@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();
}
}

Примечательно, что это дополнительные шаги, которые мы предприняли в отношении начальной загрузки сервера:

  • Во-первых, мы дождались начального рукопожатия, используя метод awaitSettings класса Http2SettingsHandler .
  • Во-вторых, мы создали запрос как FullHttpRequest.
  • В-третьих, мы помещаем streamId в наш Http2ClientResponseHandler streamIdMap и вызываем его метод awaitResponses . ``
  • И, наконец, мы проверили, что Hello World действительно получен в ответ

Вкратце, вот что произошло: клиент отправил фрейм HEADERS, произошло начальное SSL-рукопожатие, и сервер отправил ответ в HEADERS и фрейме DATA.

5. Вывод

В этом руководстве мы увидели, как реализовать сервер и клиент HTTP/2 в Netty, используя примеры кода, чтобы получить ответ Hello World с использованием кадров HTTP/2.

Мы надеемся увидеть гораздо больше улучшений в Netty API для обработки кадров HTTP/2 в будущем, поскольку над ним все еще ведется работа.

Как всегда, исходный код доступен на GitHub .