mike-neckのブログ

Java or Groovy or Swift or Golang

Spring WebFlux の Router Function のテスト

久々に Spring WebFlux を書いてたのだが、いまいちルーティングがうまくできず、 Routing Function に対してテストを書いてみることにした。

f:id:mike_neck:20180802004547p:plain

で、テストの書き方を調べてみたのだが、とりあえず何も見つからなかった(サーバーを起動するパターンは見つかったが、ルーターのテストごときでサーバーを起動したくない)ので、Reactor のテストの書き方をもとに強引に書いてみた。これが正しいのかどうかはわからない。


プロダクションコード

プロダクションコードはこんな感じ。

@Configuration
class Routing {

  private final MyHandler handler;

  Routing(final MyHandler handler) {
    this.handler = handler;
  }

  @Bean
  RouterFunction<ServerResponse> routerFunction() {
    return routes(GET("/foo"), handler::handle);
  }
}

テストコード

で、書いたテストコード。これはあくまでルーティングが意図したものになっているかを確認するテスト。というのも、ルーティングがうまくできていなかったからテストを書いたため。

@ExtendWith(SpringExtension.class)
@Import({Routing.class, MyHandler.class})
class RoutingTest {

  final Routing routing;

  @Autowired
  RoutingTest(final Routing routing) {
    this.routing = routing;
  }

  @Test
  void test() {
    final ServerRequest request =
      MockServerRequest.builder()
        .method(HttpMethod.GET)
        .uri(URI.create("/foo"))
        .build();

    final Mono<HandlerFunction<ServerResponse>>
      handler =
        routing
          .routerFunction()
          .route(request);

    StepVerifier.create(handler)
        .expectNextCount(1)
        .verifyComplete();
  }
}

なお、 URI については、かなりゆるい URI でもマッチする。具体的には http://bar-baz/foo でもマッチする。当然、 /foo/baz はマッチしない。ちなみに、マッチしない場合のテストの書き方はこんな感じ。

StepVerifier.create(handler)
    .verifyComplete();

余談、というか…

もともとは、パスに対して正規表現でマッチングする方法を調べてた。最初うまくできなかったが、次のようにやればよいらしい。

return route(GET("/{foo:[a-zA-Z0-9]+}"), handler::handle);

Swift Package Manager が Objectiv-C module 'Darwin' をビルドできなかったときの対策

Swift Package Manager が不調(?)で、 swift package generate-xcodeproj すると、コケる

【2018/08/05 22:27 追記】この方法は正しくない。正しい対処の仕方は不明。

$ swift --version
Apple Swift version 4.1.2 (swiftlang-902.0.54 clang-902.0.39.2)
Target: x86_64-apple-darwin17.7.0

$ swift package generate-xcodeproj
/Users/mike/swift-projects/http-client/HTTP-CLIENT: error: manifest parse error(s):
<module-includes>:5:9: note: in file included from <module-includes>:5:
#import "copyfile.h"
        ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.13.sdk/usr/include/copyfile.h:41:9: error: unknown type name 'uint32_t'
typedef uint32_t copyfile_flags_t;
        ^
<module-includes>:5:9: note: in file included from <module-includes>:5:
#import "copyfile.h"
        ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.13.sdk/usr/include/copyfile.h:62:44: error: unknown type name 'uint32_t'
int copyfile_state_get(copyfile_state_t s, uint32_t flag, void * dst);
                                           ^
<module-includes>:5:9: note: in file included from <module-includes>:5:
#import "copyfile.h"
        ^
/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.13.sdk/usr/include/copyfile.h:63:44: error: unknown type name 'uint32_t'
int copyfile_state_set(copyfile_state_t s, uint32_t flag, const void * src);
                                           ^
<unknown>:0: error: could not build Objective-C module 'Darwin'

仕方ないので、 /usr/bin/swift ではなく、 /Applications/... の方の swift で実行する

$ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift --version
Apple Swift version 4.1.2 (swiftlang-902.0.54 clang-902.0.39.2)
Target: x86_64-apple-darwin17.7.0

$ /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift package generate-xcodeproj
Fetching https://github.com/apple/swift-nio.git
Fetching https://github.com/apple/swift-nio-zlib-support.git
Cloning https://github.com/apple/swift-nio.git
Resolving https://github.com/apple/swift-nio.git at 1.8.0
Cloning https://github.com/apple/swift-nio-zlib-support.git
Resolving https://github.com/apple/swift-nio-zlib-support.git at 1.0.0
generated: ./HTTP-CLIENT.xcodeproj

なぜか、実行できる…

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":[ ... ]}

Swift-NIO で HTTP Client を書いてみる

f:id:mike_neck:20180609045321p:plain

この前の土日に Swift-NIO を使って HTTP Client を書いてみたが、うまく動かなかった。


Bootstrap

基本的には Netty と同じ。 ClientBootstrap のイニシャライザーを呼び出して、 EventLoopGroupChannelOptionChannelInitializer(Channel -> EventLoopFuture のこと)を渡して、 connect(host:port:) すればつながるはず。

import NIO

let eventLoopGroup = MultiTHreadedEventLoopGroup(numberOfThreads: 1)
let bootstrap = ClientBootstrap(group:eventLoopGroup)
    .channelOption(ChannelOptions.socket(SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), value: 1)
    .channelInitializer(channelInitializer())
let future = bootstrap.connect(host: "api.github.com", port: 443)
future.then { channel in
    var httpHeadPart = HTTPRequestHead(version: HTTPVersion(major:1, minor: 1), method: HTTPMethod.GET, uri: "http://api.github.com/search/repositories?q=swift&per_page=3")
    httpHeadPart.headers = HTTPHeaders([
        ("Host", "api.github.com"),
        ("Connection", "close"),
        ("Accept-Encoding", "gzip"),
        ("User-Agent", "Swift-NIO"),
        ("Accept", "application/json")
    ])
    return channel.writeAndFlush(HTTPClientRequestPart.head(httpRequestPart))
}

ChannelInitializer

ここもわりとすんなり書けた。

Netty の HttpObjectAggregator に該当するものがないようにも思われるが、 ユーザーコードが受け取るのは HTTPClientResponsePart(= HTTPPart<HTTPResponseHead, ByteBuffer>) となる模様。型からすると、レスポンスが集約されているように思われる。

func channelInitializer() -> (Channel) -> EventLoopFuture<Swift.Void> {
  return { channel in
      let pipeline = channel.pipeline
      let sslHandler = try! OpenSSLClientHandler(context: sslContext)
      _ = pipeline.add(handler: sslHandler, first: true)
      _ = pipeline.addHTTPClientHandlers()
      return pipeline.add(handler: MyClientHandler(), first: false)
  }
}

これでいけるはずだと思っていたが、実際には TLS のところで unableToValiadteCertificate Error が発生してしまって、何もできない…