Рубрики
Без рубрики

HTTP/2 в Netty

Узнайте, как реализовать сервер и клиент HTTP/2 в Netty, используя примеры кода.

Автор оригинала: Sampada Wagde.

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 обрабатывает обмен ЗАГОЛОВКАМИ , ДАННЫМИ и НАСТРОЙКАМИ фреймами .

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

Здесь мы создали контекст для сервера с поставщиком SSL JDK, добавили пару шифров и настроили согласование протокола прикладного уровня для 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 Http 2 кадровый кодек , используя его конструктор и пользовательский обработчик с именем Http2ServerResponseHandler .

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

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

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

Этот буфер будет установлен в качестве фрейма ДАННЫХ в методе 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 sslcontext сервера , только мы не предоставляем здесь никакого SelfSignedCertificate|/. Другое отличие заключается в том, что мы добавляем InsecureTrustManagerFactory , чтобы доверять любому сертификату без какой-либо проверки.

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

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

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

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

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

public class Http2SettingsHandler extends SimpleChannelInboundHandler {
    private final ChannelPromise promise;

    // constructor

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Http2Settings msg) throws Exception {
        promise.setSuccess();
        ctx.pipeline().remove(this);
    }
}

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

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

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

    // constructor
}

Этот класс также расширяет SimpleChannelInboundHandler и объявляет карту идентификатора потока значений карты , внутренний класс нашего Http2ClientResponseHandler :

public static class MapValues {
    ChannelFuture writeFuture;
    ChannelPromise promise;

    // constructor and getters
}

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

Обработчик также имеет служебный метод но , конечно, для ввода значений в карту идентификатора потока :

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

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

В принципе, это место, где мы получаем фрейм ДАННЫХ или 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> itr = streamidMap.entrySet().iterator();        
    String response = null;

    while (itr.hasNext()) {
        Entry 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. Httpclientinitializer

Как мы видели в случае с нашим сервером, целью 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 в начале процесса рукопожатия.

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

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

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

  • Сначала мы дождались первоначального рукопожатия, используя обработчик настроек Http 2/| ‘s await Settings метод Во-вторых, мы создали запрос как
  • Полный Http-запрос В-третьих, мы помещаем
  • идентификатор потока в наш Http 2 Client ResponseHandler ‘s stream Id Map , и вызвал его await Responses метод И, наконец, мы убедились, что
  • Hello World действительно получен в ответе

В двух словах, вот что произошло – клиент отправил фрейм ЗАГОЛОВКОВ, произошло первоначальное SSL-рукопожатие, и сервер отправил ответ в ЗАГОЛОВКАХ и фрейме ДАННЫХ.

5. Заключение

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

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

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