現状Cloudflare WorkersでGraphQLサーバを構築するならコレ
結論
- Cloudflare WorkersでGraphQLサーバを立てて普通に動く
- TCPでのデータベース接続も問題ない(ベータなので使ってると何かあるかもしれないが)
- Node.js互換は完全ではないので、Node.jsが必要な処理はオリジンサーバを用意するのが吉
動機
Cloudflare WorkersはCDN上のプロキシやRemixやNext.jsのレンダリング用のバックエンドとして使うというようなことが多いです。フロントエンドからデータ取得や更新するためのAPIとなると別のバックエンドサーバを立てて、構築するのがほとんどだと思います。
自身も漏れなくそのパターンでNode.jsでバックエンドサーバを立てることが多いですが、そうなると簡単に建てれるCloud Runを初手で選ぶのですが、Cloud Run自体は素晴らしいサービスなんですが、更に欲が出てくるのが人間です。
- デプロイをもう少し早くしたい
- 料金をもう少し抑えたい
前者はコンテナサービスなんで「コンテナビルド」を行ってから「コンテナ入れ替え」という作業が必要なんですが、「コンテナ入れ替え」もさることながら、「コンテナビルド」もそこそこ時間がかかります。この2つをどれだけ早くしても5分〜10分はかかります。
後者はコールドスタンバイ(起動速度)の最小化や非同期を行うというと最低コンテナ数やAlways CPUなどを用いることになります。そうなるとそれなりに金額はかかってきます。それでもCloud Runは安いとは思いますが。
で、これらの要望を叶えるのがCloudflare Workersだと思っているのですが、実際にAPIサーバを立てるとなるとデータベースの接続が必要になってきます。SupabaseのようなPostgRESTのAPIで通信出来るものであればCloudflare Workersから接続は出来ますが、直接データベースへのコネクションを行うというのは今まで出来なかった。しかし、直近発表されたTCPでの接続がサポートされたことにより状況は変わりました。
ただ、TCPで接続できるからと言って、エッジで動作しているアプリケーションがデータ取得のために別のロケーションのデータベースに接続すればその分のオーバヘッドがかかるわけなので、エッジというロケーションの利点は少し失われるなと思っています。なので今後出てくるであろうD1がそのロケーションの利点を最大限に発揮できるので期待して待っています。
ということで、Cloudflare WorkersもCloud Runに劣らず素晴らしいサービスなのでこれの上でバックエンドAPIサーバとして動けば最高ってことで実際に動かしていくというのが今回の記事の目的です。
アーキテクチャ
バックエンドAPIをGraphQLサーバとして準備するという前提で進めていきますが、何を使って構築するかというのを書いていきます。
Cloudfalre Workers
まず、前提はこれです。Cloudflare Workersというサービスないしランタイムを選択したことでアプリケーションに使用できる技術は狭まります。なので前提として書いておきます。
Web Server
Cloudflare Workersで動作するWeb Serverは結構限定されます。代表的なのは Hono
ですが、今回はGraphQLサーバなので GraphQL Yoga
を選択します。最後におまけで Hono
も合わせて使用するならっていうコードは書いておきます。
GraphQL schema builder
これは好みがあって「スキーマファースト」と「コードファースト」の2パターンがあります。私はどっちかというと「コードファースト」が好きで、コードからスキーマを自動生成させています。Prismaを使う際にお世話になって気に入ってる Pothos
をそのまま選択しています。
ORM
残念ながらTCPで直接接続できるとなったCloudflare Workersでも Prisma
は使えません。使えない理由は詳しくは書かないですが、以下のツイートで詳しく書いてるので、気になる方は読んでください。
なので、Prisma
はORMとしては使えません。残る選択肢としては Kysely
か drizzle
になるわけですが、どちらもD1で動かくことはできるので将来的に変えたいという場合でもどちらでも可能です。しかし、今回発表のあったTCP接続ができるのは pg
というライブラリを使った接続なのでこれを使用して接続する Kysely
を選択します。 drizzle
も悪くないですがまだ少し安定には欠けるかなという印象です。
(正確性のため何度も説明する羽目になるが Kysely
はORMではなく、クエリビルダーです)
DB migration
Kysely
でも出来るのですが、データベースという気を使うものを扱うのでここは安定したものを使います。なのでマイグレーションには Prisma
を使用します。 Prisma
を使用するにはもう1つ理由があります。それは Kysely
の型を自動生成するために Prisma
を使用していると型を自動生成する prisma-kysely
が使えるためでもあります。
GraphQLサーバの構築
ここから具体的にコードを書いていきます。ちなみに完成品は以下にあるので、サクッと完成品だけみたいという方はそこからコードなり、コミットを見ればいいと思います。(コードを書いてるコミットも6コミット程度なんでわかりやすいかと)
Cloudflare Workersの作成
これは特に何も面白いことはないですが、強いていうなら最近発表のあった c3
を使ってやります。
$ npm create cloudflare
で、気をつけてほしいのはテンプレートを選べるのですが、現状GraphQLを作るには何を選んでも邪魔なものが入るので、できるだけブランク状態に近いものを選んで構築します。
このHello Worldだけが出力されるCloudflare Workersが作成されるはずです。
GraphQL APIの作成
GraphQL Yoga
を使ってGraphQL APIを構築します。まずは必要なライブラリのインストール
$ npm install graphql graphql-yoga @pothos/core
サンプルのQueryを持ったスキーマを作ります。
import SchemaBuilder from '@pothos/core'
const builder = new SchemaBuilder({})
builder.queryType({
fields: (t) => ({
hello: t.string({
resolve: () => 'world',
}),
}),
})
export const schema = builder.toSchema()
後はこのスキーマを読み込んで GraphQL-Yoga
でGraphQLのエンドポイントを作ります。
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'
// Create a Yoga instance with a GraphQL schema.
const yoga = createYoga({ schema })
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
return yoga(request, ctx)
},
}
作成したGraphQLサーバが動作するかを確認するには以下のコマンドでサーバを立ち上げます。
$ npm run start
その後、ブラウザで http://localhost:8787/graphql
にアクセスするとGraphQLを実行できるPlaygroundが開けるので、そこでサンプルのQueryを実行して動けばOKです。
DBスキーマ(テーブル)の作成
まずはデータベースにアプリケーションが読み込むためのスキーマを作成します。マイグレーションは Prisma
を使用するとなっているので、 Prisma
と周辺に必要なライブラリをインストールします。
$ npm install -D prisma prisma-kysely
$ npm install kysely
次に prisma init
で初期のスキーマファイルを作ってもいいし、以下のスキーマをコピペで貼って作ってくれてもいいです。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator kysely {
provider = "prisma-kysely"
output = "../src"
fileName = "types.ts"
}
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String?
email String
}
もちろん User
のスキーマも大事なんですが、今回大事なのは generator Kysely
の部分です。これは prisma-kysely
がDBマイグレーションを実施した際に kysely
用のDBの型ファイルを出力してくれるという定義です。今回は src/types.ts
という場所に出力されるという定義を書いています。
これで以下のコマンドからデータベースにマイグレーションをかけてテーブルを作成します。
$ npx prisma migrate dev
データベースコネクションの作成
作成したデータベースを接続するコードを書いていきます。接続するために更にライブラリが必要なのでインストールします。
$ npm install -D @types/pg
$ npm install pg
このインストールした pg
を使用して接続用のコードを書きます。
import type { DB } from './types'
import { Pool } from 'pg'
import { Kysely, PostgresDialect } from 'kysely'
export const connection = () => new Kysely<DB>({
dialect: new PostgresDialect({
pool: new Pool({
host: 'localhost',
database: 'postgres',
user: 'postgres',
password: 'password',
}),
}),
})
ここに1つ重要な意味を持つコードがあります。 Kysely
のREADMEを見ればわかりますが、これは少し違います。違いは以下です。
- export const connection = new Kysely<DB>({
+ export const connection = () => new Kysely<DB>({
私のコードは関数で定義し、呼び出す度に Kysely
のインスタンス化を行うコードになっています。これは意図があります。それはCloudflare WorkersからTCPで接続する場合、TCPはCloudflare Workersの1リクエスト単位に切断されてしまうため、データベースにはリクエスト単位に接続を行う必要があるからです。 Kysely
の接続で使用する pg
で Pool
というものを使用していますが、これはconnection poolingを行うものです。 Kysely
も pool
というものでしか受けれないのでこうなっています。connection poolingは簡単に説明すると1アプリケーションからデータベースへの接続を複数個定義し、アプリケーション内で接続を使い回すというものです。しかし、このconnection poolingはCloudflare Workersの1リクエスト単位にTCPの接続が切れてしまうという仕様と相性が悪く使えません。要は2回目のリクエストを受けた際に既に接続済みのコネクションオブジェクトをもらうが、Cloudflare WorkersのTCPは接続が切れているため、データベースに接続できずエラーとなります。そこで再接続するためにあえて Kysely
のインスタンス化を行うというコードにしています。
Cloudflare Workersで pg
を動作させるには以下の設定が必要です。
node_compat = true
これが無いとTCP接続できないので記述を足してください。 node_compat
が何かこちらに解説があるのでそちらを参照ください。 (thanks りぃさん)
GraphQL(スキーマ)の作成
最後にデータベースからデータを取得して、クライアントに返すGraphQLを作っていきます。スキーマ定義に必要なライブラリを追加で入れます。
$ npm install @pothos/plugin-simple-objects
後は Pothos
を使ってコードを以下のように書いていきます。
import SchemaBuilder from '@pothos/core'
import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'
export const builder = new SchemaBuilder({
plugins: [SimpleObjectsPlugin],
})
- import SchemaBuilder from '@pothos/core'
- const builder = new SchemaBuilder({})
+ import { builder } from './builder'
builder.queryType({
fields: (t) => ({
hello: t.string({
resolve: () => 'world',
}),
}),
})
+ export * from './models'
+ export * from './resolvers'
export const schema = builder.toSchema()
export * from './User'
import { builder } from '../builder'
export const UserType = builder.simpleObject('User', {
fields: (t) => ({
id: t.int(),
name: t.string({ nullable: true }),
email: t.string(),
}),
})
export * from './User'
export * from './query'
import { builder } from '../../builder'
import { UserType } from '../../models/User'
import { connection } from '../../context'
builder.queryFields((t) => ({
User: t.field({
type: UserType,
nullable: true,
resolve: async () => {
const db = connection()
await db.selectFrom('User').selectAll().executeTakeFirst()
},
}),
}))
最後の connection()
を見てくれればわかりますが、ここでデータベースとのコネクションを確立しています。実際のアプリケーションではresolver単位でコネクションを確立するのではなく、GraphQL-Yoga
の Context内でコネクションを確立して、各resolverでそれを使うというコードの方がよいです。(むやみにコネクション数を貼るとDBに負荷をかけます。ましてやCloudflare Workersのようなサーバレスだとオートスケールして、、、)
ただし、これだけだけもデータベースの接続という点では工夫をした方がいいです。いくら1リクエスト単位に接続を確立したとしてもCloudflare Workersのようなサーバレスでオートスケールしていくとコネクション数が膨れ上がる可能性があります。そうなるとデータベースに負荷がかかってしまい、最悪の場合はデータベースがダウンしてしまう可能性があります。そこでPostgreSQLならばPgBouncerのようなPostgreSQLの前に立てるconnection poolingを行うものを使用した方がいいです。ちなみにSupabaseならば標準で提供されているので接続はそちらを使うようにしましょう。
動作確認
これで完成したので最後にGraphQL Playgroundでクエリを投げてデータが返ってくれば問題ありません。
最後に
connection poolingの件で2回目リクエストが失敗するということに気づけず、やっぱ現実的にはまだダメかと思ってましたが、それをクリアすると普通にGraphQLとして動くのはすごい収穫でした。自身のサービスをこれに置き換えてみるいいきっかけになりました。
最後に書くといった Hono
ですが、使うならこんな感じでは動きます。うーん、GraphQL以外のルーティングが必要ならありかも。
import { createYoga } from 'graphql-yoga'
import { schema } from './schema'
import { Hono } from 'hono'
const app = new Hono()
// Create a Yoga instance with a GraphQL schema.
const yoga = createYoga({ schema })
if (process.env.NODE_ENV === 'development') {
app.get('/graphql', (c) => yoga(c.req.raw, {}))
}
app.post('/graphql', (c) => yoga(c.req.raw, {}))
export default app
Discussion