DO RPC 與跨物件呼叫:型別安全取代 fetch 手動路由 | Cloudflare 完整教學

2026/08/27
DO RPC 與跨物件呼叫:型別安全取代 fetch 手動路由 | Cloudflare 完整教學

上一篇《DO WebSocket Hibernation》,我們讓 Durable Object 能扛住成千上萬條長連線又不燒錢。這一篇,我們處理系統長大後的下一個問題:當 DO 越來越多,它們之間、以及 WorkerDO 之間,該如何「互相呼叫、協調」?答案是 RPC(Remote Procedure Call,遠端程序呼叫)。只要你的類別 extends DurableObject,就能在上面定義 public 方法,呼叫端拿到 stub 後就像呼叫本地函式一樣 await stub.method(args),享有完整 TypeScript 型別推斷——徹底取代過去用 fetch 手動拼 URL、手動解析的繁瑣路由。你會學到 RPC 的型別安全、structured clone 序列化規則、RpcTarget、以及用 WorkerEntrypointService 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 cloneRPC 參數/回傳值的序列化機制,決定哪些型別可傳
RpcTarget繼承它的類別實例可作為「可繼續呼叫的 stub」被回傳
WorkerEntrypoint讓一個 Worker 對外暴露 public 方法,供 Service Bindings RPC 呼叫
Service BindingsWorker↔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 類別上,任何 asyncpublic 方法都自動成為 RPC 端點。這裡 ChatRoom 暴露 postMessagegetRecent 兩個方法——注意它們就是普通方法,沒有任何特殊裝飾:

// 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 DurableObjectcompatibility_date >= 2024-04-03,DO 的 public 方法就自動成為 RPC 端點;呼叫端拿到 stub 後 await stub.method(args),像本地函式一樣,享有完整 TypeScript 型別推斷,徹底告別 fetch 手動組 URL、手動序列化的樣板。
  • 序列化規則——參數與回傳值經 structured clone 跨越 isolate 邊界複製傳遞;純物件、陣列、DateMapArrayBufferReadableStream 都能傳,帶方法的 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 實戰:多人協作》見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →