🚀

現状Cloudflare WorkersでGraphQLサーバを構築するならコレ

2023/05/26に公開

結論

  • 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での接続がサポートされたことにより状況は変わりました。

https://blog.cloudflare.com/workers-tcp-socket-api-connect-databases/

ただ、TCPで接続できるからと言って、エッジで動作しているアプリケーションがデータ取得のために別のロケーションのデータベースに接続すればその分のオーバヘッドがかかるわけなので、エッジというロケーションの利点は少し失われるなと思っています。なので今後出てくるであろうD1がそのロケーションの利点を最大限に発揮できるので期待して待っています。

ということで、Cloudflare WorkersもCloud Runに劣らず素晴らしいサービスなのでこれの上でバックエンドAPIサーバとして動けば最高ってことで実際に動かしていくというのが今回の記事の目的です。

アーキテクチャ

バックエンドAPIをGraphQLサーバとして準備するという前提で進めていきますが、何を使って構築するかというのを書いていきます。

Cloudfalre Workers

まず、前提はこれです。Cloudflare Workersというサービスないしランタイムを選択したことでアプリケーションに使用できる技術は狭まります。なので前提として書いておきます。

https://workers.cloudflare.com/

Web Server

Cloudflare Workersで動作するWeb Serverは結構限定されます。代表的なのは Hono ですが、今回はGraphQLサーバなので GraphQL Yoga を選択します。最後におまけで Hono も合わせて使用するならっていうコードは書いておきます。

https://the-guild.dev/graphql/yoga-server

https://hono.dev/

GraphQL schema builder

これは好みがあって「スキーマファースト」と「コードファースト」の2パターンがあります。私はどっちかというと「コードファースト」が好きで、コードからスキーマを自動生成させています。Prismaを使う際にお世話になって気に入ってる Pothos をそのまま選択しています。

https://pothos-graphql.dev/

ORM

残念ながらTCPで直接接続できるとなったCloudflare Workersでも Prisma は使えません。使えない理由は詳しくは書かないですが、以下のツイートで詳しく書いてるので、気になる方は読んでください。

https://twitter.com/ogawa0071/status/1658788985307807746

なので、Prisma はORMとしては使えません。残る選択肢としては Kyselydrizzle になるわけですが、どちらもD1で動かくことはできるので将来的に変えたいという場合でもどちらでも可能です。しかし、今回発表のあったTCP接続ができるのは pg というライブラリを使った接続なのでこれを使用して接続する Kysely を選択します。 drizzle も悪くないですがまだ少し安定には欠けるかなという印象です。
(正確性のため何度も説明する羽目になるが Kysely はORMではなく、クエリビルダーです)

https://github.com/kysely-org/kysely

DB migration

Kysely でも出来るのですが、データベースという気を使うものを扱うのでここは安定したものを使います。なのでマイグレーションには Prisma を使用します。 Prisma を使用するにはもう1つ理由があります。それは Kysely の型を自動生成するために Prisma を使用していると型を自動生成する prisma-kysely が使えるためでもあります。

https://www.prisma.io/

https://github.com/valtyr/prisma-kysely

GraphQLサーバの構築

ここから具体的にコードを書いていきます。ちなみに完成品は以下にあるので、サクッと完成品だけみたいという方はそこからコードなり、コミットを見ればいいと思います。(コードを書いてるコミットも6コミット程度なんでわかりやすいかと)

https://github.com/chimame/graphql-yoga-worker-with-pothos

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を持ったスキーマを作ります。

src/schema.ts
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のエンドポイントを作ります。

src/workers.ts
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 で初期のスキーマファイルを作ってもいいし、以下のスキーマをコピペで貼って作ってくれてもいいです。

prisma/schema.prisma
// 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 を使用して接続用のコードを書きます。

src/context.ts
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 の接続で使用する pgPool というものを使用していますが、これはconnection poolingを行うものです。 Kyselypool というものでしか受けれないのでこうなっています。connection poolingは簡単に説明すると1アプリケーションからデータベースへの接続を複数個定義し、アプリケーション内で接続を使い回すというものです。しかし、このconnection poolingはCloudflare Workersの1リクエスト単位にTCPの接続が切れてしまうという仕様と相性が悪く使えません。要は2回目のリクエストを受けた際に既に接続済みのコネクションオブジェクトをもらうが、Cloudflare WorkersのTCPは接続が切れているため、データベースに接続できずエラーとなります。そこで再接続するためにあえて Kysely のインスタンス化を行うというコードにしています。

https://developers.cloudflare.com/workers/databases/connect-to-postgres/#connection-pooling--startup

Cloudflare Workersで pg を動作させるには以下の設定が必要です。

wrangler.toml
node_compat = true

これが無いとTCP接続できないので記述を足してください。 node_compat が何かこちらに解説があるのでそちらを参照ください。 (thanks りぃさん)

https://lealog.hateblo.jp/entry/2023/05/09/120122

GraphQL(スキーマ)の作成

最後にデータベースからデータを取得して、クライアントに返すGraphQLを作っていきます。スキーマ定義に必要なライブラリを追加で入れます。

$ npm install @pothos/plugin-simple-objects

後は Pothos を使ってコードを以下のように書いていきます。

src/builder.ts
import SchemaBuilder from '@pothos/core'
import SimpleObjectsPlugin from '@pothos/plugin-simple-objects'

export const builder = new SchemaBuilder({
  plugins: [SimpleObjectsPlugin],
})
src/schema.ts
- 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()
src/models/index.ts
export * from './User'
src/models/User.ts
import { builder } from '../builder'

export const UserType = builder.simpleObject('User', {
  fields: (t) => ({
    id: t.int(),
    name: t.string({ nullable: true }),
    email: t.string(),
  }),
})
src/resolvers/index.ts
export * from './User'
src/resolvers/User/index.ts
export * from './query'
src/resolvers/User/query.ts
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ならば標準で提供されているので接続はそちらを使うようにしましょう。

https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool

動作確認

これで完成したので最後にGraphQL Playgroundでクエリを投げてデータが返ってくれば問題ありません。

最後に

connection poolingの件で2回目リクエストが失敗するということに気づけず、やっぱ現実的にはまだダメかと思ってましたが、それをクリアすると普通にGraphQLとして動くのはすごい収穫でした。自身のサービスをこれに置き換えてみるいいきっかけになりました。

最後に書くといった Hono ですが、使うならこんな感じでは動きます。うーん、GraphQL以外のルーティングが必要ならありかも。

src/worker.ts
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