IMLC.ME
/netty/如何使用 Netty 创建一个高性能的 Http Client/

如何使用 Netty 创建一个高性能的 Http Client

Netty 是一个高性能的NIO网络框架。最常见的做法,就是基于 Netty 构建一个高性能的 TCP 服务器或者 HTTP 服务器
但有了服务器,有怎么少得了客户端。
得益于 Netty 优秀的架构设置,只需要简单几行代码的改动,我们就可以把一段 HTTP 服务器的代码改成了 HTTP 客户端。

本文直接引用了 Netty 官方的例子: https://github.com/netty/netty/blob/4.1/example/src/main/java/io/netty/example/http/snoop/HttpSnoopClient.java。并加上了解释说明。

1. 引入 Netty 依赖

Maven
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-all</artifactId>
    <version>4.1.77.Final</version>
</dependency>
Gradle(Groovy)
implementation 'io.netty:netty-all:4.1.77.Final'
Gradle(Kotlin)
implementation("io.netty:netty-all:4.1.77.Final")

2. 启动 Netty

public final class HttpSnoopClient {

    static final String URL = System.getProperty("url", "http://127.0.0.1:8080/");

    public static void main(String[] args) throws Exception {
        URI uri = new URI(URL);
        String scheme = uri.getScheme() == null? "http" : uri.getScheme();
        String host = uri.getHost() == null? "127.0.0.1" : uri.getHost();
        int port = uri.getPort();
        if (port == -1) {
            if ("http".equalsIgnoreCase(scheme)) {
                port = 80;
            } else if ("https".equalsIgnoreCase(scheme)) {
                port = 443;
            }
        }

        if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
            System.err.println("Only HTTP(S) is supported.");
            return;
        }

        // Configure SSL context if necessary.
        final boolean ssl = "https".equalsIgnoreCase(scheme);
        final SslContext sslCtx;
        if (ssl) {
            sslCtx = SslContextBuilder.forClient()
                .trustManager(InsecureTrustManagerFactory.INSTANCE).build();
        } else {
            sslCtx = null;
        }

        // Configure the client.
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .handler(new HttpSnoopClientInitializer(sslCtx));

            // Make the connection attempt.
            Channel ch = b.connect(host, port).sync().channel();

            // Prepare the HTTP request.
            HttpRequest request = new DefaultFullHttpRequest(
                    HttpVersion.HTTP_1_1, HttpMethod.GET, uri.getRawPath(), Unpooled.EMPTY_BUFFER);
            request.headers().set(HttpHeaderNames.HOST, host);
            request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
            request.headers().set(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP);

            // Set some example cookies.
            request.headers().set(
                    HttpHeaderNames.COOKIE,
                    ClientCookieEncoder.STRICT.encode(
                            new DefaultCookie("my-cookie", "foo"),
                            new DefaultCookie("another-cookie", "bar")));

            // Send the HTTP request.
            ch.writeAndFlush(request);

            // Wait for the server to close the connection.
            ch.closeFuture().sync();
        } finally {
            // Shut down executor threads to exit.
            group.shutdownGracefully();
        }
    }
}

这里的关键是 Bootstrap、NioEventLoopGroup 和 SocketChannel 的创建。

对比创建服务端的代码(https://www.imlc.me/netty/netty-http-server/):

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
    ServerBootstrap b = new ServerBootstrap();
    b.option(ChannelOption.SO_BACKLOG, 1024);
    b.group(bossGroup, workerGroup)
     .channel(NioServerSocketChannel.class)
     .handler(new LoggingHandler(LogLevel.INFO))
     .childHandler(new HttpHelloWorldServerInitializer(sslCtx));
  1. 我们创建了 Bootstrap 类而非 ServerBootstrap,用于启动客户端的 Netty
  2. 我们只创建了一个 NioEventLoopGroup。而服务端 Netty 需要 bossGroup 和 workerGroup 两个 EventLoopGroup
  3. 最后,我们创建了 NioSocketChannel 而非 NioServerSocketChannel。

与服务端被动等待请求不同,客户端需要主动发起请求。因此:

我们需要主动调用 connect() 方法创建 TCP 连接。

Channel ch = b.connect(host, port).sync().channel();

然后往 TCP 连接中写入数据

ch.writeAndFlush(request);

2. 初始化 ChannelPipeline

Bootstrap b = new Bootstrap();
b.group(group)
 .channel(NioSocketChannel.class)
 .handler(new HttpSnoopClientInitializer(sslCtx));

还是 Netty 的风格,我们需要给 Channel 创建一堆 ChannelHandler。 上方代码中,我们使用 HttpSnoopClientInitializer 创建 Handler。其代码如下:

public class HttpSnoopClientInitializer extends ChannelInitializer<SocketChannel> {

    private final SslContext sslCtx;

    public HttpSnoopClientInitializer(SslContext sslCtx) {
        this.sslCtx = sslCtx;
    }

    @Override
    public void initChannel(SocketChannel ch) {
        ChannelPipeline p = ch.pipeline();

        // Enable HTTPS if necessary.
        if (sslCtx != null) {
            p.addLast(sslCtx.newHandler(ch.alloc()));
        }

        p.addLast(new HttpClientCodec());

        // Remove the following line if you don't want automatic content decompression.
        p.addLast(new HttpContentDecompressor());

        // Uncomment the following line if you don't want to handle HttpContents.
        //p.addLast(new HttpObjectAggregator(1048576));

        p.addLast(new HttpSnoopClientHandler());
    }
}

这段代码非常直观。 initChannel() 里依次往 ChannelPipeline 添加 SslHandler、HttpClientCodec、HttpContentDecompressor。 最后是 HttpSnoopClientHandler ,负责实现自定义逻辑。

当我们发送请求时,数据逆序经过 HttpClientCodec 和 SslHandler,把 Java 对象转换成字节数据,然后 SSL 加密,发送到服务端。

当我们收到服务端的响应,数据顺序流过 ChannelPipeline,
SslHandler 负责解密数据。
HttpClientCodec 负责把字节数据解码成 HTTP 数据。 最后 HttpContentDecompressor 负责解压缩数据(例如数据被 gzip 压缩了)。

走完这个流程,数据被解码成我们需要的 HttpObject Java 对象。提供给后续的 HttpSnoopClientHandler 处理。

  1. 创建自定义ChannelHandler处理业务逻辑

最后,我们需要实现自己的 ChannelHandler。 最简单的,就是使用 Netty 提供的 SimpleChannelInboundHandler。

下方的代码演示了如果从 HttpObject 里读取 HTTP 响应的信息。

public class HttpSnoopClientHandler extends SimpleChannelInboundHandler<HttpObject> {

    @Override
    public void channelRead0(ChannelHandlerContext ctx, HttpObject msg) {
        if (msg instanceof HttpResponse) {
            HttpResponse response = (HttpResponse) msg;

            System.err.println("STATUS: " + response.status());
            System.err.println("VERSION: " + response.protocolVersion());
            System.err.println();

            if (!response.headers().isEmpty()) {
                for (CharSequence name: response.headers().names()) {
                    for (CharSequence value: response.headers().getAll(name)) {
                        System.err.println("HEADER: " + name + " = " + value);
                    }
                }
                System.err.println();
            }

            if (HttpUtil.isTransferEncodingChunked(response)) {
                System.err.println("CHUNKED CONTENT {");
            } else {
                System.err.println("CONTENT {");
            }
        }
        if (msg instanceof HttpContent) {
            HttpContent content = (HttpContent) msg;

            System.err.print(content.content().toString(CharsetUtil.UTF_8));
            System.err.flush();

            if (content instanceof LastHttpContent) {
                System.err.println("} END OF CONTENT");
                ctx.close();
            }
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

生产级别的实现

上方的例子只是一个粗浅的例子。 如果要在生产环境,还要实现 HTTP/2 的支持,更多的 HTTP 特性,更简装的错误处理以及更完善的连接管理。

如果你在寻找一个NIO的 HTTP Client,我更建议你使用成熟的框架,例如 vertx-web-client 或者 Java 11 引入的 HTTP client API

如果你需要实现一个自定义协议的高性能客户端,那么上方 HTTP 客户端的实现,会是一个很好的出发点。