RPC対応によりCloudflare Workers間の連携がすごいことになった
日本時間の2024/04/05にCloudflareからRPCを使用したCloudflare Workers間の通信が発表されました。
これによりいくつかの課題が解決されると同時にCloudflare上にアプリケーションを構築する利便性が1段階どころか2段階以上上がったといっても過言ではないと思っています。
このRPCの対応によりService Bindingsが更に使い勝手がよくなったのでそれの紹介です。
出来上がりのコードはここにありますので、時間の無い方はこちらを参照ください。
前提条件
以前RemixとPrismaでD1に接続する記事を書きました。
その中で容量制限の問題があると書きましたが、それを解消する話をベースに今回のRPC対応の内容を書きます。ですので記事を読んでない方はCloudflare Workersの無料版はビルドファイルが1MBまでの制限があるということを念頭にお読みください。
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
に以下の記述を足して完成です。
generator client {
provider = "prisma-client-js"
+ previewFeatures = ["driverAdapters"]
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
これでPrisma側のWorkersの初期設定は完了です。
データベースのマイグレーション
Prismaでデータベースをマイグレーションする作業も必要なのですが、これは前回同様の手順で作っています。
PrismaがPreviewというのもあって、ドキュメントでは標準出力からD1用のマイグレーションDDLに書き込んでいる手順があるのですが、正直これは使いにくいので一旦は自分のようにPrismaのmigrationファイルをD1が取り込めるようにファイル位置を変えてやるだけでいいかなと思います。
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
に記載します。以下の例です。
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のコードですが、こんな感じのコードを書きます。
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 });
}
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
設定が必要です。
name = "remix-rpc-sample"
[[services]]
binding = "USER_SERVICE"
service = "prisma-d1-rpc-sample"
entrypoint = "UserService"
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を使用してデータをロードするプログラムは以下のようになります。
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
からデータを取得して、レンダリングしてやればいいだけです。
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
を使い同時に起動させます。
{
...
"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にある仕様です。
すごく乱暴に書くとリソースの開放をよしなにやってくれるって代物です。(まじで乱暴に書いてるので自分で理解するようにしてください)じゃあなんで今回は使ってるの?というとそれはこちらのドキュメントに詳しく書いています。
全部読むと長いので要約すると
-
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