mike-neckのブログ

Java or Groovy or Swift or Golang

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 が発生してしまって、何もできない…

URLSession による HTTP 接続と libcurl

f:id:mike_neck:20180609045321p:plain

swift で HTTP でAPIを呼び出すときなどは URLSession#dataTask(with:,completionHandler:) を使うのが一般的らしい。

guard let url = URL(string: "http://localhost:8080/api") else {
  throw MyURLError.invalidURL
}
URLSession.shared.dataTask(with: url) {(data, response, error) in
  if let e = error {
    print("error: \(e)")
  }
  if let r = response as? HTTPURLResponse {
    print("status: \(r.statusCode)")
    print("Content-Type: \(r.allHeaderFields["Content-Type"] ?? "")")
  }
  if let d = data {
    let body = String(data: d, encoding: String.Encoding.utf8) ?? ""
    print("body: \(body)")
  }
}

疑問: URLSession ってどんな非同期なの?

URLSession + URLSessionDataTask の組み合わせによる、一見非同期に見えるこの処理は本当に非同期なのか、それとも非同期っぽい同期コードなのかを知りたい。ここで、

  • ちゃんと非同期
    • select ないしは kqueue を使って、イベントループがネットワークのイベントを拾ってから処理をおこなっている
    • 大量のリクエストを出しても性能が劣化しない(いくつものリクエストをさばける)
    • Java で NIO 使った場合はこちらになる
  • 非同期っぽい同期
    • 同期的なネットワーク呼び出しを GCD でディスパッチして、バックグラウンドのスレッドに固まってもらうことで、 main スレッドは固まらないようにしている
    • 準備したスレッドプールの数を超えるリクエストを出すと、全体としての性能が劣化する
    • Java で NIO 使わない場合は、こちらになる

とする。3連休は自宅でダラダラ過ごしながら、どんな感じの非同期なのか疑問に思った我々は swift-corelibs-foundation と swift-corelibs-libdispatch に挑んだ。


結論: ちゃんと非同期

結論としては、前者であるが、 selectkqueue を直接使っているわけではない。

具体的には URLSession の通信部分は libcurl をつかっていて、一つひとつのリクエストは libcurl の easy handle API を利用している。それらを URLSession が保持している libcurl の multi handle API で処理している形である。


以下、読んだ内容をざっくりと…(C言語が読めないし、 libcurl よく知らないし、 swift も大して読めないので不正確かもしれない)

  • URLSession
    • 複数のリクエストもふくめて、 リクエスト処理をあらわす URLSessionTask(_TaskRegistry で管理されている) やそれをハンドルする DispatchQueue を管理しているクラス
    • インスタンス生成時に、その URLSession が管理する multi handle API を表す _MultiHandleインスタンスを生成する
    • dataTask などのメソッドでは URLSessionTaskインスタンスと、 completion handler をラップした _TaskRegistry._Behaviour_TaskRegistry に加える
  • URLSessionTask
    • リクエストの処理をユーザーが制御するためのクラスで、 ファイルを伴わないリクエスト -> URLSessionDataTask 、 Multi Part でファイルをアップロードするリクエスト -> URLSessionUploadTask 、 ファイルをダウンロードするリクエスト -> URLSessionDownloadTask などのサブクラスがある
    • これらのクラスはイニシャライザーでリクエストの URL に基づき URLProtocol を生成・保持する
    • URLSessionTaskresume 関数にて、 URLProtocolstartLoading 関数を経由して、 HTTPURLProtocolconfigureEasyHandle 関数にて libcurl の easy handle API のラッパーである _EasyHandle にリクエストのパラメーターを渡していく
  • URLProtocol
    • 多くの場合は HTTPURLProtocolインスタンスが生成されるが、ほとんどの関数はスーパークラスである _NativeProtocol のものが使われている
    • リダイレクトの場合の制御や、データ読み込み完了の際に呼び出される関数が提供されている
    • これらのコールバックが提供されているのは、 _NativeProtocol_EasyHandleDelegate プロトコルに準拠しているため
  • _MultiHandle
    • selectkqueue のような役割を提供している
    • ソケットのステータスが変更された場合に呼び出されるコールバック関数を提供する
    • curl multi API に監視対象の _SocketSources が登録されていない場合には、新しい _SocketSources を生成・登録する
    • ソケットの状態に基づき、 _ReadSource_WriteSource を登録する
    • _MultiHandle の中にイベントループがあり、 curl multi API から curl_multi_info_readを呼び出す。その関数で _EasyHandle が見つかった場合(つまり読み込み完了した場合)は _EasyHandlecompletedTransfer(withError:) を呼び出す。これは URLProtocol(_NativeProtocol) の transferCompleted(withError:) にディスパッチされて、パラメーターの error: NSError の有無によって、 failWith(:error, request:)(エラーの場合)または comleteTask() または redirectFor(request:) が呼び出される。 completeTask が呼ばれた場合は _ProtocolClienturlProtocolDidFinishLoading(_:URLProtocol) が呼び出されて、 URLSession が抱えている DispatchQueue 上で URLSessionTask から取得した completion handler(つまりユーザーが dataTask に与えたハンドラー) にレスポンスを渡す。処理が終わったあとは _TaskRegistry から URLSessionTask を取り除く

_EasyHandleDelegate

protocol _EasyHandleDelegate {
  // レスポンスを受け取ったとき
  func didReceive(data: Data) -> _EasyHandle._Action
  // レスポンスを受け取ったとき
  func didReceive(headerData data: Data, contentLength: Int64) -> _EasyHandle._Action
  // POST のボディを書き込めるとき
  func fill(writeBuffer buffer: UnsafeMutableBufferPointer<Int8>) -> _EasyHandle._WriteBufferResult
  // 書き込み完了後
  func transferCompleted(withError error: NSError?)
  // input stream のシーキング(? CURLOPT_SEEKFUNCTION で渡すオプションのコールバック e.g. https://curl.haxx.se/libcurl/c/CURLOPT_SEEKFUNCTION.html)
  func seekInputStream(to position: UInt64) throws
  // プログレスを更新 (? CURLOPT_XFERINFOFUNCTION で渡すオプションのコールバック e.g. https://curl.haxx.se/libcurl/c/CURLOPT_XFERINFOFUNCTION.html)
  func updateProgressMeter(with propgress: _EasyHandle._Progress)
}

以上から、動作は libcurl の multi handle API をベースにしたイベント駆動の作りになっており、一つの URLSession で何個でもリクエストを投げられるようになっている