跳到內容

Workers RPC 與 Service Bindings:把單體拆開

查證日期
驗證環境wrangler@4.114.0·workerd@1.20260722.1·compatibility_date: 2026-07-24
對應範例examples/ch18-rpc

當你的 Worker 長到一定程度,會想把它拆開 —— auth 一個、API 一個、背景工作一個。傳統上這代表付出網路代價:序列化成 HTTP、跨網路、反序列化。

Service bindings + RPC 讓你幾乎不用付這個代價:直接呼叫另一個 Worker 上的方法,型別安全,不經過 HTTP。

四個必須先知道的事:

  1. 每次呼叫都是新的實例。 實測三次連續呼叫拿到三個不同的 instance id —— 不能在 entrypoint 上放跨請求的狀態。
  2. RpcTarget 上的實例屬性讀不到。 只有方法getter 能跨界。實測 await session.userId 直接 throw。
  3. 型別不會自動跨 Worker 流動。 wrangler types 知道 entrypoint 叫什麼,但只把它寫成註解。
  4. Promise pipelining 省的是往返次數,不是執行時間 —— 所以本機量不太出來。

被呼叫方auth)匯出一個 WorkerEntrypoint

import { WorkerEntrypoint } from "cloudflare:workers";
export class AuthService extends WorkerEntrypoint<CloudflareBindings> {
async verify(token: string): Promise<boolean> {
return token === "good";
}
}
export default {
async fetch(): Promise<Response> {
return new Response("bound via service binding, not routed directly\n");
},
} satisfies ExportedHandler<CloudflareBindings>;

呼叫方api)在設定裡宣告:

{
"services": [
{
"binding": "AUTH",
"service": "ch18-auth",
"entrypoint": "AuthService",
"props": { "callerName": "ch18-api", "tier": "internal" }
}
]
}

然後直接呼叫:

const ok = await env.AUTH.verify("good");

沒有 URL、沒有 HTTP、沒有序列化成 JSON。

本機同時跑兩個 Worker:

Terminal window
wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsonc

第一個 config 是主要的(接收外部請求),其餘是被綁定的服務。

這是第一個會卡住的地方。wrangler types 產生的是:

AUTH: Service /* entrypoint AuthService from ch18-auth */;

它知道 entrypoint 叫 AuthService,但只把這件事寫成註解。 Service 沒有型別參數,所以:

error TS2339: Property 'verify' does not exist on type
'{ fetch(...): Promise<Response>; connect(...): Socket; }'.

解法是自己用 Service<T> 接線:

api/src/env.ts
import type { AuthService } from "../../auth/src/index";
export interface AppEnv extends Omit<CloudflareBindings, "AUTH"> {
AUTH: Service<AuthService>;
}

然後全程用 AppEnv 取代 CloudflareBindings

這是 type-only import,不會產生 runtime 依賴 —— 兩個 Worker 仍然是獨立部署的。在 monorepo 裡建議把共用型別放進 packages/shared,避免跨 app 的相對路徑。

Terminal window
$ curl -s localhost:8787/instances
{
"ids": ["o6f3jt", "avyqrv", "csxxbw"],
"allDifferent": true,
"callCounts": [1, 1, 1]
}

三次呼叫、三個不同的 instance id、每個的內部計數器都是 1。

官方文件對此說得很清楚,但實務上這是第一大誤解

// ❌ This cache is empty on every single call.
export class AuthService extends WorkerEntrypoint {
private cache = new Map<string, User>();
async getUser(id: string) {
if (this.cache.has(id)) return this.cache.get(id); // never hits
// ...
}
}

需要跨呼叫的狀態就用 KV、DO 或 D1。WorkerEntrypoint 的實例欄位只在單次呼叫內有意義。

(對照第 14 篇:Durable Object 的實例跨請求存活,直到被回收。兩者是相反的。)

實測三種情況:

Terminal window
$ curl -s localhost:8787/rpctarget
{
"method_can": { "ok": true },
"method_profile": { "ok": { "userId": "u1", "tenantId": "t1", "label": "u1@t1" } },
"getter_label": { "ok": "u1@t1" },
"plainProp": { "threw": "TypeError: The RPC receiver does not implement the method \"plainProp\"." },
"paramProp_userId": { "threw": "TypeError: The RPC receiver does not implement the method \"userId\"." }
}

規則很清楚:方法可以、getter 可以、實例屬性不行。

export class Session extends RpcTarget {
public plainProp: string; // ❌ unreachable
constructor(
public readonly userId: string, // ❌ also a plain property
public readonly tenantId: string,
) {
super();
this.plainProp = `plain:${userId}`;
}
get label(): string { // ✅ getter on the prototype
return `${this.userId}@${this.tenantId}`;
}
can(action: string): boolean { // ✅ method
return action !== "delete-tenant";
}
}

要暴露值就用 getter 或方法,不要用實例屬性。錯誤訊息(does not implement the method "userId")不會告訴你原因,所以這一條值得記起來。

RpcTarget 的 class 完全過不去:

Terminal window
$ curl -s localhost:8787/plainclass
{"result":{"threw":"DataCloneError: Could not serialize object of type \"PlainToken\". This type does not support serialization."}}

完整的可跨越清單(官方):

可以說明
structured-cloneable 值物件、陣列、MapSetDateTypedArray……
function變成 callback stub,被呼叫方可以回呼你
RpcTarget 子類變成 stub,方法與 getter 可遠端呼叫
ReadableStream / WritableStream自動流量控制
Request / Response
其他 RPC stub即使是從第三個 Worker 拿到的

上限是 32 MiB

// caller
const result = await auth.withRetry(3, async (attempt: number) => {
if (attempt < 2) throw new Error(`fail ${attempt}`);
return `succeeded on attempt ${attempt}`;
});
Terminal window
$ curl -s localhost:8787/callback
{"result":"succeeded on attempt 2","attemptsSeen":3}

被呼叫方回呼了呼叫方三次。 這讓「把控制流程留在呼叫端、把機制放在服務端」這種模式變得可行。

⚠️ 但 stub 的生命週期有限:「this proxying only lasts until the end of the Workers’ execution contexts. A proxy connection cannot be persisted for later use.」不能把 stub 存起來之後再用。

RPC 最有價值、也最少人用的功能:省略中間的 await,多趟往返會被壓成一趟。

// Two round trips: one for openSession, one for profile.
const session = await auth.openSession("good");
const profile = await session.profile();
// One round trip: the .profile() call is sent along with the first.
const profile = await auth.openSession("good").profile();

實測(本機,每輪 N 次):

Terminal window
n=200 -> {"twoHopsMs":1358,"pipelinedMs":980,"savedMs":378,"savedPercent":28}
n=500 -> {"twoHopsMs":2136,"pipelinedMs":1958,"savedMs":178,"savedPercent":8}
n=1000 -> {"twoHopsMs":7645,"pipelinedMs":4418,"savedMs":3227,"savedPercent":42}

Pipelined 版本每次都比較快,但比例波動很大(8%–42%)。

⚠️ 這個數字在本機沒有意義,要誠實說清楚。 本機兩個 Worker 跑在同一個 workerd 行程裡,「往返」是行程內呼叫。我另外測了一個被呼叫方故意慢 300ms 的版本:

Terminal window
$ curl -s "localhost:8787/pipelining?ms=300"
{"delayMs":300,"sequentialMs":302,"pipelinedMs":302,"savedMs":0}

完全沒有差別 —— 因為 pipelining 省的是往返次數,不是被呼叫方的執行時間。那 300ms 兩種寫法都要付。

Production 上才有意義:如果兩個 Worker 被 Smart Placement 放在不同位置,每一趟往返就是真實的網路延遲。鏈越深,省得越多:

// 3 round trips
const s = await auth.openSession(t);
const org = await s.organisation();
const plan = await org.plan();
// 1 round trip
const plan = await auth.openSession(t).organisation().plan();

實務建議:能連鏈就連鏈。就算本機看不出差異,production 上是白拿的。

ctx.props:由呼叫方設定、被呼叫方可信任

Section titled “ctx.props:由呼叫方設定、被呼叫方可信任”
{ "services": [{ "binding": "AUTH", "service": "ch18-auth", "entrypoint": "AuthService",
"props": { "callerName": "ch18-api", "tier": "internal" } }] }
Terminal window
$ curl -s localhost:8787/props
{"props":{"callerName":"ch18-api","tier":"internal"}}

被呼叫方讀 this.ctx.props。關鍵在於這個值是平台注入的,不是從請求 body 來的 —— 官方的說法是只有被授權的部署才能設定它,所以不需要額外簽章就可以信任。

這是多租戶平台裡很有用的模式:每個呼叫方的 binding 帶著自己的身分,被呼叫方據此決定權限,而呼叫方沒辦法偽造。

Terminal window
$ curl -s localhost:8787/reserved
{
"dup": { "threw": "TypeError: 'dup' is a reserved method and cannot be called over RPC." },
"constructorCall": { "threw": "TypeError: Illegal constructor" },
"nonExistent": { "threw": "TypeError: The RPC receiver does not implement the method \"noSuchMethod\"." }
}
名稱狀態
fetch保留 —— 必須是 RequestResponse
connect保留(目前不支援)
dup禁用(保留給複製 stub)
constructor禁用
alarmwebSocketMessagewebSocketClosewebSocketErrorWorkerEntrypoint / DurableObject 上不能透過 RPC 呼叫(在 RpcTarget 上可以)

fetch() 仍然可用:

Terminal window
$ curl -s localhost:8787/viafetch
{"status":200,"body":{"via":"fetch","url":"https://auth.internal/check"}}

什麼時候用 fetch() 而不是 RPC? 只有兩種:你真的在轉發一個 HTTP 請求(含 header、streaming body),或需要 WebSocket upgrade。其餘一律 RPC。

順帶一提,fetch 式的 service binding 沒有被棄用 —— RPC 只是建議的預設。

回傳 stub 的方法會佔用資源直到執行上下文結束。要提早釋放用 using

{
using counter = await env.SVC.newCounter();
await counter.increment();
} // disposed here

Wrangler v4 原生支援這個語法,不需要 compat date。

⚠️ Symbol.asyncDispose 不支援 —— 只有同步的 Symbol.dispose

dup() 可以複製一個 stub 讓它活得比原本久,但如上所述不能透過 RPC 呼叫 dup(它是 stub 本身的方法,不是遠端方法)。

Service binding 讓拆分變便宜,但不是免費 —— 每次跨界仍有序列化成本,而且部署變成多個單位。

值得拆的訊號

訊號為什麼
不同的 binding 需求第 4 篇:redirector 不該拿得到 D1。拆開就是最小權限
不同的部署節奏auth 邏輯半年不動,API 每天改
不同的資源限制某個 Worker 需要更高的 CPU 上限或 nodejs_compat
bundle 太大第 5 篇:zod 佔 98 KiB。拆開讓熱路徑的 bundle 保持小

不值得拆的訊號:只是「感覺該分層」、或想要「微服務架構」。同一個 Worker 裡的模組拆分是免費的,跨 Worker 不是。


完整程式碼:examples/ch18-rpc/(兩個 Worker:apiauth

Terminal window
cd examples/ch18-rpc && npm install
npx wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsonc
Terminal window
B=localhost:8787
curl -s "$B/basic" # 基本 RPC
curl -s "$B/instances" # 每次呼叫都是新實例
curl -s "$B/rpctarget" # 方法 / getter / 屬性
curl -s "$B/plainclass" # DataCloneError
curl -s "$B/callback" # callback stub
curl -s "$B/props" # ctx.props
curl -s "$B/roundtrips?n=500" # pipelining
curl -s "$B/pipelining?ms=300" # 為什麼慢的方法看不出差異
curl -s "$B/reserved" # 保留名稱
curl -s "$B/viafetch" # fetch 呼叫慣例

練習:把 api/src/env.ts 的型別接線拿掉,看 TypeScript 報什麼錯 —— 那就是「型別不會自動跨 Worker」的樣子。


第 4 篇規劃了五個 Worker 的 binding 切分。這一篇把它們接起來。

apps/redirector ──RPC──► apps/api (TenantService)
binding: LINKS(KV), CLICKS(Queue), ABUSE(ratelimit)
✗ 沒有 D1
apps/dashboard ──RPC──► apps/api (AdminService)
apps/consumers ──RPC──► apps/api (IngestService)

apps/api 匯出三個 entrypoint,各自對應一種呼叫方。

apps/api/src/index.ts
export { TenantService } from "./entrypoints/tenant";
export { AdminService } from "./entrypoints/admin";
export { IngestService } from "./entrypoints/ingest";
export default app; // the Hono app from chapter 05

① 為什麼 redirector 不直接連 D1

第 4 篇說過:它的 env 裡沒有 D1,所以就算被攻破也碰不到使用者資料表。但它偶爾需要驗證租戶狀態 —— 走 TenantService.checkStatus(tenantId),而那個 entrypoint 只暴露一個唯讀方法。

capability 的邊界從「binding 層級」細化到「方法層級」。

props 帶呼叫方身分

apps/redirector/wrangler.jsonc
{ "services": [{ "binding": "TENANT", "service": "linkforge-api",
"entrypoint": "TenantService",
"props": { "caller": "redirector", "scope": "read-only" } }] }
export class TenantService extends WorkerEntrypoint<Env> {
private get scope(): string {
return (this.ctx.props as { scope?: string } | undefined)?.scope ?? "none";
}
async checkStatus(tenantId: string) {
// The caller cannot forge this.
if (this.scope !== "read-only" && this.scope !== "full") throw new Error("forbidden");
return findTenantStatus(this.env.DB, tenantId);
}
}

③ 共用型別放 packages/shared

packages/shared/src/services.ts
export type { TenantService, AdminService, IngestService } from "@linkforge/api/entrypoints";
// apps/redirector/src/env.ts
import type { TenantService } from "@linkforge/shared";
export interface AppEnv extends Omit<CloudflareBindings, "TENANT"> {
TENANT: Service<TenantService>;
}

避免跨 app 的相對路徑(../../api/src/...),而且第 26 篇的 monorepo 設定會讓這件事自然成立。

  • Queue consumer 和 API 共用同一個 Worker? 不 —— consumer 需要 15 分鐘 wall time 和不同的錯誤處理,拆開比較乾淨(第 19 篇)。
  • 每個 API 路由一個 Worker? 絕對不。那是把免費的模組邊界換成付費的網路邊界。

本篇交付物apps/api 的三個 entrypoint、各呼叫方的 props 身分設定、packages/shared 的型別匯出、以及本機多 Worker 開發的 pnpm dev 腳本。


五篇下來,LinkForge 的協調層定案:

redirect ──► LinkCounter DO (per-link) ← 14、15 篇:actor + SQLite
│ alarm 30s ← 17 篇:去抖動 + at-least-once
├──► D1 rollup
└──► LiveDashboard DO ← 16 篇:hibernation + tag 廣播
└──► WebSocket 客戶端
apps/* ◄──RPC──► apps/api entrypoints ← 18 篇:capability 細化到方法層級

四個貫穿 Part 3 的原則:

  1. DO 解決協調,不是儲存。(第 14 篇)
  2. 「單執行緒」不等於「不會交錯」。(第 14 篇,實測 lost update)
  3. 記憶體不可靠,storage 才可靠。(第 15、16 篇)
  4. at-least-once 代表你的 handler 必須 idempotent。(第 17 篇)

① 在 WorkerEntrypoint 上放跨請求狀態

每次呼叫都是新實例。實測三個不同 id。

② 期待 RpcTarget 的實例屬性可讀

只有方法和 getter。錯誤訊息不會說明原因。

③ 回傳非 RpcTarget 的 class

DataCloneError

④ 以為型別會自動流動

wrangler types 把 entrypoint 名稱寫成註解而已。要自己用 Service<T>

⑤ 每一步都 await

放棄了 pipelining。能連鏈就連鏈。

⑥ 把 stub 存起來之後再用

proxy 只在當次執行上下文內有效。

⑦ 用 Symbol.asyncDispose

不支援。只有同步的 using

⑧ 用保留名稱

dupconstructor 禁用;fetch 必須是 Request → Response。

⑨ 為了「微服務」而拆

模組邊界免費,Worker 邊界不是。


  1. **每次 RPC 呼叫都是新實例。**要跨呼叫的狀態就用 KV / DO / D1。
  2. **RpcTarget 只暴露方法和 getter。**實例屬性讀不到,而錯誤訊息不會告訴你為什麼。
  3. **能連鏈就連鏈。**Pipelining 省的是往返次數,本機看不出來,production 是白拿的。


下一篇19. Queues:把工作推離熱路徑 —— Part 4 開始。Queues 的計費單位是「每 64KB 的讀、寫或刪」,所以一則訊息大約算三次操作,而每次重試再加一次。