Workers RPC 與 Service Bindings:把單體拆開
這篇要解決的問題
Section titled “這篇要解決的問題”當你的 Worker 長到一定程度,會想把它拆開 —— auth 一個、API 一個、背景工作一個。傳統上這代表付出網路代價:序列化成 HTTP、跨網路、反序列化。
Service bindings + RPC 讓你幾乎不用付這個代價:直接呼叫另一個 Worker 上的方法,型別安全,不經過 HTTP。
四個必須先知道的事:
- 每次呼叫都是新的實例。 實測三次連續呼叫拿到三個不同的 instance id —— 不能在 entrypoint 上放跨請求的狀態。
RpcTarget上的實例屬性讀不到。 只有方法和 getter 能跨界。實測await session.userId直接 throw。- 型別不會自動跨 Worker 流動。
wrangler types知道 entrypoint 叫什麼,但只把它寫成註解。 - Promise pipelining 省的是往返次數,不是執行時間 —— 所以本機量不太出來。
設定與最小範例
Section titled “設定與最小範例”被呼叫方(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:
wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsonc第一個 config 是主要的(接收外部請求),其餘是被綁定的服務。
🔴 型別不會自動跨 Worker 流動
Section titled “🔴 型別不會自動跨 Worker 流動”這是第一個會卡住的地方。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> 接線:
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 的相對路徑。
🔴 每次呼叫都是新實例
Section titled “🔴 每次呼叫都是新實例”$ 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 的實例會跨請求存活,直到被回收。兩者是相反的。)
什麼能跨越邊界
Section titled “什麼能跨越邊界”實測三種情況:
$ 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 完全過不去:
$ curl -s localhost:8787/plainclass{"result":{"threw":"DataCloneError: Could not serialize object of type \"PlainToken\". This type does not support serialization."}}完整的可跨越清單(官方):
| 可以 | 說明 |
|---|---|
| structured-cloneable 值 | 物件、陣列、Map、Set、Date、TypedArray…… |
| function | 變成 callback stub,被呼叫方可以回呼你 |
RpcTarget 子類 | 變成 stub,方法與 getter 可遠端呼叫 |
ReadableStream / WritableStream | 自動流量控制 |
Request / Response | |
| 其他 RPC stub | 即使是從第三個 Worker 拿到的 |
上限是 32 MiB。
Callback stub 實測
Section titled “Callback stub 實測”// callerconst result = await auth.withRetry(3, async (attempt: number) => { if (attempt < 2) throw new Error(`fail ${attempt}`); return `succeeded on attempt ${attempt}`;});$ 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 存起來之後再用。
🎯 Promise pipelining
Section titled “🎯 Promise pipelining”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 次):
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 的版本:
$ curl -s "localhost:8787/pipelining?ms=300"{"delayMs":300,"sequentialMs":302,"pipelinedMs":302,"savedMs":0}完全沒有差別 —— 因為 pipelining 省的是往返次數,不是被呼叫方的執行時間。那 300ms 兩種寫法都要付。
Production 上才有意義:如果兩個 Worker 被 Smart Placement 放在不同位置,每一趟往返就是真實的網路延遲。鏈越深,省得越多:
// 3 round tripsconst s = await auth.openSession(t);const org = await s.organisation();const plan = await org.plan();
// 1 round tripconst 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" } }] }$ curl -s localhost:8787/props{"props":{"callerName":"ch18-api","tier":"internal"}}被呼叫方讀 this.ctx.props。關鍵在於這個值是平台注入的,不是從請求 body 來的 —— 官方的說法是只有被授權的部署才能設定它,所以不需要額外簽章就可以信任。
這是多租戶平台裡很有用的模式:每個呼叫方的 binding 帶著自己的身分,被呼叫方據此決定權限,而呼叫方沒辦法偽造。
$ 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 | 保留 —— 必須是 Request → Response |
connect | 保留(目前不支援) |
dup | 禁用(保留給複製 stub) |
constructor | 禁用 |
alarm、webSocketMessage、webSocketClose、webSocketError | 在 WorkerEntrypoint / DurableObject 上不能透過 RPC 呼叫(在 RpcTarget 上可以) |
fetch() 仍然可用:
$ 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 hereWrangler v4 原生支援這個語法,不需要 compat date。
⚠️ Symbol.asyncDispose 不支援 —— 只有同步的 Symbol.dispose。
dup() 可以複製一個 stub 讓它活得比原本久,但如上所述不能透過 RPC 呼叫 dup(它是 stub 本身的方法,不是遠端方法)。
什麼時候該拆
Section titled “什麼時候該拆”Service binding 讓拆分變便宜,但不是免費 —— 每次跨界仍有序列化成本,而且部署變成多個單位。
值得拆的訊號:
| 訊號 | 為什麼 |
|---|---|
| 不同的 binding 需求 | 第 4 篇:redirector 不該拿得到 D1。拆開就是最小權限 |
| 不同的部署節奏 | auth 邏輯半年不動,API 每天改 |
| 不同的資源限制 | 某個 Worker 需要更高的 CPU 上限或 nodejs_compat |
| bundle 太大 | 第 5 篇:zod 佔 98 KiB。拆開讓熱路徑的 bundle 保持小 |
不值得拆的訊號:只是「感覺該分層」、或想要「微服務架構」。同一個 Worker 裡的模組拆分是免費的,跨 Worker 不是。
完整程式碼:
examples/ch18-rpc/(兩個 Worker:api和auth)
cd examples/ch18-rpc && npm installnpx wrangler dev -c api/wrangler.jsonc -c auth/wrangler.jsoncB=localhost:8787curl -s "$B/basic" # 基本 RPCcurl -s "$B/instances" # 每次呼叫都是新實例curl -s "$B/rpctarget" # 方法 / getter / 屬性curl -s "$B/plainclass" # DataCloneErrorcurl -s "$B/callback" # callback stubcurl -s "$B/props" # ctx.propscurl -s "$B/roundtrips?n=500" # pipeliningcurl -s "$B/pipelining?ms=300" # 為什麼慢的方法看不出差異curl -s "$B/reserved" # 保留名稱curl -s "$B/viafetch" # fetch 呼叫慣例練習:把 api/src/env.ts 的型別接線拿掉,看 TypeScript 報什麼錯 —— 那就是「型別不會自動跨 Worker」的樣子。
接進 LinkForge
Section titled “接進 LinkForge”第 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,各自對應一種呼叫方。
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 帶呼叫方身分
{ "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
export type { TenantService, AdminService, IngestService } from "@linkforge/api/entrypoints";
// apps/redirector/src/env.tsimport 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 腳本。
Part 3 回顧
Section titled “Part 3 回顧”五篇下來,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 的原則:
- DO 解決協調,不是儲存。(第 14 篇)
- 「單執行緒」不等於「不會交錯」。(第 14 篇,實測 lost update)
- 記憶體不可靠,storage 才可靠。(第 15、16 篇)
- 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。
⑧ 用保留名稱
dup、constructor 禁用;fetch 必須是 Request → Response。
⑨ 為了「微服務」而拆
模組邊界免費,Worker 邊界不是。
本篇要記住的三句話
Section titled “本篇要記住的三句話”- **每次 RPC 呼叫都是新實例。**要跨呼叫的狀態就用 KV / DO / D1。
- **
RpcTarget只暴露方法和 getter。**實例屬性讀不到,而錯誤訊息不會告訴你為什麼。 - **能連鏈就連鏈。**Pipelining 省的是往返次數,本機看不出來,production 是白拿的。
下一篇:19. Queues:把工作推離熱路徑 —— Part 4 開始。Queues 的計費單位是「每 64KB 的讀、寫或刪」,所以一則訊息大約算三次操作,而每次重試再加一次。