Prisma Accelerate の機能を Cloudflare Workers で実装する
PrismaのQueryEngineとCloudflareWorkers
PrismaのQueryEngineはRustで実装されています。今までは各ネイティブバイナリにコンパイルされていましたが、WebAssemblyでのサポートも追加され、ネイティブライブラリが動かないCloudflareWorkersでも動作可能になりました。しかし問題点がありました。QueryEngineのwasmファイルのサイズが圧縮時で1MBを超えてしまっていたのです。無料プランでは1MBを超えることはできないので、実質的に有料プランでしか利用できませんでした。
しかし状況は変わりました。@prisma/client@5.10.0-devまで達したところで、圧縮時に900KBまで縮みました。これによってDBにアクセスするための最低限のAdapterやコードを載せても1MB以内に収まるようになりました。
最低限の実装をした場合の容量
CloudflareWorkersでPrismaをPostgreSQLに接続するための実装を行うと、圧縮容量970KB程度になります。つまり残り圧縮容量50KB程度でアプリケーション本体を実装しなければなりません。不可能ではありませんが、実用的とは言い難いサイズです。バックエンド用のフレームワークを載せたらすぐに突破してしまいます。
Workersを分ける
CloudflareWorkersの1MB制限は、Worker1つあたりのサイズです。複数のWokerにまたがって適用されるわけではないので、DBにアクセスするための専用Workerを作れば容量制限が回避できます。ここで利用するのがprisma-accelerate-localです。
https://www.npmjs.com/package/prisma-accelerate-local
Prismaには標準でDataProxy機能があり、QueryEngineのやり取りをネットワークを通じて行うことができます。元々prisma-accelerate-localは、PrismaAccelerateへのアクセスエミュレートしてローカルDBに接続するための作ったパッケージなのですが、外に出せばDataProxy用のサーバとして使えます。これをWorkersに組み込んでしまえば良いのです。
サンプルコード
https://github.com/SoraKumo001/prisma-accelerate-workers
PrismaのDataProxyの要求を受け取って、必要な処理を返しています。ここで面倒だったのが、Workersの仕様でPOSTとPUTメソッドでインスタンスが別に生成されるという問題です。PrismaのDataProxyはPrismaのスキーマをPUTで送って、各種QueryをPOSTで処理します。Queryの処理にスキーマが必要なのでこのデータを渡す必要がありました。この部分はKVを経由して引き渡すようにしています。
DenoDeployで同じものを作ったときは、容量制限もないしインスタンスもPUTとPOSTで別扱いということも無かったので素直に実装できたのですが、Workersはクセがありました。
src/index.ts
1import { PrismaPg } from '@prisma/adapter-pg';2import WASM from '@prisma/client/runtime/query-engine.wasm';3import { PrismaAccelerate, PrismaAccelerateConfig, ResultError } from 'prisma-accelerate-local/lib';4import { getPrismaClient } from '@prisma/client/runtime/wasm.js';5import pg from 'pg';67export interface Env {8 SECRET: string;9 KV: KVNamespace;10}1112const getAdapter = (datasourceUrl: string) => {13 const url = new URL(datasourceUrl);14 const schema = url.searchParams.get('schema');15 const pool = new pg.Pool({16 connectionString: url.toString(),17 });18 return new PrismaPg(pool, {19 schema: schema ?? undefined,20 });21};2223let prismaAccelerate: PrismaAccelerate;24const getPrismaAccelerate = async ({25 secret,26 onRequestSchema,27 onChangeSchema,28}: {29 secret: string;30 onRequestSchema: PrismaAccelerateConfig['onRequestSchema'];31 onChangeSchema: PrismaAccelerateConfig['onChangeSchema'];32}) => {33 if (prismaAccelerate) {34 return prismaAccelerate;35 }36 prismaAccelerate = new PrismaAccelerate({37 secret,38 adapter: (datasourceUrl) => getAdapter(datasourceUrl),39 getQueryEngineWasmModule: async () => {40 return WASM;41 },42 getPrismaClient: getPrismaClient as never,43 onRequestSchema,44 onChangeSchema,45 });46 return prismaAccelerate;47};4849export default {50 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {51 const prismaAccelerate = await getPrismaAccelerate({52 secret: env.SECRET ?? 'test',53 onRequestSchema: ({ engineVersion, hash, datasourceUrl }) => {54 return env.KV.get(`${engineVersion}:${hash}:${datasourceUrl}`);55 },56 onChangeSchema: ({ inlineSchema, engineVersion, hash, datasourceUrl }) => {57 return env.KV.put(`${engineVersion}:${hash}:${datasourceUrl}`, inlineSchema, { expirationTtl: 60 * 60 * 24 * 7 });58 },59 });6061 const url = new URL(request.url);62 const paths = url.pathname.split('/');63 const [_, version, hash, command] = paths;64 const headers = Object.fromEntries(request.headers.entries());65 const createResponse = (result: Promise<unknown>) =>66 result67 .then((r) => {68 return new Response(JSON.stringify(r), {69 headers: { 'content-type': 'application/json' },70 });71 })72 .catch((e) => {73 if (e instanceof ResultError) {74 return new Response(JSON.stringify(e.value), {75 status: e.code,76 headers: { 'content-type': 'application/json' },77 });78 }79 return new Response(JSON.stringify(e), {80 status: 500,81 headers: { 'content-type': 'application/json' },82 });83 });8485 if (request.method === 'POST') {86 const body = await request.text();87 switch (command) {88 case 'graphql':89 return createResponse(prismaAccelerate.query({ body, hash, headers }));90 case 'transaction':91 return createResponse(92 prismaAccelerate.startTransaction({93 body,94 hash,95 headers,96 version,97 })98 );99 case 'itx': {100 const id = paths[4];101 switch (paths[5]) {102 case 'commit':103 return createResponse(104 prismaAccelerate.commitTransaction({105 id,106 hash,107 headers,108 })109 );110 case 'rollback':111 return createResponse(112 prismaAccelerate.rollbackTransaction({113 id,114 hash,115 headers,116 })117 );118 }119 }120 }121 } else if (request.method === 'PUT') {122 const body = await request.text();123 switch (command) {124 case 'schema':125 return createResponse(126 prismaAccelerate.updateSchema({127 body,128 hash,129 headers,130 })131 );132 }133 }134 return new Response('Not Found', { status: 404 });135 },136};
wrangler.toml
こちらは最低限必要な設定になります。容量削減のためのminifyとpgパッケージを動かすためにnode_compatが必要です。また、KVの設定とAPIKey用のSECRETが必要です。
1minify = true2node_compat = true34[[kv_namespaces]]5binding = "KV"6id = "xxxxxx"78[vars]9SECRET = "**********"
クライアントからの使い方
まずはapi_keyを作ります。Keyの中にDBのアドレスを含めます。
npx prisma-accelerate-local -s SECRET -m DB_URL
1npx prisma-accelerate-local -s abc -m postgres://postgres:xxxx@db.example.com:5432/postgres?schema=public
PrismaのDATABASE_URLをデプロイしたアドレスと、先程生成したapi_keyを設定します。
1DATABASE_URL="prisma://xxxx.workers.dev/?api_key=xxx"
あとは以下のようにedgeランタイム用のPrismaClientを呼び出せば動作します。
1import { PrismaClient } from '@prisma/client/edge';
こちらはwasmを含む必要がないので、Workers上で実装する場合も、さほど容量を気にする必要はありません。
Workers上でService Bindingsを使用する場合は、fetchに手を入れる必要があります。以下ではprismaという名前で
Service Bindingsを用意し、要求が来たらそちらへ処理を振り分けています。また、開発用のローカルアドレスへの要求はhttpsをhttpに変換するようにしています。これは開発中にprisma-accelerate-localをhttpモードで動かしている場合の対策です。
1import { PrismaClient } from '@prisma/client/edge';23export interface Env {4 DATABASE_URL: string;5 prisma: Fetcher;6}78const initFetch = (env: Env) => {9 const that = globalThis as typeof globalThis & { originFetch?: typeof fetch };10 if (that.originFetch) return;11 const originFetch = globalThis.fetch;12 that.originFetch = originFetch;13 globalThis.fetch = async (input: RequestInfo, init?: RequestInit) => {14 const url = new URL(input.toString());15 const databaseURL = new URL(env.DATABASE_URL);16 if (['127.0.0.1', 'localhost'].includes(url.hostname)) {17 url.protocol = 'http:';18 return originFetch(url.toString(), init);19 }20 if (url.hostname === databaseURL.hostname) {21 return env.prisma.fetch(input, init);22 }23 return originFetch(input, init);24 };25};2627export default {28 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {29 initFetch(env);30 const url = new URL(request.url);31 if (url.pathname !== '/') return new Response('Not found', { status: 404 });3233 const prisma = new PrismaClient({ datasourceUrl: env.DATABASE_URL });34 await prisma.post.create({ data: {} });35 const result = await prisma.post.findMany({ orderBy: { createdAt: 'desc' } });36 return new Response(JSON.stringify(result, undefined, ' '), {37 headers: { 'content-type': 'application/json' },38 });39 },40};41
まとめ
@prisma/client@5.10.0系統が正式版になったら、本格的にCloudflareWokersで無料のシステムが作れるようになりそうです。これに先立って画像最適化も無料プランで出来るようにしたので、無料乞食精神で必要なものがあれば逐次投入していく予定です。