🥫

Rust で Cap'n Proto を触ってみる

2023/12/24に公開

本記事はLabBase テックカレンダー Advent Calendar 2023 23 日目です。
https://qiita.com/advent-calendar/2023/labbase

Cap'n Proto

https://capnproto.org/

どこかの缶スープを思わせるトップページですが、Cap'n Proto は、gPRC + Protocol Buffers と同じシリアライズと RPC のためのライブラリです。
Cloudflare の ブログ 1, ブログ2 を読んだときに、Cloudflare Workers の内部において Cap'n Proto を利用していることが触れられていました。

Cap'n Proto の特徴としては以下のようなものがあります。

  • Capability-Based RPC protocol
  • メモリ内のデータ構造をそのままバイト列として書き出し・読み込みができる

自分の理解では、Capability-Based というのは ACL などでリソースへのアクセス管理をするのではなく、"capability" というリソースへのアクセスする方法を提供することでアクセス管理をする方式です。
Cloudflare Workers を利用したことがある方はわかりやすいかもですが、Cloudflare Workers で Cloudflare Workers KV を利用する場合、Bindings で指定する必要があると思います。
Bindings で指定することで、Cloudflare Workers のコード上では env を通じて Cloudflare Workers KV に対するアクセスができるようになります。
Cloudflare Workers ではこれが "capability" となっており、この辺りも Cap’n Proto で実現されているとのことです。
ちなみに、Capability-Based の考え方については WASIFuchsia OS にも採用されているとのことです。
ちなみにちなみに、Cap’n Proto の "Cap" は "capability" から来ているとのことです。

Cap’n Proto では、JSON などとは違い、プログラマ上で構築したオブジェクトはメモリ上のバイト表現のままシリアライズすることができ、また、そのシリアライズされたバイトはプログラマ上でそのままデシリアライズすることができます。
また、Cloudflare では Edge デバイスで収集したログを転送する際にも Cap’n Proto を利用しており、JSON よりパフォーマンスが向上したとのことです(Cap’n Proto が全てのデータ構造・ワークロードにおいて最適であるとは限りません)。
余談ではありますが、OLAP Database の ClickHouse ではバイナリフォーマットとして Cap’n Proto をサポートしています(Protocol Buffers などもあるよ)。

Cap’n Proto Schema Language

Protocol Buffers と同様に、Cap’n Proto でもデータ構造や、RPC のために独自のスキーマファイルを作成します。
また、このスキーマファイルからプログラムファイルを生成できます。

Cap’n Proto の Schema Language では、Boolean や Integers といったビルトインの型があり、Struct を定義できます。
RPC のためには Interface を利用し、その中にメソッドを定義します。

以下がドキュメントに例示されている Schema Language の例で、下記はファイルシステムを Schema Language で表したものになります。

interface Node {
  isDirectory @0 () -> (result :Bool);
}

interface Directory extends(Node) {
  list @0 () -> (list :List(Entry));
  struct Entry {
    name @0 :Text;
    node @1 :Node;
  }

  create @1 (name :Text) -> (file :File);
  mkdir @2 (name :Text) -> (directory :Directory);
  open @3 (name :Text) -> (node :Node);
  delete @4 (name :Text);
  link @5 (name :Text, node :Node);
}

interface File extends(Node) {
  size @0 () -> (size :UInt64);
  read @1 (startAt :UInt64 = 0, amount :UInt64 = 0xffffffffffffffff)
       -> (data :Data);
  # Default params = read entire file.

  write @2 (startAt :UInt64, data :Data);
  truncate @3 (size :UInt64);
}

Cap’n Proto を Rust で触ってみる

Cap’n Proto のリファレンス実装は C++ で提供されています。

https://github.com/capnproto/capnproto

他の言語での実装もいくつかあり、今回は Rust での実装 capnproto-rust を利用してみたいと思います。

https://github.com/dwrensha/capnproto-rust

注意点としては、C++ 以外での実装は Cap’n Proto の作者によるレビューはしていないとのことですので、適材適所での利用にしましょう。

helloworld

capnproto-rust のページではいくつかのサンプルプログラムが用意されています。
下記は RPC の echo サーバー的なサンプルで、クライアントから送信された文字列をサーバーから返却するものになっています。

https://github.com/capnproto/capnproto-rust/tree/master/capnp-rpc/examples/hello-world

Schema Language のコンパイル

まずは Schema Language ファイルから、Rust のコードを生成する必要があり、capnp というツールが必要です。
macos であれば homebrew が利用できます。

brew install capnp

他の OS では公式のインストールページを確認してください。

capnp 単体では Schema Language ファイルから Rust のコードを生成できず、 capnproto-rust からコンパイルする必要があります。
なので、以下のような build.rs ファイルが用意されています。

fn main() -> Result<(), Box<dyn std::error::Error>> {
    capnpc::CompilerCommand::new()
        .file("hello_world.capnp")
        .run()?;
    Ok(())
}

生成結果は cargo の OUT_DIR に書き込まれるため、下記のようなコマンドで出力先を探します。

cargo build --message-format=json | jq . | grep out_dir

hello_world_capnp.rs というファイル名が作成されているはずです。

Server 側の実装

まず、Schema Language から生成された Server trait を impl します。

struct HelloWorldImpl;

impl hello_world::Server for HelloWorldImpl {
    fn say_hello(
        &mut self,
        params: hello_world::SayHelloParams,
        mut results: hello_world::SayHelloResults,
    ) -> Promise<(), ::capnp::Error> {
        let request = pry!(pry!(params.get()).get_request());
        let name = pry!(pry!(request.get_name()).to_str());
        let message = format!("Hello, {name}!");

        results.get().init_reply().set_message(message[..].into());

        Promise::ok(())
    }
}

say_hello 関数が Schema Language の方で定義した sayHello に一致します。
このコードで実施していることは、呼び出し元から受け取った文字列 name 変数に束縛し、"Hello, {name}!" と言った形式にフォーマットしたあと、呼び出し元に返却しています。
pry! マクロは try! マクロと似たようなものです。

Cap’n Proto では全ての RPC call は Promise を返します。
これは JavaScript の Promise とほぼ同じものと見なして良いとのことです。
Promise が返却されることで、promise pipelining という機能を実現しています。

Server 側の main 関数では tokio の TcpListener を利用し、TCP サーバーを立ち上げます。
次に、VatNetworkRpcSystem いうのが出てきますが、これは Cap’n Proto 上でのネットワーク実装を表すもの、RpcSystem が RPC サーバーみたいです(あまり自分もよくわかっていない)。

RpcSystem 作成時に、hello_world_client というクライアントを渡しています。
hello_world_client は Schema Language から生成された Server trait を impl した HelloWorldImpl に対するクライアント(= Schema Language 上の interface HelloWorld に対するクライアント)です。
これを RpcSystem に渡すと、TCP コネクション確立時にクライアントにクライアントを渡すことができます。
「クライアントにクライアントを渡す」という、サンドウィッチマンが出てきそうな意味不明なことを言っていますが、ここが Capability-Based になります。
「クライアント」の文脈を補足して言い直すと、TCP コネクションを確立してきたクライアント(これは TCP 上のクライアント)に対して、Schema Language 上の interface HelloWorld に対するクライアントを渡すことができます。
TCP クライアントは、TCP サーバーから渡された Schema Language 上の interface HelloWorld に対するクライアントを利用して RPC コールをすることができます(="capability" )。

    let listener = tokio::net::TcpListener::bind(&addr).await?;
    let hello_world_client: hello_world::Client = capnp_rpc::new_client(HelloWorldImpl);

    loop {
        let (stream, _) = listener.accept().await?;
        stream.set_nodelay(true)?;
        let (reader, writer) =
            tokio_util::compat::TokioAsyncReadCompatExt::compat(stream).split();
        let network = twoparty::VatNetwork::new(
            reader,
            writer,
            rpc_twoparty_capnp::Side::Server,
            Default::default(),
        );

        let rpc_system =
            RpcSystem::new(Box::new(network), Some(hello_world_client.clone().client));

        tokio::task::spawn_local(rpc_system);

client 側

クライアント側を見てみます。
TcpStream を利用して、TCP 接続を先程のサーバーに対して確立します。
その後、例の通り VatNetworkRpcSystem を立ち上げます。
ここで、RpcSystembootstrap メソッドを利用することで、サーバー側から RPC クライアント、ここでは Schema Language 上の interface HelloWorld に対するクライアントを受け取ることができます。
こうして受け取った RPC クライアントを利用して、Schema Language の方で定義した sayHello コールを呼び出すことができます。

    let stream = tokio::net::TcpStream::connect(&addr).await?;
    stream.set_nodelay(true)?;
    let (reader, writer) =
        tokio_util::compat::TokioAsyncReadCompatExt::compat(stream).split();
    let rpc_network = Box::new(twoparty::VatNetwork::new(
        reader,
        writer,
        rpc_twoparty_capnp::Side::Client,
        Default::default(),
    ));
    let mut rpc_system = RpcSystem::new(rpc_network, None);
    let hello_world: hello_world::Client =
        rpc_system.bootstrap(rpc_twoparty_capnp::Side::Server);

    tokio::task::spawn_local(rpc_system);

    let mut request = hello_world.say_hello_request();
    request.get().init_request().set_name(msg[..].into());

    let reply = request.send().promise.await?;

    println!(
        "received: {}",
        reply.get()?.get_reply()?.get_message()?.to_str()?
    );

まとめ

Cap’n Proto について Rust のサンプルコードを動かしてみました。
Cap’n Proto の全貌は未だに掴めていない(ドキュメント少ない)ですが、Cap’n Proto のドキュメントは設計思想が面白く解説されていて、今回は紹介出来ていませんが、promise pipelining やそれに伴う singleton-ish interface に関する項目についてなど、読んでいて面白かったです。
今後も Cap’n Proto について調べてみるのと、Cap’n Proto を利用したシステムを作ってみたいです。

その他参考

https://dev.to/kushalj/capn-proto-rpc-at-the-speed-of-rust-part-1-4joo
https://news.ycombinator.com/item?id=36908309

Discussion