在当今瞬息万变的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模型。这赋予了它一系列独特且强大的功能:

  1. 双向调用支持:客户端不仅可以调用服务器上的方法,服务器也可以反过来调用客户端上的方法,这为实时交互和复杂工作流提供了无限可能。
  2. 按引用传递函数:当你在RPC中传递一个函数时,接收方会得到一个“存根”(stub)。当他们调用这个存根时,实际上会发起一个RPC请求,回到原始函数被创建的位置执行。这种机制正是实现双向调用的基石:客户端可以将回调函数传递给服务器,服务器便能在未来某个时刻调用它。
  3. 按引用传递对象:类似地,如果一个类扩展了特殊的标记类型 RpcTarget,那么该类的实例将按引用传递。这意味着当通过存根调用其方法时,实际执行的是对象被创建位置的代码。
  4. Promise 管道化(Promise Pipelining):这是一个革命性的特性。当你发起一个RPC调用时,会立即得到一个Promise。你无需等待它解析,而是可以立即在后续的RPC调用中使用这个Promise。这样,一系列相互依赖的调用可以在单次网络往返中完成,极大减少了延迟。
  5. 基于能力的安全模式:通过精确控制哪些对象和功能被暴露给远程调用者,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函数会在每个元素上执行;如果解析为 nullundefined,函数不执行;否则,执行一次。

它是如何做到的? Cap’n Web并不会在网络上传输任意代码。它的奥秘在于“记录-回放”机制:在调用端,Cap’n Web会在特殊的“记录”模式下调用你的回调函数一次,并传入一个特殊的占位符存根,该存根会记录你对其所做的操作。回调函数中进行的任何RPC调用(在任何存根上)都不会实际执行,而是被记录为回调函数执行的操作。一旦回调函数返回,记录和捕获列表就会发送给对等方,在那里可以根据需要回放该记录来处理各个结果。

RPC 核心概念深入解析

值传递类型

Cap’n Web支持多种按值传递的类型,它们在RPC传输时会被序列化,在接收端生成一份副本:

  • 基本值:字符串、数字、布尔值、null、undefined
  • 普通对象(如字面量创建的对象)、数组
  • bigintDateUint8Array
  • Error 及其内置子类

不支持:MapSetArrayBufferReadableStreamWritableStreamHeadersRequestResponse

有意不被支持:未扩展 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 而非普通 PromiseRpcPromise 具有普通 Promise 的所有功能,如 await.then() 等。

RpcPromise 更强大:

  1. 它也充当Promise最终结果的存根。你可以在不等待Promise解析的情况下,直接访问其属性或调用其方法。
    // 在一次往返中,验证用户并获取其通知。
    let user = api.authenticate(cookie);
    let notifications = await user.getNotifications();
    
  2. 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没有尝试解决这些复杂的垃圾回收问题,而是提供了两种策略:

  1. 显式销毁存根:当你不再需要存根时,显式地销毁它。这会通知远程端释放相关资源。
  2. 使用短生命周期会话:会话结束时,所有存根都会被隐式销毁。例如,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:httpws 库来处理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开发者深入探索和实践。