Cloudflare Workers間でRPC通信する

2024.04.09

Introduction

Cloudflare Workers に Service Binding を利用した RPC 機能が追加されました。
これにより、Workers 間の通信がとても簡単になります。

RPC は、ネットワーク上の 2 つのプログラム間の通信を表現する方法です。
AWS Lambda で別の Lambda を呼び出す場合、invoke を使って呼び出すことができますが、
今回 Workers に追加された機能では、Service Binding に別の Worker を設定することで、
通常の関数を呼び出すのと同じように別の Worker を実行することができます。

今回は、2 つの Workers を作成し、
別Workersを呼び出してみます。

Environment

  • MacBook Pro (13-inch, M1, 2020)
  • OS : MacOS 14.3.1
  • wrangler : 3.48.0
  • Node : v20.8.1

Try

では、2 つの Workers を実装していきます。
まずは呼び出される側の Workers を作ります。

% npm create cloudflare@latest

プロジェクト名は hello-rpc、言語は Typescript で作成します。
wranglewr.toml は ↓ のようになってます。

name = "hello-rpc"
main = "src/index.ts"
compatibility_date = "2024-04-05"
compatibility_flags = ["nodejs_compat"]

src/index.ts は下記です。
Calc クラスとそれを生成して返す CalcService を定義しています。
この Workers を直接実行した場合、メッセージを返すだけです。

import { WorkerEntrypoint, RpcTarget } from "cloudflare:workers";

export class Calc extends RpcTarget {
  #exec_counter = 0;

  add(a: number, b: number): number {
    this.#exec_counter += 1;
    return a + b;
  }

  get exec_counter() {
    return this.#exec_counter;
  }
}

export class CalcService extends WorkerEntrypoint {
  async newCalc() {
    return new Calc();
  }

  async newFunction() {
    let value = 0;
    return (increment = 0) => {
      value += increment;
      return value;
    };
  }
}

export default {
  fetch() {
    return new Response("HelloRpc is Healthy!");
  },
};

デプロイもしておきます。

% npm run deploy

次は ↑ の Workers を呼び出す側の Workers を作成します。

% npm create cloudflare@latest workers-rpc -- --type=hello-world

wrangler.toml に Service Binding を記述します。
entrypoint は WorkerEntrypoint を継承したクラス(デフォルトの場合は必要なし)を指定します。

services = [
    { binding = "CALC_SERVICE", service = "hello-rpc", entrypoint = "CalcService" },
]

関数を返す Workers を実行してみる

CalcService の newFunction 関数では、increment する関数を返しています。
これを呼び出してみましょう。
workers-rpc/src/index.ts を下記のように実装します。

export interface Env {
}

export default {
    async fetch(request, env) {
        using func = await env.CALC_SERVICE.newFunction();
        await func(3);
        await func(6);
        let count = await func(10);
        return new Response(count);
    }
}

env から binding した CACL_SERVICE を使ってそのまま関数を実行しています。
using はこのあたりに説明がありますが、スコープ外に出ると自動でリソース開放(dispose)してくれるヤツです。

Workers RPC の仕様上、呼び出し側(workers-rpc)にオブジェクトが存在する限り
呼び出された側(hello-rpc)のメモリ上に保持されます。
なので、呼び出し側で使い終わったら開放してあげる必要があるので、
適切に管理しましょう。

なお、現時点(2024/04)では、using は V8 でつかえません。
なので、普通に wranglder build とかできません。
そのため、ビルドやデプロイをしたい場合、下記のようにします。

% npx wrangler@using-keyword-experimental build
% npx wrangler@using-keyword-experimental deploy

デプロイして発行された URL にアクセスすると、
CalcService から取得した関数を実行できます。

クラスのインスタンスを使う

次は Calc クラスのオブジェクトを取得して使ってみます。
RPC のパラメーターや返り値で定義したクラスを使用するには、
RpcTarget クラスを継承します。
こちらも問題なくオブジェクトが使えます。

export default {
    async fetch(request, env) {

        using calc: Calc = await env.CALC_SERVICE.newCalc();

        let result = await calc.add(2, 2);
        console.log("2+2=" + result);
        let result2 = await calc.add(3, 5);
        console.log("3+5=" + result2);

        const count = await calc.exec_counter;

        return new Response(count);
    }
}

Miniflare でテスト

デプロイして動作させることはできたのですが、
ローカルで動かせないと UnitTest もできないので困ります。
下記のような記述を見つけたので、できるかと思って試してみたのですが、
まだ動作せず。

const mf = new Miniflare({
  envPath: true,
  packagePath: true,
  wranglerConfigPath: true,
  workers: [
    {
      name: "a",
      serviceBindings: {
        A_RPC_SERVICE: { name: kCurrentWorker, entrypoint: "RpcEntrypoint" },
        A_NAMED_SERVICE: { name: "a", entrypoint: "namedEntrypoint" },
        B_NAMED_SERVICE: { name: "b", entrypoint: "anotherNamedEntrypoint" },
      },
      compatibilityFlags: ["rpc"],
      modules: true,
      script: `
      import { WorkerEntrypoint } from "cloudflare:workers";

      export class RpcEntrypoint extends WorkerEntrypoint {
        ping() { return "a:rpc:pong"; }
      }

      export const namedEntrypoint = {
        fetch(request, env, ctx) { return new Response("a:named:pong"); }
      };

      ...
      `,
    },
    {
      name: "b",
      modules: true,
      script: `
      export const anotherNamedEntrypoint = {
        fetch(request, env, ctx) { return new Response("b:named:pong"); }
      };
      `,
    },
  ],
});

Summary

Binding を使うだけでそのまま(ほぼオーバーヘッドなしで)別 Workers を呼び出せるので、
Workers 間の連携がとても簡単になりました。

References