ブラウザとWebサーバーのための新しいRPCシステム Cap'n Web
(blog.cloudflare.com)- Cap'n WebはTypeScriptで実装された新しいRPCプロトコルで、Web環境に最適化され、さまざまなJavaScriptランタイムで動作する
- スキーマや煩雑なボイラープレートなしで、JSONベースのシリアライズと人間が読めるデータ形式を提供する
- オブジェクトケーパビリティベースのモデルにより、双方向呼び出し、関数・オブジェクト参照の受け渡し、Promiseパイプライニング、セキュリティパターンの実装が可能
- WebSocket, HTTP, postMessage など多様なネットワーク環境をサポートし、10kB以下の軽量オープンソース
- GraphQLに似たwaterfall問題の解決だけでなく、一般的なJavaScript APIのような自然なRPCモデリングを可能にする
Cap'n Webとは何か
- Cap'n WebはCloudflareが開発したTypeScriptベースのオープンソースRPC(protocol)システム
- Cap'n Protoに着想を得ているが、別途スキーマ定義なしで動作し、JSONを活用した人間にやさしいシリアライズ方式を採用
- TypeScriptと統合されており、自動補完や型チェックなど開発者体験を向上させ、ランタイムの型検証は別途(type guardなど)で処理できる
- HTTP, WebSocket, postMessageなどのネットワークプロトコルをサポートし、主要ブラウザ・Cloudflare Workers・Node.jsなどで動作
- 依存関係のない軽量な構造で、minify + gzip時に10kB未満で提供される
Cap'n Webのオブジェクトケーパビリティベースモデル(OCap)
- オブジェクトケーパビリティ(object-capability)ベースのモデルを採用し、既存のRPCシステムより多様な表現が可能
- 双方向呼び出し: クライアントとサーバーが互いに関数を呼び出せる
- 関数・オブジェクト参照の受け渡し: 関数やオブジェクトをRPCで渡すと、相手はスタブを受け取り、呼び出し時に元の場所で実行される
- Promise Pipelining: 複数のRPCをチェーンでつなぐ際、1回のネットワーク往復で処理
- セキュリティパターン: 権限付与やセッション管理などのセキュリティ制御を自然に実装できる
基本的な使い方
-
クライアントの例
import { newWebSocketRpcSession } from "capnweb" let api = newWebSocketRpcSession("wss://example.com/api") let result = await api.hello("World") console.log(result) -
サーバーの例(Cloudflare Workerベース)
import { RpcTarget, newWorkersRpcResponse } from "capnweb" class MyApiServer extends RpcTarget { hello(name) { return `Hello, ${name}!` } } export default { fetch(request, env, ctx) { let url = new URL(request.url) if (url.pathname === "/api") { return newWorkersRpcResponse(request, new MyApiServer()) } return new Response("Not found", {status: 404}) } } -
APIへのメソッド追加、クライアントのコールバック関数の受け渡し、TypeScriptインターフェースの定義と適用を簡単に行える
RPCとは何か、そしてCap'n Webの特徴
- **RPC(Remote Procedure Call)**は、ネットワーク上の2つのプログラムがあたかも関数呼び出しのように通信できるようにする概念
- 従来のHTTP/RESTプロトコルと異なり、RPCでは関数呼び出しの抽象化によって開発者の思考様式に一致するコードを書ける
- Cap'n Webはasync/await, Promise, Exceptionサポートなど、最新のJavaScriptの流れとよく合う
- RPCをめぐる歴史的な論争(同期呼び出し、ネットワーク障害)とは異なり、現代のJS環境ではより安全かつ効率的に利用できる
Cap'n Webの活用シナリオ
- 2つのJavaScriptアプリケーション間でネットワーク通信が必要なあらゆる環境で活躍
- クライアント-サーバー、マイクロサービス間呼び出しなど
- 特にリアルタイム共同作業Webアプリや、複雑なセキュリティ境界をまたぐ相互作用に適している
- 実験的な段階にあり、新しい技術の導入に前向きな開発者にとって特に有益
さまざまな機能
HTTPバッチモード
-
継続的な接続が不要な場合、HTTPバッチ(batch)モードで複数のRPC呼び出しを一度にまとめて処理できる
import { newHttpBatchRpcSession } from "capnweb" let batch = newHttpBatchRpcSession("https://example.com/api") let result = await batch.hello("World") console.log(result) -
1つのバッチ内で複数の呼び出しを同時実行し、結果を並列に受け取れる
let promise1 = batch.hello("Alice") let promise2 = batch.hello("Bob") let [result1, result2] = await Promise.all([promise1, promise2])
Promise Pipelining(チェーン呼び出し)
-
前の呼び出し結果を待たずに、その結果をすぐ次の呼び出しの引数として使う方式をサポート
-
例)
getMyName()の結果Promiseをそのままhello()に渡し、1回のネットワーク往復で処理let namePromise = batch.getMyName() let result = await batch.hello(namePromise) -
Cap'n WebのPromiseはプロキシ(proxy)オブジェクトとして動作し、追加メソッド呼び出し時も遅延なくチェーン処理できる
let sessionPromise = batch.authenticate(apiKey) let name = await sessionPromise.whoami()
セキュリティ: 認証とオブジェクトケーパビリティ
- authenticateメソッドにより成功時に権限(セッション)オブジェクトを割り当て、その後は追加認証なしで機能を呼び出せる
- 従来のRPCと異なりセッションオブジェクトを偽造できず、認証なしでは権限が必要なメソッドにアクセスできない
- WebSocketの構造的な制約を自然に克服し、認証ロジックの一貫性を保証
- TypeScriptでAPIインターフェースを宣言すれば、クライアント〜サーバーに自動適用でき、自動補完と型安全性を確保できる
GraphQLとの比較とCap'n Webの差別化ポイント
-
GraphQLはRESTのwaterfall(多段階呼び出し)問題を緩和するが、新しい言語・スキーマ・ツールチェーンの導入が必要
-
Cap'n WebはJavaScriptコードだけでwaterfall問題を解決し、
- Promiseパイプライニング / オブジェクト参照のサポートにより、ネストした呼び出しや複合トランザクションロジックを自然にモデル化できる
let user = api.createUser({ name: "Alice" }) let friendRequest = await user.sendFriendRequest("Bob") -
GraphQLの複雑さや学習・運用コストなしに、JavaScript APIのように活用できる
配列演算(array.mapなど)と最適化
-
Cap'n Webでは配列の各要素に対して追加のネットワーク往復なしでmap演算が可能
-
mapコールバック関数をクライアントで一度実行して演算内容を記録(record-replay)し、サーバーへ送信してサーバー側で一括処理する
let friendsWithPhotos = friendsPromise.map(friend => { return {friend, photo: api.getUserPhoto(friend.id)} }) let results = await friendsWithPhotos -
制限付きのドメイン特化言語(DSL)により、JavaScript関数のように表現しつつ、実際にはCap'n Webプロトコルを活用して複数呼び出しを最適化処理する
内部プロトコル構造と通信フロー
- JSON + 特別な前処理による構造化データ転送で、配列・日付などの特別な型もサポート
- 対称的なプロトコルによりクライアント・サーバーの区別なく双方向通信が可能
- 各パーティ(例: AliceとBob)はexport/importテーブルを管理し、オブジェクト・関数参照をIDで区別
- push/pullメッセージやPromise IDの割り当てなどにより、1回のラウンドトリップで多数の呼び出しを反映できる
現状と適用事例
- Cap'n Webはまだ実験的なオープンソースで、Cloudflare Wranglerのremote bindingsなど実サービスで活用されている
- 追加のブログ投稿やさまざまなフロントエンド実験も予定されている
- MITライセンスで公開され、誰でも自由に適用可能
- GitHubリポジトリはこちら
1件のコメント
Hacker Newsのコメント
2つ気になる点がある。
本当に革新的な仕事だと思う。
コールバックを持つ購読(subscription)オブジェクトがあるなら、開始時点で「最後に見たメッセージ」を指定できるようにAPIを設計すべきだ。そうすればすぐ続きからデータを受け取れ、途中で取りこぼさない。
こういうデザインパターンをブログ記事シリーズとして一度まとめるべきかもしれない。
配列の問題をどう解決したのかに関するセクションが本当に興味深く、同時に少し怖くもある。ブログリンク
.map()の場合、サーバーにJavaScriptコードそのものを送るわけではないが、「コード」のような何かを送っていて、これは限定的なドメイン固有言語(DSL)を使っている。クライアント側でコールバックをプレースホルダー値を入れて一度実行してみて、その動作をrecord-replay方式で追跡し、サーバーへinstruction setを送る。サーバーではそのinstructionを受け取り、配列の各メンバーごとに実行することになる。つまり開発者はただJSメソッドを使っているだけなのに、実際にはこれを狭いDSLへ変換するトリックが使われている。コールバックは同期的にしか動作できず、
awaitは不可能。その代わりpromise pipeliningだけを許可して、その過程をすべて捕捉してサーバーへ渡し、サーバーでは必要に応じて再実行する。C#にはこの種の問題を扱うexpression treeがある。Entity Frameworkがラムダ式を受け取ってSQLクエリに変換するときにそれを活用している。つまり、コードを実行せずに走査したり変換したりしながら利用できる。
たとえば、
db.People.Where(p => p.Name == "Joe")では、Whereは実際のpredicate関数を受け取るのではなくexpressionを受け取るので、渡されたコードを走査してNameフィールドが"Joe"と一致するかを確認し、SQLのWHERE句へ変換する。JavaScriptにはこうした仕組みがないので、プレースホルダー値を入れてどう動くかを一つひとつ記録して真似しているわけだ。
最近Tanstack DBのクエリDSLを作るときにも、このrecord-replayのトリックを使った。ガイドリンク。where/select/joinのコールバックにRefProxyオブジェクトを渡し、そのオブジェクトに対してどんなprop/演算が発生したかを追跡する。
JSでは通常の演算子(==、> など)を直接フックできないので、eq/gt/notなどの追跡可能な小さな関数を作り、コールバックを一度だけ実行して連結された式を捕捉し、IRにする。
驚いたことに、JSのspread演算子まで追跡できた。
Kenton、もし可能ならこの概念をcapnwebにもfake operator(eq、gt、inなど)として追加して、リモートトレーシング機能を入れられないだろうか。
条件分岐は禁止されているように見えるが(まるでReact Hooksのルールのように)、そうした制約をどう実装しているのか気になる。
このプロジェクトは興味深い。
MLコンパイラライブラリ(TensorFlow 1、JAX jit、PyTorch compileなど)に似た側面がある。トレーシング方式でoperation graphを作り、それをcompileしたりVM向けに変換したりして実行する。
現在は動的言語をフロントエンドとして新しいDSLを定義するのではなく、既存のスクリプト言語の中にAST変換を隠している。
MLではGPU/linalgカーネルの実行を遅らせてカーネルを融合するが、Cap'n WebのようなRPCではネットワークリクエストを遅らせて複数のnetwork callをまとめられる。
結局のところinstruction/data planeを分離するのが核心であり、ごく小さなスケールの単一CPUでさえ分散システム的な構造(命令/データキャッシュ分離)を持っている。
Cap'n WebではRPC graph自体がinstructionの役割を果たす。
こういうパターンは本当に面白いが、スタック構造(compilerの上にinterpreter、その上にcompiler...)が無限に繰り返されるようにも感じる。Lispyの code is data, data is code パターンの別バージョンを見ている気分だ。何かもっと根本的に深いストーリーがありそうだ。
動的言語はいまや新しいDSLのフロントエンドになりつつあり、その代わり新しい文法を定義するのではなく、スクリプトの中にAST生成を溶け込ませている。
ここでゲームチェンジャーになるのがTypeScriptだと思う。JavaScriptのランタイムの柔軟性(Cap'n Webが巧みにProxyを使っているように)と型安全性を同時に得られるからだ。
最近はORM界隈でこの考え方にハマっている。ほとんどのORMは直列的でeagerな方式なので、クエリ実行直前にしか操作できない。
本当にcomposableなORMはcompilerのように動くべきだと思う。TypeScriptでSQL上に完全に型安全なDSLを定義してクエリASTを作り、最後にだけSQLへcompileすればよい。
私が開発中のTypegresもまさにこのアイデアそのものだ。こういうパターンに興味があるなら参考になると思う。
RPCライブラリの本質的な問題は、round-tripがどこでどう発生するのかを隠そうとする点にある。
Cap'n Webのarray
.map()だけ見ても、実際にどこでnetwork round-tripが起きるのか分かりにくい。これは「機能」ではなく、むしろ「バグ」だと思う――コードを見れば動作がすぐ分かるべきで、それを隠すのは望ましくない。
参考リンク
awaitを使ったときに発生する。promise pipeliningでは複数のstatementを
awaitなしで並べて設定できるので、その途中で追加のnetwork往復は発生しない。最後に一度awaitすれば、それで全部だ。gRPCとWebを使ったことがあるなら、ProtobufをWebに適用するのがどれほどつらいか分かるはずだ。
Cap'n Webのシンプルさは本当に良い。capnprotoドキュメント
Cap'n WebはCap'n Protoと違ってスキーマがまったくない。不要なboilerplateがほとんどないので、Cloudflare WorkersのJavaScriptネイティブRPCにかなり近い感触がある。
github参考
kentonvの新しいライブラリを見つけてすぐ飛んできた。
GitHubに上がっているコードを見ると、意外なほど規模がとても小さくて驚いた。これで全部なのか気になる。
理論的には、サーバーサイド側を別の言語に移植するのもそれほど難しくなさそうで、私はElixirサーバーとJS/TSフロントエンドで使ってみたくなった。
LLMにこういう言語移植をやらせるのも面白そうだ。もしかしてこのリポジトリにLLMベースのコードは入っているのだろうか。数か月前にkentonvがAI生成(人間がレビューした)POCを作ったという話を見た覚えがある。
現時点では、LLMがこのライブラリを作るのは難しかったと思う。内部構造が非常に精巧にかみ合ったパズルのように設計されている。
実際のコードより設計を考える時間のほうが長かった。
well-known specをnovelな形で実装するworkers-oauth-providerライブラリとはまったく違う。
コード構造は動的言語、たとえばPythonには移植しやすいかもしれないが、静的型付け言語では難しいと思う。任意オブジェクト型に依存する部分が多いからだ。
OCapNとの類似点と重要な違いもある。参考
どちらもcapability transfer、promise pipelining、schemalessモデルをサポートしている。
Cap'n Webには、OCapNのsturdyref(復元可能なURI)のようなout-of-band capabilityがない。このためAPI key認証が必要なのだろうと推測している。sturdyrefは一種の推測不能なトークンで、それを持っていればそのエンドポイントへのアクセス権が得られる。
またCap'n Webには、AliceがBobをCarolに紹介するような三者ハンドオフ機能がない。これは分散アプリには必須なので、その意味でCap'n Webは伝統的なSaaSスタイルのclient-server用途にocap的な特徴を加えたサービスにより近い。
SturdyRefはプラットフォームごとに復元方法が異なるので、RPCプロトコルレベルよりも各プラットフォームに合わせて実装すべきだと考えている。
たとえばCloudflare Workersでは、近いうちにDurable Object storageでcapabilityの永続化が可能になる予定だが、実装方法はWorkersプラットフォームに特化している。
Sandstormにもpersistent capabilityはあるが、内部サービスに限られる。
そのためCap’n Protoではpersistent capabilityの概念自体を取り除いており、Web標準でそれに最も近いものはOAuthだ。
OAuth refresh tokenベースのsturdyref定義も想像はできるが、特定プラットフォーム以外でも使える構造にはならない。
ざっと見たところ、このシステムはimport/exportテーブルやオブジェクト状態をサーバー側でstatefulに保持することを要求している、あるいは推奨しているように見える。
従来のRPCでは、すべての呼び出しがトップレベルに入り、各呼び出しにkeyなどを渡すので、複数サーバーにリクエストが分散されても問題ないが、Cap’n Webはそうではない。
テーブルをシリアライズしてDBに保存することで同じようにサーバー分散できるのか、それともサーバーaffinityやDurable Objectsのような構造を必ず要求するのかが気になる。
状態は単一のRPCセッションでのみ保持される。
WebSocketを使う場合、WebSocket接続が維持されている間だけ状態も生きている。
HTTP batch転送を使う場合、セッションは単一のHTTPリクエスト全体に限定され、その中で全呼び出しが一度に処理される。
したがって、複数のHTTPリクエスト/接続にまたがって状態を保持する必要はCap’n Webにはない。
ただし、セッションが途中で切れるとすべてのcapabilityを失ってしまうような設計が問題になるなら、そうしたデザインは避けるべきだ。いつでも接続を張り直してcapabilityを復元できるようにしておく必要がある。
ドキュメントを読む限り、WebSocketでaffinityを保つ構造に見える。
HTTP batchingはすべてのリクエストを一度に送り、応答を待つ方式だ。
こういう方式はロードバランシングが難しくなる。チャットクライアントが多いと特定サーバーに接続が集中する構造になりうる。その結果、そのサーバーが過負荷になるおそれがある。
サーバーのスケールイン/アウトも面倒になる。長期接続を維持しながら複数のリクエストを同時処理していると、管理は非常に難しい。
もう一つ、クライアントが常に応答を受け取らずpushイベントだけを送り続けると、サーバーはその応答をずっとメモリに保持しなければならず、DDoS攻撃が容易になると思う。
以前Cap'n Protoのドキュメントを読んだときの記憶では、サーバーとクライアントはpeer stubをやり取りできる。
サーバーCがクライアントBを介してAで生成されたstubを渡された場合、CはAを直接呼び出すこともできる。
「RPC」はもともと、リモート呼び出しが内部関数呼び出しと区別不能に見えるようにするプログラミングパラダイムだ。
実際にはそのためにwire protocolやクライアント/サーバーライブラリなどが必要になる。
最近では認識がかなり変わっていて、RESTエンドポイントのように関数シグネチャを持つ構造が主流だ。
Future、Optionalなどのプログラミング言語機能が現れたことで、「この動作は遅延する可能性がある」「失敗する可能性がある」といった性質を明確に区別できるようになった。
過去のRPCではこうした属性がすべて隠されていた。
どういう意味なのか気になる。非同期プログラミングはさまざまな言語に存在する。JavaScript、C++、Python、Rust、C#など、ほぼすべて使ったことがある。
要するに、初期のRPCシステムはネットワークリクエストが進行している間、呼び出しスレッドをブロックする構造で、これは本当に悪い設計だったので、今では非同期が当然になったということだ。
Cap'n WebがCloudflare製品だけに縛られず独立して存在しているのが非常に楽しみだ。
ドキュメントのこの部分を読んで気になった。
むしろCap'n WebのほうがWorkers RPCより先行する可能性すらあると思う(実際、パイプライン機能はすでに先を行っている)。
Cap'n Webの構造のほうがずっと単純なので、新機能の実験もまずCap'n Webで行うことになるだろう。