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
import { PrismaPg } from '@prisma/adapter-pg';
import WASM from '@prisma/client/runtime/query-engine.wasm';
import { PrismaAccelerate, PrismaAccelerateConfig, ResultError } from 'prisma-accelerate-local/lib';
import { getPrismaClient } from '@prisma/client/runtime/wasm.js';
import pg from 'pg';
export interface Env {
SECRET: string;
KV: KVNamespace;
}
const getAdapter = (datasourceUrl: string) => {
const url = new URL(datasourceUrl);
const schema = url.searchParams.get('schema');
const pool = new pg.Pool({
connectionString: url.toString(),
});
return new PrismaPg(pool, {
schema: schema ?? undefined,
});
};
let prismaAccelerate: PrismaAccelerate;
const getPrismaAccelerate = async ({
secret,
onRequestSchema,
onChangeSchema,
}: {
secret: string;
onRequestSchema: PrismaAccelerateConfig['onRequestSchema'];
onChangeSchema: PrismaAccelerateConfig['onChangeSchema'];
}) => {
if (prismaAccelerate) {
return prismaAccelerate;
}
prismaAccelerate = new PrismaAccelerate({
secret,
adapter: (datasourceUrl) => getAdapter(datasourceUrl),
getQueryEngineWasmModule: async () => {
return WASM;
},
getPrismaClient: getPrismaClient as never,
onRequestSchema,
onChangeSchema,
});
return prismaAccelerate;
};
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const prismaAccelerate = await getPrismaAccelerate({
secret: env.SECRET ?? 'test',
onRequestSchema: ({ engineVersion, hash, datasourceUrl }) => {
return env.KV.get(`${engineVersion}:${hash}:${datasourceUrl}`);
},
onChangeSchema: ({ inlineSchema, engineVersion, hash, datasourceUrl }) => {
return env.KV.put(`${engineVersion}:${hash}:${datasourceUrl}`, inlineSchema, { expirationTtl: 60 * 60 * 24 * 7 });
},
});
const url = new URL(request.url);
const paths = url.pathname.split('/');
const [_, version, hash, command] = paths;
const headers = Object.fromEntries(request.headers.entries());
const createResponse = (result: Promise<unknown>) =>
result
.then((r) => {
return new Response(JSON.stringify(r), {
headers: { 'content-type': 'application/json' },
});
})
.catch((e) => {
if (e instanceof ResultError) {
return new Response(JSON.stringify(e.value), {
status: e.code,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify(e), {
status: 500,
headers: { 'content-type': 'application/json' },
});
});
if (request.method === 'POST') {
const body = await request.text();
switch (command) {
case 'graphql':
return createResponse(prismaAccelerate.query({ body, hash, headers }));
case 'transaction':
return createResponse(
prismaAccelerate.startTransaction({
body,
hash,
headers,
version,
})
);
case 'itx': {
const id = paths[4];
switch (paths[5]) {
case 'commit':
return createResponse(
prismaAccelerate.commitTransaction({
id,
hash,
headers,
})
);
case 'rollback':
return createResponse(
prismaAccelerate.rollbackTransaction({
id,
hash,
headers,
})
);
}
}
}
} else if (request.method === 'PUT') {
const body = await request.text();
switch (command) {
case 'schema':
return createResponse(
prismaAccelerate.updateSchema({
body,
hash,
headers,
})
);
}
}
return new Response('Not Found', { status: 404 });
},
};wrangler.toml
こちらは最低限必要な設定になります。容量削減のためのminifyとpgパッケージを動かすためにnode_compatが必要です。また、KVの設定とAPIKey用のSECRETが必要です。
minify = true
node_compat = true
[[kv_namespaces]]
binding = "KV"
id = "xxxxxx"
[vars]
SECRET = "**********"クライアントからの使い方
まずはapi_keyを作ります。Keyの中にDBのアドレスを含めます。
npx prisma-accelerate-local -s SECRET -m DB_URL
npx prisma-accelerate-local -s abc -m postgres://postgres:xxxx@db.example.com:5432/postgres?schema=publicPrismaのDATABASE_URLをデプロイしたアドレスと、先程生成したapi_keyを設定します。
DATABASE_URL="prisma://xxxx.workers.dev/?api_key=xxx"あとは以下のようにedgeランタイム用のPrismaClientを呼び出せば動作します。
import { PrismaClient } from '@prisma/client/edge';こちらはwasmを含む必要がないので、Workers上で実装する場合も、さほど容量を気にする必要はありません。
Workers上でService Bindingsを使用する場合は、fetchに手を入れる必要があります。以下ではprismaという名前で
Service Bindingsを用意し、要求が来たらそちらへ処理を振り分けています。また、開発用のローカルアドレスへの要求はhttpsをhttpに変換するようにしています。これは開発中にprisma-accelerate-localをhttpモードで動かしている場合の対策です。
import { PrismaClient } from '@prisma/client/edge';
export interface Env {
DATABASE_URL: string;
prisma: Fetcher;
}
const initFetch = (env: Env) => {
const that = globalThis as typeof globalThis & { originFetch?: typeof fetch };
if (that.originFetch) return;
const originFetch = globalThis.fetch;
that.originFetch = originFetch;
globalThis.fetch = async (input: RequestInfo, init?: RequestInit) => {
const url = new URL(input.toString());
const databaseURL = new URL(env.DATABASE_URL);
if (['127.0.0.1', 'localhost'].includes(url.hostname)) {
url.protocol = 'http:';
return originFetch(url.toString(), init);
}
if (url.hostname === databaseURL.hostname) {
return env.prisma.fetch(input, init);
}
return originFetch(input, init);
};
};
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
initFetch(env);
const url = new URL(request.url);
if (url.pathname !== '/') return new Response('Not found', { status: 404 });
const prisma = new PrismaClient({ datasourceUrl: env.DATABASE_URL });
await prisma.post.create({ data: {} });
const result = await prisma.post.findMany({ orderBy: { createdAt: 'desc' } });
return new Response(JSON.stringify(result, undefined, ' '), {
headers: { 'content-type': 'application/json' },
});
},
};
まとめ
@prisma/client@5.10.0系統が正式版になったら、本格的にCloudflareWokersで無料のシステムが作れるようになりそうです。これに先立って画像最適化も無料プランで出来るようにしたので、無料乞食精神で必要なものがあれば逐次投入していく予定です。