Netty で HTTP クライアントの書き方を調べてみたら、意外なことにほとんど見つからなかったので、ブログに書くことにしました。
ChannelInitializer
Pipeline に加えるハンドラーと順番は次のとおり
SslHandler
HttpClientCodec
HttpContentDecompressor
HttpObjectAggregator
(でかいファイルをダウンロードするなどの用途の場合は入れないほうが良いかもしれない)- 自作の
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":[ ... ]}