Netty http client 编写总结

Apache http client 有两个问题,第一个是 apache http client 是阻塞式的读取 Http request, 异步读写网络数据性能更好些。第二个是当 client 到 server 的连接中断时,http client 无法感知到这件事的发生,需要开发者主动的轮训校验,发 keep alive 或者 heart beat 消息,而 netty 可以设置回调函数,确保网络连接中断时有逻辑来 handle

使用 Netty 编写 Http client,也有一些问题。首先是 netty 是事件驱动的,逻辑主要基于回调函数。数据包到来了也好,网络连接中断也好,都要通过写回调函数确定这些事件来临后的后续操作。没有人喜欢回调函数,Future 是 scala 里讨人喜欢的特性,它能把常规于语言里通过回调才能解决的问题通过主动调用的方式来解决,配合 map, flatmap, for 甚至 async,scala 里可以做到完全看不到回调函数。所以用 netty 做 client 第一个问题是如何把 回调函数搞成主动调用的函数。第二点是 长连接,一个 channel 不能发了一个消息就关闭了,每次发消息都要经过 http 三次握手四次挥手效率太低了,最好能重用 channel。第三个是 thread-safe,这个一开始并没有考虑到,后来发现这个是最难解决的问题。当然 netty 作为一个比较底层的包,用它来实现一些高层的接口是比较费时费力的,有很多事情都要手动去做。我花了四五天的时间,没有解决这几个问题,只留下一些经验,供以后参考(见后面的 update)。

回调函数变主动调用函数

netty 的操作都是基于回调函数的
消息到达时的逻辑

    protected void channelRead0(ChannelHandlerContext ctx, HttpObject msg) throws Exception {

        if (msg instanceof HttpContent) {
            HttpContent content = (HttpContent) msg;

            if (content instanceof HttpContent) {
                sendFullResponse(ctx, content);
            } else {
                log.error("content is not http content");
            }
        }
    }

到 server 的连接建立后创建 channel 的逻辑

        bootstrap.handler(new ChannelInitializer<SocketChannel>() {
            @Override
            protected void initChannel(SocketChannel ch) throws Exception {
                ChannelPipeline p = ch.pipeline();
                p.addLast(new HttpClientCodec());
                p.addLast(new HttpContentDecompressor());
                p.addLast(new HttpObjectAggregator(512 * 1024));
                p.addLast(new ResponseHandler());
            }
        });

这是我就希望有一个像 scala Future/Promise 一样的东西,帮我把回调函数转成主动调用函数,这是 scala 的一个例子

    Promise promise = Promise[HttpContent]
    def channelRead0(ChannelHandlerContext ctx, HttpObject msg) {
        HttpContent content = (HttpContent) msg
        promise.success(content)
    }
    
    //somewhere else
    promise.future.map(content => println("content has been recieved in client"))

可以说有了 promise,我们接收到 httpContent 以后的事情就都能用主动调用的方式来写了,虽然不完全像普通的 java 代码那样简单,需要加一些组合子,但是已经够好了。

Java 里没有 promise,需要自己实现,参考了别人的代码,发现 CountDownLatch 是实现 promise 的关键。setComplete 和 await 是最重要的两个函数,一个设置 CountDownLatch,一个等待 CountDownLatch。

    private boolean setComplete(ResultHolder holder) {
        log.info("set complete");

        if (isDone()) {
            return false;
        }

        synchronized (this) {
            if (isDone()) {
                return false;
            }

            this.result = holder;
            if (this.complteLatch != null) {

                log.info("set complete time: " + System.currentTimeMillis());
                this.complteLatch.countDown();
            } else {
                log.info("completeLatch is null at the time: " + System.currentTimeMillis());
            }
        }
        return true;
    }
    
    
    public TaskFuture await() throws InterruptedException {
        if (isDone()) {
            return this;
        }

        synchronized (this) {
            if (isDone()) {
                return this;
            }

            if (this.complteLatch == null) {
                log.info("await time: " + System.currentTimeMillis());
                this.complteLatch = new CountDownLatch(1);
            }
        }

        this.complteLatch.await();
        return this;
    }

有了 Promise 以后就能把回调函数转为主动调用的函数了。虽然没有组合子,但是已经够好了,起码 await 函数能够保证开发者拿到 HttpContent 后能够像正常的 java 代码一样操纵这个值。

public TaskPromise executeInternal(HttpRequest httpRequest)

重用 channel

根据上面那一节,得到了这个函数

    public TaskPromise executeInternal(HttpRequest httpRequest) {
        final TaskPromise promise = new DefaultTaskPromise();

        log.info("new created promise hashcode is " + promise.hashCode());

        Channel channel = channelFuture.channel();
        channel.pipeline().get(ResponseHandler.class).setResponseHandler(promise);

        channel.writeAndFlush(httpRequest).addListener((ChannelFutureListener) future -> {
            if(future.isSuccess()) {
                log.info("write success");
             }
        });

public class ResponseHandler extends SimpleChannelInboundHandler<FullHttpResponse> {

    Logger log = LoggerFactory.getLogger(getClass());

    private TaskPromise promise;

    public void setResponseHandler(TaskPromise promise) {
        this.promise = promise;
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse msg) throws Exception {
        log.info("channel read0 returned");
        promise.setSuccess(new NettyHttpResponse(ctx, msg));
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext context, Throwable cause) throws Exception {
        log.info("exception caught in response handler");
        this.promise.setFailure(cause);
    }

}

每次调用 executeInternal 都创建一个 promise 将此 promise 放到 ResponseHandler 注册一下,然后将 promise 句柄当做返回值。channel.pipeline().get(xxx).set(yyy) 是在 SO 找到的,看起来像个黑科技。这个函数看起来可以满足需求了。
实际上不然,它不是线程安全的。当两个线程同时调用 executeInternal 时,可能会同时 setResponseHandler,导致第一个 promise 被冲掉,然后两个线程持有同一个 promise,一个 promise 只能被 setComplete 一次,第二次时会 exception。假如把 executeInernal 写成同步的,线程安全问题仍在,因为只要是在一个请求返回来之前设置了 promise,第一个 promise 总是会被冲掉的。看起来这是一个解决不了的问题。

在 github 看了很多别人的代码,发现大家都没认真研究线程安全的问题,或者一个 channel 只发一个消息。查阅了一些资料,了解到InboundHandler 的执行是原子的,不用担心线程安全问题,但这对我也没什么帮助。找到 AsyncRestTemplate 的底层实现, Netty4ClientHttpRequest,我觉得它想做的事情跟我很像,但不过它好像是每个 channel 只发一个消息。因为每次发新的消息,Bootstrap 都会调用 connect 函数。

    @Override
    protected ListenableFuture<ClientHttpResponse> executeInternal(final HttpHeaders headers) throws IOException {
        final SettableListenableFuture<ClientHttpResponse> responseFuture =
                new SettableListenableFuture<ClientHttpResponse>();

        ChannelFutureListener connectionListener = new ChannelFutureListener() {
            @Override
            public void operationComplete(ChannelFuture future) throws Exception {
                if (future.isSuccess()) {
                    Channel channel = future.channel();
                    channel.pipeline().addLast(new RequestExecuteHandler(responseFuture));
                    FullHttpRequest nettyRequest = createFullHttpRequest(headers);
                    channel.writeAndFlush(nettyRequest);
                }
                else {
                    responseFuture.setException(future.cause());
                }
            }
        };

        this.bootstrap.connect(this.uri.getHost(), getPort(this.uri)).addListener(connectionListener);

        return responseFuture;
    }

如果 bootstrap 能够缓存住以前的连接,那么他就是我想要的东西了,但是我循环了 executeInternal 十次,发现建立了十个到 Server 的连接,也就说它并没有重用 channel

update:

上一次写总结时还卡在一个解决不了的并发问题上,当初的并发问题实际上可以写成 how concurrent response mapping to request. 在 Stackoverflow 和中文论坛上有人讨论过这个问题,从他们的讨论中看的结论是:

在 Netty 里,channel 是 multiplex 的,但是返回的 Response 不会自动映射到发出的 Request 上,Netty 本身没有这种能力,为了达到这个效果,需要在应用层做一些功夫。一般有两种做法

  • 如果 Client, Server 都由开发者掌控,那么 client 和 server 可以在交互协议上添加 requestId field, request 和 response 都有 requestId 标识。client 端每发送一个 request 后,就在本地记录 (requestId, Future[Response]) 这么一个 pair, 当 response 返回后,根据 requestId 找到对应的 future, 填充 future
  • 当 server 端不由开发者掌控时,channel 只能被动接受没有状态的 response,没有其他信息可供 client 分辨它对应的是那个 request, 此时就只能使用 sync 模式发送消息了,这样能够保证 response 对应着的就是正在等待它的那个 request. 使用这种方法就失掉了并发的特性,但是可以创建一个 channel pool, 提供一定的并发性

对于有些不需要 response, request 对应关系的服务,channel 的写法可以保持原始的回调函数,比如 heartbeat 服务就可以可以这么写。

源码链接https://github.com/sangszhou/NettyHttpClient

做了个简单的 benchmark, 发现比 apache http client 慢了 2~3 倍,目前还不确定性能瓶颈的位置。

你可能感兴趣的:(Netty http client 编写总结)