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

ChannelInitializer
Pipeline に加えるハンドラーと順番は次のとおり
SslHandlerHttpClientCodecHttpContentDecompressorHttpObjectAggregator(でかいファイルをダウンロードするなどの用途の場合は入れないほうが良いかもしれない)- 自作の
ChannelInboundHandler
それぞれ、
SslHandlerはhttpsに対応するために使います。HttpClientCodecはHttpRequestEncoderとHttpResponseDecoderを組み合わせたDuplexHandlerで、泥臭い HTTP まわりの処理を行っています。HttpContentDecompressorはメッセージ等を gzip に圧縮します。HttpObjectAggregatorはHttpClientCodecで作られた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":[ ... ]}