🥂

RPC対応によりCloudflare Workers間の連携がすごいことになった

2024/04/07に公開

日本時間の2024/04/05にCloudflareからRPCを使用したCloudflare Workers間の通信が発表されました。

https://blog.cloudflare.com/javascript-native-rpc

これによりいくつかの課題が解決されると同時にCloudflare上にアプリケーションを構築する利便性が1段階どころか2段階以上上がったといっても過言ではないと思っています。
このRPCの対応によりService Bindingsが更に使い勝手がよくなったのでそれの紹介です。

出来上がりのコードはここにありますので、時間の無い方はこちらを参照ください。
https://github.com/chimame/connect-remix-and-prisma-d1-using-rpc-on-cloudflare-pages

前提条件

以前RemixとPrismaでD1に接続する記事を書きました。
https://zenn.dev/chimame/articles/d3e7af9a612038

その中で容量制限の問題があると書きましたが、それを解消する話をベースに今回のRPC対応の内容を書きます。ですので記事を読んでない方はCloudflare Workersの無料版はビルドファイルが1MBまでの制限があるということを念頭にお読みください。

Service Bindings

ちょっと古いですが、昔にこんな記事を書きました。
https://zenn.dev/chimame/articles/0207636dea9c78#service-bindingsとは

要はWorkersをすべて1つに纏めず、分割して配置して、WorkersからWorkersを呼べるようにするという代物です。分割によるいくつのかメリットも生まれます。

  • Cloudflare Workersのファイルサイズを小さく保つことが出来る
  • Cloudflare Workersの処理ごとキャッシュしてしまう

などなどあります。もちろん小さく保つことはメリットもありますが分割しすぎるとマイクロサービスのようなデメリットもありますのでその点は注意してください。
しかし、Cloudflare Workersは(加入プランによるが)サイズ制限もあります。仮に無料ならばビルド後(圧縮した)サイズで1MBという制約もあります。サイズの制約を回避するためにもSerivce Bindingsは非常に有用な機能です。

ただし、今まではただ単にSerivce Bindingsを使用して分割した場合は2つのWorkers間のインターフェースをどうするかという問題がありました。それが今回のRPCにより解決されたのでそれの使用方法を解説しつつ分割していきます。

2Workersを持つmonorepoを作成

現時点のPrismaはWasmのサイズのためRemixとPrismaをバンドルすると1MBを超えます。なので無料版で動かしたいって方は残念ながら他の方法を取らざるを得ませんでした。
しかし、今回発表されたRPCを使ってスマートに回避することができるようになったのでそれを書いていきます。

今回は完成形の構成概要を以下に書きます

上記のように2つのWorkersに分割します。

  • Prismaを使用したDBアクセスを行うことに特化したWorkers
  • Remixを使用した受け取ったデータでレンダリングを行うWorkers

Remixの場合は正確にはCloudflare Pagesを使用しているのでCloudflare Pages Functionsという名称ですが実態はCloudflare Workersなので違いは殆どありません。なので上記のように2つのWorkersに分割してアプリケーションを構築してサイズ制限を回避します。

2つのWorkersを作るのでプロジェクトもそのように作成していきます。yarnを使用したmonorepo構成で記載しますが、pnpmなど自身な好きなもので作成して頂いて大丈夫です。

こんな感じに配置していきます。

├── README.md
├── package.json
├── yarn.lock
└── pakcages/
    ├── prisma(workers)
    └── remix(workers)

Remixを初期設定

この辺は特に何も考えずにRemixを以下のように配置してください。

mkdir -p packages/remix
cd packages/remix
npx create-remix@latest --template remix-run/remix/templates/cloudflare

Prismaを初期設定

前回はRemixと同じpackage.jsonに入れましたが、今回は分割します。プロジェクトルートからは以下のようなコマンドで設定して頂ければ問題ないです。

mkdir -p packages/prisma
# nameだけを書いた packages/prisma/package.jsonを作成
yarn workspace <prisma package name> add -D prisma
yarn workspace <prisma package name> add @prisma/client @prisma/adapter-d1
yarn workspace <prisma package name> run prisma init --datasource-provider sqlite

最後に作成された schema.prisma に以下の記述を足して完成です。

schema.prisma
generator client {
  provider = "prisma-client-js"
+ previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

これでPrisma側のWorkersの初期設定は完了です。

データベースのマイグレーション

Prismaでデータベースをマイグレーションする作業も必要なのですが、これは前回同様の手順で作っています。

https://github.com/chimame/connect-remix-and-prisma-d1-using-rpc-on-cloudflare-pages/commit/4718ddbeda21db2e5496dd7558d736bdd1a974f0

https://zenn.dev/chimame/articles/d3e7af9a612038#cloudflare-d1にmigrate

PrismaがPreviewというのもあって、ドキュメントでは標準出力からD1用のマイグレーションDDLに書き込んでいる手順があるのですが、正直これは使いにくいので一旦は自分のようにPrismaのmigrationファイルをD1が取り込めるようにファイル位置を変えてやるだけでいいかなと思います。

https://www.prisma.io/blog/build-applications-at-the-edge-with-prisma-orm-and-cloudflare-d1-preview#4-create-a-table-in-the-database

Prisma Workersの処理作成

まずはDatebaseへのアクセスするための処理を書いて行きます。

Workersの初期設定

Prisma側のpackageにはPrismaしか入れてないので、まずはWorkersが動くための設定を入れていきます。

yarn workspace <prisma package name> add -D wrangler @cloudflare/workers-types
yarn workspace <prisma package name> run wrangler create D1 <D1 database name>

作ったD1の設定を wrangler.toml に記載します。以下の例です。

packages/prisma/wrangler.toml
name = "prisma-d1-rpc-sample"
main = "src/index.ts"
compatibility_date = "2022-04-05"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "prisma-rpc-db"
database_id = "__YOUR_D1_DATABASE_ID__"

Prismaを使ったWorkersの作成

次に実際のPrisma側のWorkersのコードですが、こんな感じのコードを書きます。

pakcages/prisma/database/client.ts
import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";

export const connection = (db: D1Database) => {
  const adapter = new PrismaD1(db);
  return new PrismaClient({ adapter });
}
packages/prisma/src/index.ts
import { WorkerEntrypoint } from "cloudflare:workers";
import { connection } from "./database/client";

export interface Env {
  DB: D1Database,
}

export class UserService extends WorkerEntrypoint<Env> {
  fetchUsers() {
    const db = connection(this.env.DB)
    return db.user.findMany();
  }

  async createUser(data: { name: string, email: string }) {
    const db = connection(this.env.DB)
    const user = await db.user.create({
      data,
    });

    return user;
  }
}

export default {
  // An error occurs when deploying this worker without register event handlers as default export.
  async fetch() {
    return new Response("Healthy!");
  },
};

client.ts の方は特に説明は不要だと思いますが、ただ単にD1への接続をPrismaを用いて行う関数を定義しているだけです。 src/index.ts 側がWorkers本体のコードです。Service Bindingsの対象となるのはexportしている UserService です。named exportで定義していますが、default exportでも問題ありません。 UserService は2つの関数を持っており、ユーザデータを返す fetchUsers と ユーザを作成する createUser の2つの関数を定義しています。この関数内ではPrismaを使ってD1にアクセスし、SELECTやINSERTを行う処理が書いてあります。
これを見るだけだと特に難しいことはないと思います。ここで 大事なのはWorkerEntrypoint<Env>です 。特に Env によりD1の設定を取り込み this.env.DB でアクセス可能にしつつ、今回のRPC対応のService Bindingsを定義するベースとなります。これによりD1およびPrismaの処理をここのWorkersに閉じ込めることが出来ます。

Remix(Pages Functions)の作成

Service Bindingsの設定

Remixは初期セットアップで大体設定が揃っていますが、今回はRPCを使ったService Bindingsを使用するので以下の設定を持った wrangler.toml および Env 設定が必要です。

packages/remix/wrangler.toml
name = "remix-rpc-sample"

[[services]]
binding = "USER_SERVICE"
service = "prisma-d1-rpc-sample"
entrypoint = "UserService"
packages/remix/load-context.ts
import { type PlatformProxy } from "wrangler";
import type { UserService } from "@rpc-sample/prisma/src";

interface Env {
  USER_SERVICE: Service<UserService>; // <---ADD
}

type Cloudflare = Omit<PlatformProxy<Env>, "dispose">;

declare module "@remix-run/cloudflare" {
  interface AppLoadContext {
    cloudflare: Cloudflare;
  }
}

まず、Remix側からPrismaをもったWorkersを参照できるように wrangler.toml に設定を記述しています。これを書くことでローカルでもService Bindigsの設定が使えるようになります。ここで entrypoint を指定していますが、named exportでバインドされる側のWorkersを書いた場合にこの設定が必要になります。逆にdefault exportの場合はこれは必要ありません。
load-context.ts に書いた Env を書くことで wrangler.toml の内容を型として定義されます。
この2つが揃うことで次以降に書くRPCを使ったService Bindingsの本領が発揮されます。

注意点があるのですが、Remixも wrangler types のnpm scriptが定義されていますが、wrangler typesを使っても上記のような Service<UserService> の型を出力してくれません。まだRPCに対応してないと思うのでそこは手動で訂正するようにしてください。

Service Bindingsを介してDBデータをロードする

Remixでは loader を使ってデータをロードしますが、設定したService Bindingsを使用してデータをロードするプログラムは以下のようになります。

packages/remix/app/routes/users/handlers/loader.ts
import type { LoaderFunctionArgs } from "@remix-run/cloudflare";

export const loader = async ({ context }: LoaderFunctionArgs) => {
  using users = await context.cloudflare.env.USER_SERVICE.fetchUsers();

  return { users }
}

実際のデータロード処理はPrismaのWorkers側にあるので、Remixはただ単にそれを接続設定から呼び出しているだけです。ですが、これはTypeScriptです。この users という変数はどうなってるかというと

このように型が入っているのです。これがRPCを対応したService Bindingsのすごさです。このように型情報まで連携されるのでTypeScriptの恩恵を大きく受けることが出来ます。これはちょっと前にあった tRPC とほぼ同等のことがCloudflare内で実装できるということです。

型があるので後はこれをいつものように useLoaderData からデータを取得して、レンダリングしてやればいいだけです。

packages/remix/app/routes/users/route.tsx
import { Form, useLoaderData } from "@remix-run/react";
import { loader, action } from "./handlers";

export default function UserPage() {
  const { users } = useLoaderData<typeof loader>()

  return (
    <div>
      <Form method="post">
        <label htmlFor="name">Name</label>
        <input type="text" id="name" name="name" />
        <br />
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" required />
        <button type="submit">Add</button>
      </Form>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  )
}

export { loader, action };

アプリケーションの起動

まだ詳しい原因はわかっていないのですが、このRPCによるService BindingsはVite( getPlatformProxy )での起動はできませんでした。なのでもう1つの起動コマンドである start スクリプトの実態である wrangler pages dev で動作させる必要があります。

PrismaのWorkersも起動するので concurrently を使い同時に起動させます。

package.json
{
  ...
  "scripts": {
    "dev": "concurrently \"yarn run dev:prisma\" \"yarn run dev:remix:build && yarn run dev:remix:start\"",
    "dev:prisma": "yarn workspace @rpc-sample/prisma dev",
    "dev:remix:build": "yarn workspace @rpc-sample/remix build",
    "dev:remix:start": "yarn workspace @rpc-sample/remix start"
  },
  ...
}

これでRemixの画面へアクセスしつつ /users のパスへアクセスするとUserの登録と登録したUserが表示される画面が表示されるはずです。

今回発表されたRPCによりRemixとPrismaをWorkers単位で分離しつつ、型情報を共有できる連携が可能となりました。無料版のアカウントで試しみましたが無事デプロイ出来て動作することを確認しております。

using って何?

Remixの loader 処理の中でサラッと using というのが出てきます。これはTypeScript 5.2に出てきたものですが、これはECMAScriptのStage 3にある仕様です。

https://github.com/tc39/proposal-explicit-resource-management

https://zenn.dev/ventus/articles/ts5_2-using-preview

すごく乱暴に書くとリソースの開放をよしなにやってくれるって代物です。(まじで乱暴に書いてるので自分で理解するようにしてください)じゃあなんで今回は使ってるの?というとそれはこちらのドキュメントに詳しく書いています。

https://developers.cloudflare.com/workers/runtime-apis/rpc/lifecycle/

全部読むと長いので要約すると

  • using はStage 3の仕様だが、V8にもそのうち取り込まれるよ
  • Cloudflare WorkersのRPCの返り値はサーバ側(この記事でいうPrisma側のWorkers)のメモリ上に保持しているよ
  • サーバ側のメモリを明示的に開放するためには Symbol.dispose いわゆるディスポーザーをクライアント側(この記事でいうRemix側)から呼んでサーバ側に開放していいよって命令してね
  • 変数をクライアント側に保持するなら dup でコピーしてね

てことです。そうなんです。このPRCから返ってきた値は呼び出し側のメモリ空間に持っているのではなく、サーバ側が持っているんです。(実装した人がすごすぎる) using はメモリの開放を行う際に Symbol.dispose を行います。なのでサーバ側のメモリ解放にもこの using の仕様が使われているわけです。(逆に使う必要があるということです)
いうなればService Bindingsもhttpのようなプロトコルで通信したデータのやりとりをしているわけでなく、Service Bindingsで結合したWorkersを何か別のホストで動かいているようなものに近いのかもしれません。

さいごに

発表されたRPCがCloudflare WorkersのSerivce Bindingsの使い勝手を大きく向上させました。class構文というスマートな書き心地だけでこれだけのことが実装されるのは想像を遥かに超えています。これよりCloudflare Workersだけでフロントエンドとバックエンドを実装するということもかなり容易になったはずです。
Workers間の連携が必要な設計が出てきた際にはぜひ使用してみてください。

Discussion