DO RPC 與跨物件呼叫:型別安全取代 fetch 手動路由 | Cloudflare 完整教學
上一篇《DO WebSocket Hibernation》,我們讓 Durable Object 能扛住成千上萬條長連線又不燒錢。這一篇,我們處理系統長大後的下一個問題:當 DO 越來越多,它們之間、以及 Worker 與 DO 之間,該如何「互相呼叫、協調」?答案是 RPC(Remote Procedure Call,遠端程序呼叫)。只要你的類別
extends DurableObject,就能在上面定義 public 方法,呼叫端拿到 stub 後就像呼叫本地函式一樣await stub.method(args),享有完整 TypeScript 型別推斷——徹底取代過去用fetch手動拼 URL、手動解析的繁瑣路由。你會學到 RPC 的型別安全、structured clone序列化規則、RpcTarget、以及用WorkerEntrypoint做 Service Bindings 的 Worker↔Worker RPC。
前言
RPC(Remote Procedure Call,遠端程序呼叫) 是一種讓你「呼叫遠端服務上的方法,就像呼叫本地函式一樣」的機制。在 Durable Objects 的世界裡,它要解決的問題很具體:過去 Worker 想跟一個 DO 溝通,唯一的介面是 fetch()——你得在 Worker 端把想傳的東西手動塞進一個 Request(組 URL、決定 method、把資料 JSON.stringify 進 body),然後在 DO 端的 fetch() handler 裡手動拆開(解析 pathname、比對 method、把 body 反序列化),處理完再手動包成 Response 送回,Worker 端又得再 await response.json() 拆一次。這一整套「手動路由」不但冗長,而且完全沒有型別檢查:URL 打錯字、欄位名拼錯、回傳結構改了,全都要等到執行期才會爆炸。
打個比方。用 fetch 呼叫 DO,像是你和同事之間所有溝通都必須寫成正式公文:每次都要填一張表單(Request),指定收件單位(URL)、公文類別(method)、把訴求寫進內文(body),對方收到後拆信、判讀、辦理,再回一封公文(Response)給你拆閱。就算只是問一個數字,也得走完這整套流程。而 RPC 則像是你和這位同事直接坐在隔壁,伸頭就能問:「幫我把計數器加 5」——你直接喊出方法名跟參數,他回你一個數字,乾淨俐落。更棒的是,因為你們講的是「同一套約定好的方法簽章(型別)」,你連方法名有沒有打錯、參數型別對不對,在你開口前(編譯期)就知道了。
這個從「公文往返」到「隔壁直接問」的轉變,正是 RPC 的價值:把跨物件、跨 Worker 的呼叫,變成型別安全的本地函式呼叫。
這篇聚焦在「DO RPC 與跨物件呼叫」這一個主題。讀完你會掌握:
- RPC vs fetch 手動路由——同一件事兩種寫法對照,看清 RPC 省下的樣板程式碼與換來的型別安全
- 序列化規則——參數與回傳值如何經過
structured clone傳遞、哪些型別可以傳、哪些會拋錯 - RpcTarget 與 Service Bindings——如何回傳「可繼續呼叫」的物件,以及用
WorkerEntrypoint做 Worker↔Worker 的 RPC - 錯誤處理與限制——例外如何跨越邊界傳播、32 MiB 酬載上限、以及「物件之間不共享記憶體」的心智模型
核心概念
RPC vs fetch 手動路由:同一件事,兩種寫法
先看沒有 RPC 的年代,一個「計數器 DO」的加值操作要怎麼寫。你會發現同一件事,程式碼被硬生生拆成「Worker 端組請求」與「DO 端解請求」兩坨互不信任的樣板:
// ❌ fetch 手動路由:冗長、無型別、易錯
// DO 端:要自己解析路徑、method、body
export class Counter extends DurableObject<Env> {
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "POST" && url.pathname === "/increment") {
const { amount } = await req.json<{ amount: number }>();
const next = (await this.ctx.storage.get<number>("v") ?? 0) + amount;
await this.ctx.storage.put("v", next);
return Response.json({ value: next }); // 回傳也要手動包
}
return new Response("Not Found", { status: 404 });
}
}
// Worker 端:要手動組 Request、再手動拆 Response
const stub = env.COUNTER.getByName("global");
const res = await stub.fetch("https://do/increment", {
method: "POST",
body: JSON.stringify({ amount: 5 }), // 欄位名打錯?執行期才知道
});
const { value } = await res.json<{ value: number }>(); // 型別是你手寫的,不保證對
同樣的功能,改用 RPC 後長這樣——DO 上就是一個普通的 public 方法,Worker 端就是普通的函式呼叫:
// ✅ RPC:短、型別安全、像本地呼叫
// DO 端:就是一個公開方法
export class Counter extends DurableObject<Env> {
async increment(amount: number): Promise<number> {
const next = (await this.ctx.storage.get<number>("v") ?? 0) + amount;
await this.ctx.storage.put("v", next);
return next;
}
}
// Worker 端:直接呼叫,TypeScript 完整推斷型別
const stub = env.COUNTER.getByName("global");
const value = await stub.increment(5); // value: number,打錯名字編譯期就報錯
差異一目了然:RPC 版本沒有 URL、沒有 method、沒有手動序列化、沒有 404 分支。你在 DO 上寫一個方法,呼叫端就自動「看得到」這個方法及其型別簽章。
型別安全從何而來:泛型 Namespace
RPC 的型別魔法,來自 DurableObjectNamespace<T> 這個泛型。當你在 Env 裡把 binding 的型別參數指向你的 DO 類別時,TypeScript 就知道這個 namespace 產生的 stub 上有哪些方法:
export interface Env {
// ★ 泛型參數 <Counter> 讓 stub 具備 Counter 的所有 public 方法型別
COUNTER: DurableObjectNamespace<Counter>;
}
const stub = env.COUNTER.getByName("global");
// stub 的型別是 DurableObjectStub<Counter>
// 因此 stub.increment / stub.getCount 都有型別提示與自動完成
於是 stub.increment(5) 回傳 Promise<number>、stub.increment("oops") 編譯不過——這些檢查全發生在編譯期,而不是等請求打過去才炸。
運作原理:呼叫是「跨隔離邊界」的
有一點必須先建立正確心智:RPC 呼叫看起來像本地函式,但它本質上是跨越 isolate 邊界的遠端呼叫。呼叫端(Worker)和被呼叫端(DO)是兩個獨立的執行環境,即使跑在同一台機器上,也不共享記憶體。這帶來三個推論:
- 一律是非同步的:每個 RPC 方法呼叫都回傳
Promise,即使 DO 端的方法看起來同步,呼叫端也要await。 - 參數與回傳值是「複製」傳遞:資料經過序列化(structured clone)跨越邊界,呼叫端拿到的是副本,不是同一個記憶體參考。改動回傳物件不會影響 DO 內部狀態。
- DO 內部仍是單執行緒:進到 DO 的每個 RPC 呼叫,依然受 DO 的 Input Gate 保護,一次處理一個,天然序列化,消除競態。
關鍵術語速查
| 術語 | 一句話定義 |
|---|---|
extends DurableObject | 讓類別的 public 方法自動成為可被 RPC 呼叫的端點 |
DurableObjectNamespace<T> | 泛型 binding 型別,讓 stub 帶有 DO 類別 T 的方法型別 |
stub.method(args) | 對 DO 的 RPC 呼叫,永遠回傳 Promise |
| structured clone | RPC 參數/回傳值的序列化機制,決定哪些型別可傳 |
RpcTarget | 繼承它的類別實例可作為「可繼續呼叫的 stub」被回傳 |
WorkerEntrypoint | 讓一個 Worker 對外暴露 public 方法,供 Service Bindings RPC 呼叫 |
| Service Bindings | Worker↔Worker 的零延遲綁定,搭配 WorkerEntrypoint 即得 RPC |
實作範例
我們用一個貫穿全文的例子:一個多房間聊天系統。有一個 ChatRoom DO(每個房間一個實例)負責存訊息;有一個 Presence DO 負責記錄「哪些使用者在線上」。我們會示範:Worker 用 RPC 呼叫 DO、DO 之間互相 RPC 呼叫(跨物件)、以及用 WorkerEntrypoint 做 Worker↔Worker 的 Service Bindings RPC。前提是 compatibility_date >= 2024-04-03 且類別 extends DurableObject。
1. wrangler 設定
RPC 是預設能力,不需特別開關,只要 compatibility_date 夠新、DO 綁定正確即可:
{
"name": "chat-app",
"main": "src/index.ts",
"compatibility_date": "2024-04-03",
"durable_objects": {
"bindings": [
{ "name": "CHAT_ROOM", "class_name": "ChatRoom" },
{ "name": "PRESENCE", "class_name": "Presence" }
]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["ChatRoom", "Presence"] }
]
}
2. DO 定義 public 方法(RPC 端點)
在 DO 類別上,任何 async 的 public 方法都自動成為 RPC 端點。這裡 ChatRoom 暴露 postMessage 與 getRecent 兩個方法——注意它們就是普通方法,沒有任何特殊裝飾:
// src/index.ts
import { DurableObject } from "cloudflare:workers";
export interface Env {
CHAT_ROOM: DurableObjectNamespace<ChatRoom>;
PRESENCE: DurableObjectNamespace<Presence>;
}
export class ChatRoom extends DurableObject<Env> {
private sql = this.ctx.storage.sql;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
who TEXT NOT NULL,
text TEXT NOT NULL
);
`);
});
}
// ★ RPC 方法:回傳純數字(可序列化)
async postMessage(who: string, text: string): Promise<number> {
const row = this.sql
.exec<{ id: number }>(
"INSERT INTO messages (who, text) VALUES (?, ?) RETURNING id",
who,
text
)
.one();
return row.id;
}
// ★ RPC 方法:回傳純物件陣列(可序列化)
async getRecent(limit = 20): Promise<{ who: string; text: string }[]> {
return this.sql
.exec<{ who: string; text: string }>(
"SELECT who, text FROM messages ORDER BY id DESC LIMIT ?",
limit
)
.toArray()
.reverse();
}
// ★ 內部 private 方法「不會」被暴露為 RPC 端點
private assertValid(text: string): void {
if (text.length > 500) throw new Error("訊息過長");
}
}
3. Worker 端:直接 stub.method() 呼叫
Worker 拿到 stub 後,就像呼叫本地物件一樣呼叫 RPC。全程有型別提示,回傳值型別自動推斷:
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const url = new URL(req.url);
const room = url.searchParams.get("room") ?? "lobby";
const stub = env.CHAT_ROOM.getByName(room);
if (req.method === "POST") {
const { who, text } = await req.json<{ who: string; text: string }>();
// ★ RPC:像本地函式,回傳 Promise<number>
const id = await stub.postMessage(who, text);
return Response.json({ id });
}
// ★ RPC:回傳型別自動推斷為 { who: string; text: string }[]
const recent = await stub.getRecent(10);
return Response.json({ recent });
},
} satisfies ExportedHandler<Env>;
4. 跨物件呼叫:DO 呼叫另一個 DO
重點來了。DO 之間也能互相 RPC——因為 DO 的建構子拿得到 env,它就能像 Worker 一樣從 env 取得另一個 DO 的 stub。這裡讓 ChatRoom 在有人發言時,順手去更新 Presence DO 的「最後活躍時間」:
export class ChatRoom extends DurableObject<Env> {
// ...(前略,同上)
async postMessage(who: string, text: string): Promise<number> {
const row = this.sql
.exec<{ id: number }>(
"INSERT INTO messages (who, text) VALUES (?, ?) RETURNING id",
who, text
)
.one();
// ★ 跨物件 RPC:從 env 取得 Presence 的 stub,直接呼叫它的方法
// 注意:這是「呼叫 B 的方法」,不是「讀 B 的變數」——兩物件不共享記憶體
const presence = this.env.PRESENCE.getByName("global");
await presence.markActive(who); // await 一個跨 isolate 的遠端呼叫
return row.id;
}
}
export class Presence extends DurableObject<Env> {
private sql = this.ctx.storage.sql;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.sql.exec(`
CREATE TABLE IF NOT EXISTS presence (
who TEXT PRIMARY KEY,
last_ts INTEGER NOT NULL
);
`);
});
}
// ★ 被 ChatRoom 跨物件呼叫的 RPC 方法
async markActive(who: string): Promise<void> {
this.sql.exec(
`INSERT INTO presence (who, last_ts) VALUES (?, ?)
ON CONFLICT(who) DO UPDATE SET last_ts = excluded.last_ts;`,
who,
Date.now()
);
}
async onlineCount(sinceMs = 60_000): Promise<number> {
const cutoff = Date.now() - sinceMs;
return this.sql
.exec<{ n: number }>(
"SELECT COUNT(*) AS n FROM presence WHERE last_ts >= ?",
cutoff
)
.one().n;
}
}
透過 env.PRESENCE.getByName("global") 取得 stub 再呼叫 markActive,ChatRoom 就把「更新線上狀態」這個責任委派給了專責的 Presence DO。兩者狀態完全隔離,只透過方法呼叫溝通——這正是把多個有狀態實體「織成協作網」的基本手法。
5. RpcTarget:回傳一個「可繼續呼叫」的物件
有時你想回傳的不是死資料,而是一個「還能繼續呼叫方法的物件」——例如一個綁定了某位使用者的 session 控制器。一般 class 實例不能跨 RPC 邊界,但繼承 RpcTarget 的類別可以,它會被包成一個 stub 回傳:
import { DurableObject, RpcTarget } from "cloudflare:workers";
// ★ 繼承 RpcTarget:此類別的實例可作為「可呼叫 stub」被回傳
class UserSession extends RpcTarget {
constructor(private who: string, private room: ChatRoom) {
super();
}
// 這些方法呼叫端可以繼續 await
async say(text: string): Promise<number> {
return this.room.postMessage(this.who, text);
}
}
export class ChatRoom extends DurableObject<Env> {
// ...(前略)
// ★ 回傳一個 RpcTarget stub,而非純資料
async login(who: string): Promise<UserSession> {
return new UserSession(who, this);
}
}
// Worker 呼叫端:拿到 session stub 後,繼續呼叫它的方法
const room = env.CHAT_ROOM.getByName("lobby");
const session = await room.login("Alice");
const msgId = await session.say("Hello!"); // 對回傳的 stub 再次 RPC
6. Service Bindings RPC:Worker↔Worker
RPC 不只用於 DO。若你把功能拆成多個 Worker(如一個 auth-service、一個主 Worker),可以讓服務 Worker 繼承 WorkerEntrypoint 暴露 public 方法,再透過 Service Bindings 讓其他 Worker 零延遲、型別安全地呼叫——完全不用打 HTTP:
// auth-service/src/index.ts —— 被呼叫的服務 Worker
import { WorkerEntrypoint } from "cloudflare:workers";
export default class AuthService extends WorkerEntrypoint<Env> {
// ★ public 方法即 RPC 端點
async verifyToken(token: string): Promise<{ userId: string } | null> {
if (token === "secret") return { userId: "u_123" };
return null;
}
// WorkerEntrypoint 仍可保留傳統 HTTP 入口(可選)
async fetch(): Promise<Response> {
return new Response("auth-service up");
}
}
在主 Worker 的 wrangler.jsonc 綁定這個服務,並宣告其型別:
{
"name": "main-app",
"services": [
{ "binding": "AUTH", "service": "auth-service", "entrypoint": "AuthService" }
]
}
// main-app/src/index.ts —— 呼叫端
interface Env {
AUTH: Service<import("../auth-service/src/index").default>;
}
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const token = req.headers.get("authorization") ?? "";
// ★ Service Bindings RPC:像本地呼叫另一個 Worker 的方法
const user = await env.AUTH.verifyToken(token);
if (!user) return new Response("Unauthorized", { status: 401 });
return Response.json({ userId: user.userId });
},
} satisfies ExportedHandler<Env>;
無論是 DO RPC、DO 跨物件呼叫,還是 Worker↔Worker 的 Service Bindings RPC,心智模型完全一致:在被呼叫端定義 public 方法,在呼叫端拿到 stub 直接 await 呼叫。
常見錯誤與最佳實踐
坑一:從 RPC 方法回傳「帶方法的 class 實例」,拋出 DataCloneError。
這是最常見的序列化錯誤。RPC 回傳值必須是 structured clone 支援的型別。一個普通的 class 實例(帶方法)不在其中,會直接拋錯。要嘛回傳純資料,要嘛讓那個類別繼承 RpcTarget。
// ❌ 錯誤:User 是帶方法的 class 實例,無法 structured clone
class User { constructor(public id: string) {} greet() { return "hi"; } }
async getUser(): Promise<User> { return new User("u1"); } // 拋 DataCloneError
// ✅ 正確:回傳純物件(plain data)
async getUser(): Promise<{ id: string }> { return { id: "u1" }; }
// ✅ 或:若真要回傳「行為」,讓類別繼承 RpcTarget
class UserCtl extends RpcTarget { /* ... */ }
坑二:以為兩個物件能共享記憶體、直接讀對方的變數。
DO 之間、Worker 與 DO 之間不共享記憶體。你不能「拿到另一個物件然後讀它的 this.someField」——唯一的溝通方式是呼叫它的 public 方法,而且拿到的是回傳資料的副本。
// ❌ 錯誤的想像:以為能直接讀另一個 DO 的欄位
const other = env.PRESENCE.getByName("global");
// other.someField ← 不存在這種用法,stub 上只有「方法」
// ✅ 正確:呼叫方法,拿回複製的資料
const count = await other.onlineCount(); // 拿到的是數字副本
坑三:RPC 呼叫沒有 await,或忘了它是非同步的。
每個 RPC 呼叫都回傳 Promise,即使 DO 端方法看起來同步。漏掉 await 會拿到一個 pending 的 stub/promise 而非結果,常導致「值是 undefined 或 [object Promise]」的怪異 bug。
// ❌ 錯誤:忘了 await,value 不是數字
const value = stub.increment(5); // value 是 Promise,不是 number
// ✅ 正確
const value = await stub.increment(5);
坑四:錯誤沒有妥善處理,讓遠端例外無聲吞掉或炸穿整條呼叫鏈。
RPC 的好處之一是被呼叫端 throw 的例外,會跨越邊界在呼叫端重新拋出,所以你可以直接用 try/catch 包住。但要注意:例外訊息會傳過來,呼叫端應該妥善捕捉並轉成合理的回應,而不是讓它一路炸到使用者。
// DO 端:正常 throw 即可
async postMessage(who: string, text: string): Promise<number> {
if (text.length > 500) throw new Error("訊息過長"); // 會傳回呼叫端
// ...
}
// ✅ 呼叫端:用 try/catch 攔截遠端例外,轉成合理回應
try {
const id = await stub.postMessage(who, text);
return Response.json({ id });
} catch (err) {
// 遠端 throw 的 Error 在這裡被重新拋出並捕捉
return Response.json({ error: (err as Error).message }, { status: 400 });
}
坑五:一次塞超過 32 MiB 的酬載。
RPC 的序列化酬載上限是 32 MiB。別想著把一個巨大陣列或整包檔案當參數/回傳值傳。若要傳大量資料,改用分頁(limit/offset)、或改傳 ReadableStream(串流所有權會轉移,不受此上限限制)。
還要記得的幾個原則:
- 啟用條件:DO 類別必須
extends DurableObject,且compatibility_date >= 2024-04-03;否則 RPC 不生效,只能走fetch。 - 只有 public 方法是端點:
private/#私有方法不會被暴露,可安心放內部邏輯。 - Promise Pipelining 省往返:對回傳 stub(如
RpcTarget)的方法,可省略中間await把多次呼叫合併為單一往返,降低延遲。 - fetch 沒有被淘汰:需要原生 HTTP 語義(轉發原始
Request、串流大檔、既有路由相容)時,fetch介面仍然可用,兩者能並存。
最佳實踐小結:記牢——被呼叫端定義 public 方法、呼叫端拿 stub 直接 await;回傳一律用可 structured clone 的純資料,要回傳「行為」才用 RpcTarget;物件之間不共享記憶體,只能用方法呼叫溝通;遠端例外會跨邊界重拋,用 try/catch 攔截;酬載不超過 32 MiB,大資料用串流或分頁。守住這幾條,你就能把散落的有狀態實體,織成一張型別安全的協作網。
小結
上一篇《DO WebSocket Hibernation》,我們讓每個 DO 能扛住大量長連線又能在空閒時休眠省錢;這一篇,我們讓 DO 與 Worker 學會彼此呼叫、互相協調:
- RPC 取代手動路由——只要類別
extends DurableObject且compatibility_date >= 2024-04-03,DO 的 public 方法就自動成為 RPC 端點;呼叫端拿到 stub 後await stub.method(args),像本地函式一樣,享有完整 TypeScript 型別推斷,徹底告別fetch手動組 URL、手動序列化的樣板。 - 序列化規則——參數與回傳值經
structured clone跨越 isolate 邊界複製傳遞;純物件、陣列、Date、Map、ArrayBuffer、ReadableStream都能傳,帶方法的 class 實例不行(會拋DataCloneError),要回傳「可呼叫物件」就繼承RpcTarget。 - 跨物件與跨 Worker——DO 從
env取得另一個 DO 的 stub 即可跨物件 RPC(但兩者不共享記憶體,只能呼叫方法);Worker↔Worker 則用WorkerEntrypoint暴露方法,搭配 Service Bindings 零延遲呼叫。 - 錯誤與限制——遠端
throw的例外會在呼叫端重新拋出,用try/catch攔截;酬載上限 32 MiB;RPC 呼叫永遠是非同步,別忘了await。
現在你的 DO 不只能存值、查詢、交易、排定時器、扛長連線,還能像微服務一樣彼此型別安全地呼叫協作。基礎能力已經鋪滿,是時候把它們組合起來打一場硬仗了。下一篇《DO 實戰:多人協作》,我們就用這些積木——RPC、WebSocket、SQLite、單執行緒一致性——從零打造一個真正的多人即時協作應用,把這一路學到的東西全部串成一個完整系統。
想先查閱官方對 Durable Objects RPC 的完整說明,可以隨時參考 Cloudflare Durable Objects RPC 官方文件。型別安全的跨物件呼叫已經到手,我們下一篇《DO 實戰:多人協作》見。