mike-neckのブログ

Java or Groovy or Swift or Golang

Netty で HTTP クライアントを記述する

Netty で HTTP クライアントの書き方を調べてみたら、意外なことにほとんど見つからなかったので、ブログに書くことにしました。

f:id:mike_neck:20180720074807p:plain


ChannelInitializer

Pipeline に加えるハンドラーと順番は次のとおり

  1. SslHandler
  2. HttpClientCodec
  3. HttpContentDecompressor
  4. HttpObjectAggregator (でかいファイルをダウンロードするなどの用途の場合は入れないほうが良いかもしれない)
  5. 自作の ChannelInboundHandler

それぞれ、

  • SslHandlerhttps に対応するために使います。
  • HttpClientCodecHttpRequestEncoderHttpResponseDecoder を組み合わせた DuplexHandler で、泥臭い HTTP まわりの処理を行っています。
  • HttpContentDecompressor はメッセージ等を gzip に圧縮します。
  • HttpObjectAggregatorHttpClientCodec で作られた HttpContent(チャンク) を1つのレスポンスとしてまとめるハンドラー。複数回呼ばれる channelRead を1回の channelRead にまとめる

参考コード

import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpContentDecompressor;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;

import javax.net.ssl.SSLEngine;

public class HttpHandlerInitializer extends ChannelInitializer<Channel> {

  private final SslContext context;
  private final boolean startTls;

  HttpHandlerInitializer(SslContext context, boolean startTls) {
    this.context = context;
    this.startTls = startTls;
  }

  private SslHandler sslEngine(final Channel channel) {
    final SSLEngine sslEngine = context.newEngine(channel.alloc());
    return new SslHandler(sslEngine, startTls);
  }

  @Override
  protected void initChannel(Channel ch) {
    final SslHandler sslHandler = sslEngine(ch);
    ch.pipeline()
        .addFirst("ssl", sslHandler) // 1
        .addLast("http-codec", new HttpClientCodec()) // 2
        .addLast("decompress", new HttpContentDecompressor()) // 3
        .addLast("aggregate", new HttpObjectAggregator(1048576)) // 4
        .addLast("client", new ClientHandler.ForResponse()) // 5 ユーザーのレスポンスを処理するハンドラー
        .addLast("req", new ClientHandler.ForRequest()); // ユーザーのリクエストを処理するハンドラー
  }
}

Bootstrap

ここは特に難しいものはなさそう。 接続先のホストの名前を connect で指定しているので、HTTP ヘッダーの Host の項目を省略したら、 403 が返ってきてハマった。

import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.*;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.net.ssl.SSLException;
import java.net.InetSocketAddress;

public class NettyHttpClient {

  private static final Logger logger = LoggerFactory.getLogger(NettyHttpClient.class);

  public static void main(String[] args) throws SSLException, InterruptedException {
    final SslContext context = SslContextBuilder.forClient().startTls(false).build();
    final String hostname = "api.github.com";

    final Bootstrap bootstrap = new Bootstrap();
    final EventLoopGroup eventLoopGroup = new NioEventLoopGroup(1);
    bootstrap
        .group(eventLoopGroup)
        .channel(NioSocketChannel.class)
        .handler(new HttpHandlerInitializer(context, false));
    final ChannelFuture channelFuture = bootstrap.connect(new InetSocketAddress(hostname, 443));

    channelFuture.addListener(
        future -> {
          final Channel channel = channelFuture.channel();

          final DefaultHttpHeaders httpHeaders = new DefaultHttpHeaders();
          httpHeaders.add("Host", hostname);
          httpHeaders.add(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
          httpHeaders.add(HttpHeaderNames.ACCEPT_ENCODING, HttpHeaderValues.GZIP);
          httpHeaders.add("User-Agent", "Netty");
          httpHeaders.add("Accept", "application/json");

          final DefaultHttpRequest request =
              new DefaultHttpRequest(
                  HttpVersion.HTTP_1_1,
                  HttpMethod.GET,
                  "/search/repositories?q=java&sort=stars&order=desc&per_page=3",
                  httpHeaders);

          channel.writeAndFlush(request).addListener(future1 -> logger.info("request sent."));
        });

    channelFuture
        .channel()
        .closeFuture()
        .addListener(
            f -> {
              logger.info("closed");
              eventLoopGroup.shutdownGracefully();
            }).sync();
  }
}

ハンドラー

ChannelInboundHandler の実装。 HttpObjectAggregator を通過しているので、 channelRead には FullHttpResponse が渡されることを期待している。

class ClientHandler {

  private static final Logger logger = LoggerFactory.getLogger(ClientHandler.class);

  static class ForResponse extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
      logger.info("response returned.");
      if (msg instanceof FullHttpResponse) {
        final FullHttpResponse httpResponse = (FullHttpResponse) msg;
        logger.info("response type: {}", httpResponse.getClass().getCanonicalName());
        logger.info("http object: {}", httpResponse);
        final String response = httpResponse.content().toString(StandardCharsets.UTF_8);
        logger.info("response body: {}", response);
      }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
      logger.warn("exception caught", cause);
      final Channel channel = ctx.channel();
      if (channel.isOpen()) {
        channel
            .closeFuture()
            .addListener(f -> logger.info("closed"));
      } else {
        logger.info("already closed");
      }
    }
  }
}

このようなハンドラーを作ると、以下のように出力される(一部データを略した)

[nioEventLoopGroup-2-1] INFO com.example.ClientHandler - response returned.
[nioEventLoopGroup-2-1] INFO com.example.ClientHandler - response type: io.netty.handler.codec.http.HttpObjectAggregator.AggregatedFullHttpResponse
[nioEventLoopGroup-2-1] INFO com.example.ClientHandler - http object: HttpObjectAggregator$AggregatedFullHttpResponse(decodeResult: success, version: HTTP/1.1, content: CompositeByteBuf(ridx: 0, widx: 15354, cap: 15354, components=3))
HTTP/1.1 200 OK
Date: Sun, 22 Jul 2018 16:41:42 GMT
Content-Type: application/json; charset=utf-8
Connection: close
Server: GitHub.com
Status: 200 OK
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1532277762
Cache-Control: no-cache
X-GitHub-Media-Type: github.v3
Link: <https://api.github.com/search/repositories?q=java&sort=stars&order=desc&per_page=3&page=2>; rel="next", <https://api.github.com/search/repositories?q=java&sort=stars&order=desc&per_page=3&page=334>; rel="last"
Access-Control-Expose-Headers: ETag, Link, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval
Access-Control-Allow-Origin: *
Strict-Transport-Security: max-age=31536000; includeSubdomains; preload
X-Frame-Options: deny
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
Referrer-Policy: origin-when-cross-origin, strict-origin-when-cross-origin
Content-Security-Policy: default-src 'none'
X-Runtime-rack: 0.158863
Vary: Accept-Encoding
X-GitHub-Request-Id: F3F8:32A5:7B8A63:A432DE:5B54B3C5
content-length: 15354
[nioEventLoopGroup-2-1] INFO com.example.ClientHandler - response body: {"total_count":830457,"incomplete_results":false,"items":[ ... ]}