使用Netty實現HTTP2伺服器/客戶端的原始碼和教程 - Baeldung

banq發表於2020-06-14

在本教程中,我們將看到如何在Netty中實現HTTP / 2伺服器和客戶端。
Netty是基於NIO的客戶端-伺服器框架,它使Java開發人員能夠在網路層上進行操作。使用此框架,開發人員可以構建自己的任何已知協議甚至自定義協議的實現。

伺服器端
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

接下來,我們需要一個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();
        }
    }
}


作為此通道初始化的一部分,我們在實用程式方法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;
}


我們的自定義處理程式擴充套件了Netty的ChannelDuplexHandler,並充當伺服器的入站和出站處理程式。它準備要傳送給客戶端的響應。
在io.netty.buffer.ByteBuf中定義一個靜態Hello World響應 -首選的物件,該物件在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);
    }
}

我們的伺服器已準備好釋出Hello World。
為了進行快速測試,請啟動伺服器並使用–http2選項觸發curl命令:

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


客戶端
接下來,讓我們看一下客戶端。當然,其目的是傳送請求,然後處理從伺服器獲得的響應。
我們的客戶端程式碼將包括幾個處理程式,一個初始化器類(用於在管道中對其進行設定)以及最後一個JUnit測試,以引導客戶端並將所有內容整合在一起。
讓我們再次看看如何設定客戶端的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來信任任何證照而無需任何驗證。
重要的是,此信任管理器僅用於演示目的,不應在生產中使用。要改為使用可信證照,Netty的SslContextBuilder提供了許多替代方案。

現在,讓我們看一下處理程式。
首先,我們需要一個稱為Http2SettingsHandler的處理程式來處理HTTP / 2的設定。它擴充套件了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,並宣告瞭MapValues的streamidMap,它是我們Http2ClientResponseHandler的內部類:

public static class MapValues {
    ChannelFuture writeFuture;
    ChannelPromise promise;
 
    // constructor and getters
}

我們新增了此類,以便能夠為給定的Integer鍵儲存兩個值。
處理程式還具有一個實用方法put,當然可以將值放入streamidMap中:

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


接下來,讓我們看看在管道中讀取通道時此處理程式的作用。

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


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

客戶端啟動:我們需要新增更多功能來處理傳送請求和接收響應。如前所述,我們將其編寫為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();
    }
}


值得注意的是,雖然類似伺服器載入程式,下面採取的額外步驟:
  • 首先,我們使用Http2SettingsHandler的awaitSettings方法等待初始握手。
  • 其次,我們將請求建立為FullHttpRequest
  • 第三,將streamId放入Http2ClientResponseHandler的streamIdMap中,並呼叫其awaitResponses方法
  • 最後,我們驗證了在回應中確實獲得了Hello World

簡而言之,這就是發生的情況–客戶端傳送了HEADERS幀,發生了初始SSL握手,伺服器傳送了HEADERS和DATA幀中的響應。

我們希望Netty API在將來能夠處理HTTP / 2框架方面有更多改進,因為它仍在開發中。
與往常一樣,原始碼可以在GitHub上獲得

相關文章