在当今瞬息万变的Web开发世界中,构建高性能、安全且易于维护的分布式系统是每一位开发者面临的挑战。远程过程调用(RPC)作为一种核心技术,使得不同服务间的通信变得透明。今天,我们将深入探讨一个专为现代Web栈设计的创新RPC系统——Cap’n Web。它不仅是Cap’n Proto的“精神兄弟”,更在JavaScript生态系统中带来了独特的简洁与强大。
Cap’n Web 的诞生与核心理念
Cap’n Web 由 Cap’n Proto 的同一位作者创建,但它的设计理念是“完美融入Web栈”。这意味着它保留了Cap’n Proto强大而独特的核心——能力对象协议(Object-Capability Protocol),同时又抛弃了Cap’n Proto复杂的模式(schemas),转而追求极简的JavaScript原生体验。
“Cap’n”这个名字,实际上是“capabilities and”的缩写,直指其核心的“能力对象”概念。能力对象协议是一种强大的安全范式,它将系统的权限和访问控制通过可传递的对象引用来管理。这种模型能够让开发者构建出更加健壮和安全的分布式应用。
与Cap’n Proto不同,Cap’n Web的最大亮点在于它的“零样板代码”特性。它没有复杂的模式定义,开发者可以像编写普通JavaScript代码一样编写RPC服务。它的底层序列化机制也更加亲民,直接使用人类可读的JSON格式,辅以少量的预处理和后处理。同时,它对TypeScript提供了完美的原生支持,让类型安全在分布式调用中也触手可及。
Cap’n Web的兼容性也令人印象深刻:它开箱即用地支持HTTP、WebSocket和postMessage()等多种传输方式,并能轻松扩展到其他协议。无论是主流浏览器、Cloudflare Workers、Node.js,还是其他现代JavaScript运行时环境,Cap’n Web都能游刃有余。更令人惊喜的是,整个库经过压缩(minify+gzip)后,其大小不足10KB,且没有任何外部依赖。
能力对象RPC:Cap’n Web 的超凡表达力
Cap’n Web之所以比大多数RPC系统更具表现力,正是因为它实现了能力对象RPC模型。这赋予了它一系列独特且强大的功能:
- 双向调用支持:客户端不仅可以调用服务器上的方法,服务器也可以反过来调用客户端上的方法,这为实时交互和复杂工作流提供了无限可能。
- 按引用传递函数:当你在RPC中传递一个函数时,接收方会得到一个“存根”(stub)。当他们调用这个存根时,实际上会发起一个RPC请求,回到原始函数被创建的位置执行。这种机制正是实现双向调用的基石:客户端可以将回调函数传递给服务器,服务器便能在未来某个时刻调用它。
- 按引用传递对象:类似地,如果一个类扩展了特殊的标记类型
RpcTarget
,那么该类的实例将按引用传递。这意味着当通过存根调用其方法时,实际执行的是对象被创建位置的代码。 - Promise 管道化(Promise Pipelining):这是一个革命性的特性。当你发起一个RPC调用时,会立即得到一个Promise。你无需等待它解析,而是可以立即在后续的RPC调用中使用这个Promise。这样,一系列相互依赖的调用可以在单次网络往返中完成,极大减少了延迟。
- 基于能力的安全模式:通过精确控制哪些对象和功能被暴露给远程调用者,Cap’n Web能够支持细粒度的基于能力的安全模式,从而构建出更加安全的系统。
快速上手 Cap’n Web
安装
Cap’n Web 是一个标准的npm包,安装非常简单:
npm i capnweb
基础示例
想象一下,我们想构建一个简单的“Hello, World!”服务。
客户端代码:
import { newWebSocketRpcSession } from "capnweb";
// 一行代码设置RPC会话
let api = newWebSocketRpcSession("wss://example.com/api");
// 调用服务器上的方法!
let result = await api.hello("World");
console.log(result);
服务器端代码(以Cloudflare Workers为例):
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
// 这是服务器的实现。
class MyApiServer extends RpcTarget {
hello(name) {
return `Hello, ${name}!`
}
}
// 标准的Cloudflare Workers HTTP处理器。
// (Node和其他运行时也支持,详见下文。)
export default {
fetch(request, env, ctx) {
// 解析URL进行路由。
let url = new URL(request.url);
// 在`/api`路径提供API服务。
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new MyApiServer());
}
// 你可以在这里提供其他端点...
return new Response("Not found", {status: 404});
}
}
这段代码简洁明了,无需复杂的接口定义文件或代码生成步骤。开发者可以专注于业务逻辑,而Cap’n Web则负责底层的通信魔法。
更复杂的示例:Promise 管道化与 TypeScript
让我们来看一个更复杂的场景,它结合了TypeScript和Promise管道化,实现了单次往返的多个依赖调用。
首先,我们在一个共享的类型文件中声明接口:
interface PublicApi {
// 验证API令牌,并返回已认证的API。
authenticate(apiToken: string): AuthedApi;
// 获取给定用户的公共资料信息。(无需认证)
getUserProfile(userId: string): Promise<UserProfile>;
}
interface AuthedApi {
getUserId(): number;
// 获取用户所有好友的用户ID。
getFriendIds(): number[];
}
type UserProfile = {
name: string;
photoUrl: string;
}
在服务器端,我们实现这些接口:
import { newWorkersRpcResponse, RpcTarget } from "capnweb";
class ApiServer extends RpcTarget implements PublicApi {
// ... 实现 PublicApi 接口 ...
async authenticate(apiToken: string): Promise<AuthedApi> {
// 实际的认证逻辑
if (apiToken === "valid-token") {
return new AuthenticatedApiImpl(123); // 假设用户ID是123
}
throw new Error("认证失败");
}
async getUserProfile(userId: string): Promise<UserProfile> {
// 实际的用户资料获取逻辑
return { name: `用户 ${userId}`, photoUrl: `/photos/${userId}.jpg` };
}
}
class AuthenticatedApiImpl extends RpcTarget implements AuthedApi {
private userId: number;
constructor(userId: number) {
super();
this.userId = userId;
}
getUserId(): number {
return this.userId;
}
getFriendIds(): number[] {
// 假设的好友ID
return [456, 789];
}
}
export default {
async fetch(req, env, ctx) {
let url = new URL(req.url);
if (url.pathname === "/api") {
return newWorkersRpcResponse(req, new ApiServer());
}
return new Response("Not found", {status: 404});
}
}
客户端的批处理请求则展示了Promise管道化的强大之处:
import { newHttpBatchRpcSession, RpcPromise } from "capnweb";
let api = newHttpBatchRpcSession<PublicApi>("https://example.com/api");
const apiToken = "my-secret-token";
// 调用 authenticate(),但不要等待它。我们可以使用返回的 Promise
// 来进行“管道化”调用,而无需等待。
let authedApi: RpcPromise<AuthedApi> = api.authenticate(apiToken);
// 进行管道化调用以获取用户ID。同样,不要等待它。
let userIdPromise: RpcPromise<number> = authedApi.getUserId();
// 进行另一个管道化调用以获取用户的公共资料,它依赖于用户ID。
// 注意,我们可以在任何需要 T 类型的地方使用 RpcPromise<T>。
// 在发送调用之前,Promise 会被其解析值替换。
let profilePromise = api.getUserProfile(userIdPromise);
// 进行另一个调用以获取用户的朋友。
let friendsPromise = authedApi.getFriendIds();
// `friendsPromise` 只返回一个用户ID数组,但我们还想要所有资料信息。
// 我们可以使用神奇的 .map() 函数来获取它们!这仍然是一次网络往返。
let friendProfilesPromise = friendsPromise.map((id: RpcPromise<number>) => {
return { id, profile: api.getUserProfile(id) };
});
// 现在等待所有 Promise。批处理在此时发送。
// 重要的是,要同时等待所有你实际需要结果的 Promise。
// 如果你在批处理发送之前没有实际等待一个 Promise,系统会检测到这一点,
// 并且不会要求服务器发送返回值!
let [profile, friendProfiles] =
await Promise.all([profilePromise, friendProfilesPromise]);
console.log(`你好,${profile.name}!`);
console.log("好友资料:", friendProfiles);
// 注意,此时 `api` 和 `authedApi` 存根不再工作,因为批处理已完成。
// 你必须开始一个新的批处理会话。
通过这种方式,客户端在一次网络往返中完成了认证、获取用户ID、获取用户资料、获取好友ID以及获取好友资料等一系列操作,大大提升了效率。
神奇的 map()
方法
RpcPromise
还提供了一个特殊的 .map()
方法,用于在不将值拉回本地的情况下,远程转换值。例如:
// 获取用户ID列表。
let idsPromise = api.listUserIds();
// 查找每个ID的用户名。
let names = await idsPromise.map(id => [id, api.getUserName(id)]);
这段代码在一个网络往返中,先获取用户ID列表,然后对列表中的每个ID进行RPC调用以查找用户名,最后返回ID/名称对的列表。
这一切都发生在一次网络往返中!
promise.map(func)
的工作原理是将 func
的表示形式传输到服务器,并在Promise的结果上执行。如果Promise解析为数组,mapper函数会在每个元素上执行;如果解析为 null
或 undefined
,函数不执行;否则,执行一次。
它是如何做到的? Cap’n Web并不会在网络上传输任意代码。它的奥秘在于“记录-回放”机制:在调用端,Cap’n Web会在特殊的“记录”模式下调用你的回调函数一次,并传入一个特殊的占位符存根,该存根会记录你对其所做的操作。回调函数中进行的任何RPC调用(在任何存根上)都不会实际执行,而是被记录为回调函数执行的操作。一旦回调函数返回,记录和捕获列表就会发送给对等方,在那里可以根据需要回放该记录来处理各个结果。
RPC 核心概念深入解析
值传递类型
Cap’n Web支持多种按值传递的类型,它们在RPC传输时会被序列化,在接收端生成一份副本:
- 基本值:字符串、数字、布尔值、null、undefined
- 普通对象(如字面量创建的对象)、数组
bigint
、Date
、Uint8Array
Error
及其内置子类
不支持:Map
、Set
、ArrayBuffer
、ReadableStream
、WritableStream
、Headers
、Request
、Response
。
有意不被支持:未扩展 RpcTarget
的自定义类、循环引用值(消息严格按树形结构序列化)。
RpcTarget
:按引用传递的魔法
要通过RPC暴露一个接口,你的类必须扩展 RpcTarget
。这个标记告诉RPC系统:该类的实例是按引用传递的。当实例通过RPC传递时,对象不会被序列化,而是RPC消息中会包含一个指向原始目标对象的“存根”。调用这个存根,就会通过RPC回调到原始对象所在的位置。
当你将 RpcTarget
引用发送给对方时,他们可以通过RPC调用该类的任何方法(包括getter)。但他们无法访问对象的“自有属性”(instance properties),只能访问原型链上的属性。这旨在与典型的JavaScript代码行为保持一致,通常私有成员作为实例属性存储。
警告: TypeScript的 private
关键字在运行时会被擦除,无法强制私有。要真正私有化方法,请使用JavaScript的 #
前缀(#methodName
),这些方法永远不会通过RPC暴露。
函数:特殊的 RpcTarget
当一个普通函数通过RPC传递时,它会被视为类似于 RpcTarget
。函数会被替换为一个存根,调用该存根时,会通过RPC回调到原始函数对象。与 RpcTarget
不同,函数的自有属性可以通过RPC访问(如果函数有的话)。
RpcStub<T>
:远程对象的本地代理
当一个扩展 RpcTarget
的类型 T
(或一个函数)作为RPC消息的一部分发送时,它会被替换为一个 RpcStub<T>
类型的存根。
存根使用JavaScript的 Proxy
实现,看起来拥有所有可能的方法和属性名称。在运行时,存根并不知道服务器端实际存在哪些属性;只有当你等待结果时,如果属性不存在才会报错。然而,TypeScript会在编译时检查 T
的属性,因此你可以获得完整的编译时类型检查和自动补全。
要读取远程对象的属性(而非调用方法),只需 await
该属性,例如 let foo = await stub.foo;
。存根可以再次跨RPC传递,甚至跨独立的连接。
RpcPromise<T>
:Promise 管道化的核心
调用RPC方法返回的是 RpcPromise
而非普通 Promise
。RpcPromise
具有普通 Promise
的所有功能,如 await
、.then()
等。
但 RpcPromise
更强大:
- 它也充当Promise最终结果的存根。你可以在不等待Promise解析的情况下,直接访问其属性或调用其方法。
// 在一次往返中,验证用户并获取其通知。 let user = api.authenticate(cookie); let notifications = await user.getNotifications();
RpcPromise
(或其属性)可以作为参数传递给其他RPC调用。// 在一次往返中,验证用户,并根据其ID获取其公共资料。 let user = api.authenticate(cookie); let profile = await api.getUserProfile(user.id);
当 RpcPromise
作为RPC参数传递或作为结果返回时,它在传递给接收应用程序之前会被其解析值替换。因此,你可以在任何需要 T
类型的地方使用 RpcPromise<T>
!
资源管理与销毁
在涉及远程资源时,垃圾回收机制往往力不从心。这是因为JavaScript运行时通常只在内存压力大时才运行垃圾回收器,但它无法感知RPC连接另一端的内存压力。此外,垃圾回收器无法追踪跨越RPC连接的远程对象图和循环引用。
Cap’n Web没有尝试解决这些复杂的垃圾回收问题,而是提供了两种策略:
- 显式销毁存根:当你不再需要存根时,显式地销毁它。这会通知远程端释放相关资源。
- 使用短生命周期会话:会话结束时,所有存根都会被隐式销毁。例如,HTTP批处理请求通常无需销毁存根。但对于长寿命的WebSocket会话,显式销毁可能很重要。
如何销毁
存根集成了JavaScript的“显式资源管理”特性。
- 可销毁对象(包括存根)都有一个
[Symbol.dispose]
方法。你可以像stub[Symbol.dispose]()
这样调用它。 - 你可以使用
using
变量将存根绑定到函数作用域,例如using stub = api.getStub();
。当变量超出作用域时,销毁器会自动调用。
自动销毁规则
为了简化资源管理,Cap’n Web实现了一些自动销毁规则,其基本原则是:调用方负责销毁所有存根。
- 作为参数传递的存根仍归调用方所有,并由调用方销毁。
- 作为结果返回的存根,其所有权从被调用方转移到调用方,并由调用方销毁。
RPC系统会在调用完成后隐式销毁被调用方持有的、作为参数传入的存根副本,以及在确定不再有管道化调用后,隐式销毁作为结果返回的存根副本。
安全考量
在设计和部署基于Cap’n Web的系统时,有几个重要的安全方面需要注意:
- WebSocket的跨站连接:浏览器中的WebSocket API允许跨站连接且无法设置HTTP头(如Cookie进行身份验证)。因此,我们强烈建议采用示例中展示的模式:通过RPC方法进行带内认证,然后返回一个已认证的API存根。
- Promise管道化与资源消耗:Cap’n Web的管道化功能虽然强大,但也可能让恶意客户端在服务器上排队大量工作。为了缓解这种风险,建议对昂贵的操作实施速率限制。对于Cloudflare Workers,可以配置更低的CPU限制。
- 运行时类型检查:Cap’n Web目前不提供运行时类型检查。当使用TypeScript时,请记住类型仅在编译时检查。恶意客户端可能会发送非预期类型的数据,导致应用行为异常或引入安全漏洞(例如,查询注入)。因此,可以考虑使用像Zod这样的运行时类型检查框架来验证输入。
建立会话
Cap’n Web提供了多种方式来建立客户端与服务器之间的会话。
HTTP 批处理客户端
在HTTP批处理模式下,一系列RPC调用可以在单个HTTP请求中完成,服务器则返回一批结果。Cap’n Web的“魔法”在于:批处理中一个调用的结果可以作为同一批次中后续调用的参数,即使整个批次是一次性发送的。这就是Promise 管道化。
import { RpcTarget, RpcStub, newHttpBatchRpcSession } from "capnweb";
// 声明RPC接口。
interface MyApi extends RpcTarget {
getUserInfo(): UserInfo;
greet(name: string): string;
};
// 启动一个使用此接口的批处理请求。
using stub: RpcStub<MyApi> = newHttpBatchRpcSession<MyApi>("https://example.com/api");
// 批处理将在下一个I/O tick发送。在此之前,你可以添加更多调用。
// 我们可以进行任意数量的批处理调用,只要我们不等待Promise。
let promise1 = stub.greet("Alice");
let promise2 = stub.greet("Bob");
// 一个调用返回的Promise可以作为另一个调用的输入。
// 第一个调用的结果会在服务器端替换到第二个调用的参数中。
let userInfoPromise = stub.getUserInfo();
let promise3 = stub.greet(userInfoPromise.name);
// 使用 Promise.all() 同时等待所有Promise。
// 注意:你必须显式地等待(或调用.then())所有Promise,
// 系统才会要求服务器返回这些Promise的结果。
let [greeting1, greeting2, greeting3] = await Promise.all([promise1, promise2, promise3]);
console.log(greeting1, greeting2, greeting3);
WebSocket 客户端
在WebSocket模式下,客户端与服务器建立一个长寿命连接,可以在长时间内进行多次调用。在这种模式下,服务器甚至可以异步地调用客户端。
import { RpcTarget, RpcStub, newWebSocketRpcSession } from "capnweb";
interface MyApi extends RpcTarget {
getUserInfo(): UserInfo;
greet(name: string): string;
};
// 启动一个WebSocket会话。
// (注意:销毁根存根会关闭连接。使用 `using` 声明可以确保在存根超出作用域时连接关闭。)
using stub: RpcStub<MyApi> = newWebSocketRpcSession<MyApi>("wss://example.com/api");
// 在WebSocket模式下,我们可以随时自由地进行调用。
console.log(await stub.greet("Alice"));
console.log(await stub.greet("Bob"));
// 但我们仍然可以使用Promise管道化来减少往返次数。
// 对于我们不打算等待的Promise,应该使用 `using`,
// 以便系统知道我们何时不再需要它们。
{
using userInfoPromise = stub.getUserInfo();
console.log(await stub.greet(userInfoPromise.name));
}
服务器端实现
无论是Cloudflare Workers、Node.js还是其他JavaScript运行时,Cap’n Web都提供了相应的工具来快速设置RPC服务器。例如,newWorkersRpcResponse()
函数让Cloudflare Workers能够同时支持HTTP批处理和WebSocket API。对于Node.js,则需要结合 node:http
和 ws
库来处理HTTP和WebSocket连接。
// Cloudflare Workers HTTP服务器示例(已在前面展示)
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
// ... (MyApiImpl 类定义) ...
export default {
fetch(request, env, ctx) {
// ...
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new MyApiImpl(userInfo));
}
// ...
}
}
MessagePort
Cap’n Web还可以通过MessagePort进行通信,这在浏览器环境中非常有用,可以与Web Workers、iframes等进行通信。
import { RpcTarget, RpcStub, newMessagePortRpcSession } from "capnweb";
class Greeter extends RpcTarget {
greet(name: string): string {
return `Hello, ${name}!`;
}
};
let channel = new MessageChannel();
newMessagePortRpcSession(channel.port1, new Greeter());
using stub: RpcStub<Greeter> = newMessagePortRpcSession<Greeter>(channel.port2);
console.log(await stub.greet("Alice"));
结语
Cap’n Web是一个为现代Web栈量身定制的JavaScript原生RPC系统。它凭借着能力对象协议的强大、零样板代码的简洁、Promise管道化的高效,以及对TypeScript和多种运行时环境的广泛支持,为开发者提供了一种全新的、更优雅的方式来构建分布式应用。无论是需要高性能的单次往返调用,还是需要长寿命双向通信的实时应用,Cap’n Web都提供了一套强大而灵活的解决方案,值得每一位Web开发者深入探索和实践。