1. 概要

Netty は、Java開発者がネットワーク層で操作できるようにするNIOベースのクライアントサーバーフレームワークです。 このフレームワークを使用して、開発者は既知のプロトコル、またはカスタムプロトコルの独自の実装を構築できます。

フレームワークの基本的な理解のために、Nettyの紹介は良いスタートです。

このチュートリアルでは、NettyでHTTP/2サーバーとクライアントを実装する方法を説明します。

2. HTTP / 2 とは何ですか?

名前が示すように、HTTPバージョン2または単にHTTP/ 2 は、ハイパーテキスト転送プロトコルの新しいバージョンです。

インターネットが誕生した1989年頃、HTTP/1.0が誕生しました。 1997年に、バージョン1.1にアップグレードされました。 ただし、メジャーアップグレードバージョン2がリリースされたのは2015年のことでした。

これを書いている時点では、 HTTP / 3 も利用可能ですが、すべてのブラウザーでデフォルトでまだサポートされていません。

HTTP / 2は、広く受け入れられ、実装されているプロトコルの最新バージョンです。 とりわけ、多重化およびサーバープッシュ機能を備えた以前のバージョンとは大きく異なります。

HTTP / 2での通信は、フレームと呼ばれるバイトのグループを介して行われ、複数のフレームがストリームを形成します。

コードサンプルでは、 NettyがHEADERS、DATA、およびSETTINGSフレームの交換をどのように処理するかを確認します。

3. サーバー

それでは、NettyでHTTP/2サーバーを作成する方法を見てみましょう。

3.1. SslContext

Nettyは、TLSを介したHTTP/2のAPNネゴシエーションをサポートしています。 したがって、サーバーを作成するために最初に必要なのは、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を使用したサーバーのブートストラップ

次に、Nettyパイプラインを設定するために、多重化する子チャネル用にChannelInitializerが必要です。

このチャネルで以前の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();
        }
    }
}

このチャネルの初期化の一環として、独自のユーティリティクラス Http2Utilで定義したユーティリティメソッドgetServerAPNHandler()のパイプラインにAPNハンドラーを追加します。

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

このハンドラーは、ビルダーとHttp2ServerResponseHandlerと呼ばれるカスタムハンドラーを使用してNettyが提供するHttp2FrameCodecを追加します。

カスタムハンドラーはNettyのChannelDuplexHandlerを拡張し、サーバーのインバウンドハンドラーとアウトバウンドハンドラーの両方として機能します。 主に、クライアントに送信される応答を準備します。

このチュートリアルの目的のために、io。netty.buffer.ByteBufで静的HelloWorld応答を定義します–Nettyでバイトを読み書きするための優先オブジェクト:

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

このバッファは、ハンドラーの channelRead メソッドでDATAフレームとして設定され、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);
    }
}

これで、サーバーはHelloWorldを提供する準備が整いました。

簡単にテストするには、サーバーを起動し、 –http2オプションを指定してcurlコマンドを実行します。

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

ご覧のとおり、ここでは SelfSignedCertificate を提供していないという点で、サーバーのS slContextとほとんど同じです。 もう1つの違いは、 InsecureTrustManagerFactory を追加して、検証なしで証明書を信頼することです。

重要なことに、このトラストマネージャーは純粋にデモ目的であり、本番環境では使用しないでください。 代わりに信頼できる証明書を使用するために、NettyのSslContextBuilderには多くの選択肢があります。

最後にこのJUnitに戻って、クライアントをブートストラップします。

4.2. ハンドラー

とりあえず、ハンドラーを見てみましょう。

まず、 HTTP / 2のSETTINGSフレームを処理するために、Http2SettingsHandlerと呼ばれるハンドラーが必要です。  Nettyの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);
    }
}

このクラスは、 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も拡張し、Http2ClientResponseHandlerの内部クラスであるMapValuesstreamidMapを宣言します。

public static class MapValues {
    ChannelFuture writeFuture;
    ChannelPromise promise;

    // constructor and getters
}

このクラスを追加して、特定のIntegerキーに2つの値を格納できるようにしました。

もちろん、ハンドラーには、streamidMapに値を入れるためのユーティリティメソッドputもあります。

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
}

この場合、ハンドシェイクプロセスの開始時に TLS SNI拡張機能を追加するために、新しいSslHandlerでパイプラインを開始しています。

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

特に、これらはサーバーのブートストラップに関して行った追加の手順です。

  • まず、Http2SettingsHandlerawaitSettingsメソッドを使用して、最初のハンドシェイクを待ちました。
  • 次に、リクエストをFullHttpRequestとして作成しました
  • 3番目に、streamIdHttp2ClientResponseHandlerstreamIdMapに配置し、そのawaitResponsesメソッドを呼び出しました。
  • そして最後に、 HelloWorldが実際に応答で取得されることを確認しました

簡単に言うと、これが起こったことです。クライアントはHEADERSフレームを送信し、最初のSSLハンドシェイクが行われ、サーバーはHEADERSとDATAフレームで応答を送信しました。

5. 結論

このチュートリアルでは、コードサンプルを使用してNettyにHTTP / 2サーバーとクライアントを実装し、HTTP/2フレームを使用してHelloWorld応答を取得する方法を説明しました。

Netty APIはまだ作業中であるため、将来的にはHTTP/2フレームを処理するためのさらに多くの改善が見られることを期待しています。

いつものように、ソースコードはGitHubから入手できます。