🏭

Cloudflare D1 を Hono + Drizzleで触ってみる 〜Zod による型安全なバリデーションを添えて〜

2024/12/11に公開

はじめに

こんにちは。しんりうです。

個人開発や 新規プロダクト開発を始める際、データベースの選定は特に重要な意思決定の 1 つです。選定にはあたってはコスト、運用の手間、パフォーマンスなど、考慮すべき点が多くあります。

また、現在私が取り組んでいるプロジェクトでは、Cloudflare のサービスを積極的に活用しています。
具体的には DNS 管理、Web アプリのホスティング、オブジェクトストレージの管理などを利用しており、さまざまな用途で Cloudflare のエコシステムの恩恵を受けています。

この状況を踏まえ、Cloudflare D1 は有効な選択肢の 1 つだと考えました。
本記事では、D1 とそれを取り巻くモダンな開発スタック(Hono + Drizzle)について、実装を交えながら紹介したいと思います。そして、実装例では Zod を用いた型安全なバリデーションも併せて紹介します。

Cloudflare D1 について

D1 とはなにか

一言で表すと、「Cloudflare が提供する世界中のエッジで動くシンプルで高速な SQLite データベース」です。
そのため、Cloudflare Workers/Pages/R2 など 同社のエコシステムとの親和性が高いことが強みです。

D1 は 2024 年4月に GA(Generally Available) しており、今後活用ケースが増えていくのではないかと思います。


https://www.cloudflare.com/ja-jp/developer-platform/products/d1/

D1 の特徴

特に「シンプル」、「速い」、「低コスト」である点が注目されています。

シンプル

  • SQLite ベースなので学習コストが低く、既存のドライバや ORM が活用できる
  • wrangler コマンド一つで環境を構築できる[1]

速い

  • ユーザー近くのエッジで動作するためレイテンシを抑えられる
  • Cloudflare のグローバルネットワークを活用した効率的なルーティング[2]

低コスト

  • 無料で 5GB のストレージと月間 500 万クエリ/日まで利用でき、商用利用も可能
  • 追加コストもストレージ(1GB/$0.75)とクエリ(250 億回/$0.50)のシンプルな従量課金制 [3]

Hono と Drizzle について

Web アプリケーションから D1 を操作するため、今回はエッジファーストな設計思想を持つ Hono と Drizzle を使用します。
それぞれについて簡単にご紹介します。

Hono とは

今とてもアツくて盛り上がっている Hono [炎] です。
軽量で高速な、 マルチランタイムで動作する Typescript 製 Web アプリケーションフレームワークです。

Node.js 依存の API ではなく Web 標準 API のみを使用しているため、ランタイムに依存しません。元々 Hono は Cloudflare Workers に特化した フレームワークとして開発されたという背景があるため、D1 とも相性抜群です [4]

また、文法も Express ライクになっているため、最初の敷居も高くありません。

作者が日本人ということもあり国内外でコミュニティが盛り上がっているため、さまざまな所で紹介されています。
本記事ではこれ以上の説明は省略しますが、興味のある方はぜひ公式ドキュメントやコミュニティでの情報を参照してください。

https://hono.dev/

Drizzle とは

SQL ライクにもリレーショナルにも書くことができる 軽量で高速な Typescripft 製 ORM[5] です。
SQL ライクとはどういうことかというと、次のような記述で DB を操作できるということです。

index.ts
await db
  .select()
  .from(countries)
  .leftJoin(cities, eq(cities.countryId, countries.id))
  .where(eq(countries.id, 10));

また、一般的な ORM と同じくリレーショナルに書くこともできます。

index.ts
const result = await db.query.users.findMany({
  with: {
    posts: true,
  },
});

https://orm.drizzle.team/

公式 docs にパフォーマンステストの結果もあるため、ぜひご覧ください。

実装

実際にハンズオンで実装していきたいと思います。
今回は便宜上、次のような順番で進めます。

  1. Hono のセットアップ
  2. D1 SQL Database の作成
  3. Drizzle のセットアップ
  4. マイグレーション
  5. Hono から D1 への接続
  6. CRUD 操作の実装 w/ Zod
  7. Cloudflare Workers へデプロイ

なお、動作環境については Node.js バージョンが 21.2.0、各ライブラリのバージョンは以下のとおりです。

"hono": "^4.6.12",
"wrangler": "^3.88.0",
"drizzle-orm": "^0.37.0"
"drizzle-kit": "^0.29.1"
"@hono/zod-validator": "^0.4.1"
"drizzle-zod": "^0.5.1"
"zod": "^3.23.8"

ソースコードはこちらに公開しています。
https://github.com/shinryuzz/d1-hono-drizzle-zod-sample

Hono のセットアップ

Hono プロジェクトをcreate-hono コマンドで作成します。
※ template として cloudflare-workers を選んでいる点に注意してください。

$ npx create-hono <任意のプロジェクト名>

> create-hono version 0.14.3
✔ Using target directory … .
? Which template do you want to use? cloudflare-workers
? Do you want to install project dependencies? yes
? Which package manager do you want to use? pnpm
✔ Cloning the template
✔ Installing project dependencies
🎉 Copied project files

create-hono が成功すると、 src/index.ts に以下のようなコードが生成されていると思います。

src/index.ts
import { Hono } from "hono";

const app = new Hono();

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

export default app;

ターミナルから pnpm run dev コマンド等でローカルサーバーを立ち上げ、動作確認をしてみます。

$ curl -X GET "http://localhost:8787"
> Hello Hono!

D1 SQL Database の作成

Cloudflare のコンソール画面から作成することもできますが、今回は wrangler コマンドで作成します。

$ npx wrangler d1 create <任意のデータベース名>

 ⛅️ wrangler 3.92.0
-------------------

✅ Successfully created DB <任意のデータベース名> in region WNAM
Created your new D1 database.

[[d1_databases]]
binding = "DB"
database_name = "<任意のデータベース名>"
database_id = "xxxx-xxxx-xxxx-xxxx"

次に、Cloudflare Workers が D1 に接続できるようバインディングを作成する必要があります。
上記のデータベース情報を元に、wrangler.toml を以下のように更新してください。

wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "<任意のデータベース名>"
database_id = "xxxx-xxxx-xxxx-xxxx"

うまく設定できている状態で dev サーバーを立ち上げると、バインディングされている旨が表示されます。

$ pnpm run dev

> wrangler dev

 ⛅️ wrangler 3.92.0
-------------------

Your worker has access to the following bindings:
- D1 Databases:
  - DB: <任意のデータベース名> (xxxx-xxxx-xxxx-xxxx) (local)
⎔ Starting local server...
[wrangler:inf] Ready on http://localhost:8787

Drizzle のセットアップ

インストール

まずは 2 つのパッケージをインストールします。

  • drizzle-orm: データベースを操作するための API を提供してくれるコアパッケージ
  • drizzle-kit: マイグレーションの実行などスキーマ管理を行うツールキット

Drizzle は コアパッケージと開発ツールを完全に分離することで、本番環境でのバンドルサイズを軽量に保ち、エッジでの動作を最適化できるという特徴があります。
これは例えば Prisma のようにスキーマからクライアントコードを生成・バンドルする方式と比較して、大きな利点といえます。

$ pnpm add drizzle-orm
$ pnpm add -D drizzle-kit

スキーマの定義

テーブル構造を schema.ts に定義します。
今回は id, title, isCompleted, updatedAt, createdAt をカラムとするシンプルな TODO テーブルを作成します。
タイムスタンプのデフォルト値はこちらを参考にしました。

drizzle/schema.ts
import { sql } from "drizzle-orm";
import { sqliteTable, integer, text } from "drizzle-orm/sqlite-core";

export const todos = sqliteTable("todos", {
  id: text("id").primaryKey().notNull(),
  title: text("title").notNull(),
  isCompleted: integer("is_completed", { mode: "boolean" }).default(false),
  updatedAt: text("updated_at")
    .notNull()
    .default(sql`(current_timestamp)`)
    .$onUpdateFn(() => sql`(current_timestamp)`),
  createdAt: text("created_at")
    .notNull()
    .default(sql`(current_timestamp)`),
});

公式 docs に Example が載っているため、そちらも参考にされてください。

コンフィグファイルの作成

ここでは参照するスキーマファイル、マイグレーションファイルの出力先、ドライバ、DB の種類、機密情報などを設定します。

drizzle/drizzle.config.ts
import type { Config } from "drizzle-kit";

export default {
  schema: "./drizzle/schema.ts",
  out: "./drizzle/migrations",
  driver: "d1-http",
  dialect: "sqlite",
  dbCredentials: {
    accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
    databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
    token: process.env.CLOUDFLARE_D1_TOKEN!,
  },
} satisfies Config;

機密情報は Cloudflare ダッシュボードから取得することができます。
詳細は下記リンクを参考にされてください。
https://orm.drizzle.team/docs/guides/d1-http-with-drizzle-kit

マイグレーション

マイグレーションファイルの生成は以下コマンドになります。

$ npx drizzle-kit generate

1 tables
todos 4 columns 0 indexes 0 fks

[] Your SQL migration file ➜ drizzle/migrations/0000_blushing_namorita.sql 🚀

マイグレーションの実行は以下コマンドになります。

$ npx drizzle-kit migrate

> [] migrations applied successfully!

ただし、ローカル環境の DB をマイグレーションする場合、Drizzle Kit ではそのような機能が提供されていません。そのため、wrangler コマンドを使う形になります。

$ npx wrangler d1 execute <任意のデータベース名> --file=./drizzle/migrations/<任意のマイグレーションファイル>.sql --local

Drizzle Studio で DB の中身を確認

Drizzle は GUI 上で DB を操作できるツールを提供しています。
D1 のベースとなっている SQLite はファイルベースのデータベースであり、MySQL や PostgreSQL のようなサーバー機能を持ちません。そのため、通常は DB クライアントツールからのリモート接続や DB 操作は困難です。
しかし Drizzle Studio を用いればこの問題を克服し、リモートで直感的な DB 操作を行うことができます [6]

以下のコマンドで立ち上げることができます。
試しに todos テーブルにレコードを追加してみてください。

$ npx dirzzle-kit studio

Hono から D1 への接続

マイグレーションが成功したら、次は実際に Hono アプリケーション上で Drizzle ORM を介して D1 に接続してみます。D1 へのアクセスは Hono のコンテキストを介して行うことができます。

上述したwrangler.tomlで バインディングが設定されていることを確認の上で、src/index.ts を以下のように修正してください。

src/index.ts
import { Hono } from "hono";
+ import { drizzle } from "drizzle-orm/d1";
+ import { todos } from "../drizzle/schema";

+ type Bindings = {
+   DB: D1Database;
+ };

- const app = new Hono();
+ const app = new Hono<{ Bindings: Bindings }>().basePath("api");

app.get("/", (c) => {
  return c.text("Hello Hono!");
});

+ app.get("/todos", async (c) => {
+   const db = drizzle(c.env.DB);
+   const allTodos = await db.select().from(todos).all();
+
+   return c.json(allTodos, 200);
+ });

再度ローカルでサーバーを立ち上げて動作確認をしてみます。
ただし、ここでデフォルトではローカルの DB に接続されるようになっています。
リモートの DB に切り替えたい場合はコンソールでの出力メッセージにあるように、Lキーを押して切り替えてください

todos レコードが存在する場合、次のような結果を得ることができます。

$ curl -X GET "http://localhost:8787/api/todos" | jq

[
  {
    "id": "02a087ac-518d-48a6-8042-ef7769a9b4cc",
    "title": "task 1",
    "isCompleted": false,
    "updatedAt": "2024-12-06 05:13:36",
    "createdAt": "2024-12-06 01:18:53"
  },
  {
    "id": "05402ca9-1084-49ee-8983-1eea38e5942b",
    "title": "task 2",
    "isCompleted": true,
    "updatedAt": "2024-12-06 03:05:21",
    "createdAt": "2024-12-06 02:53:42"
  },
]

簡単な CRUD 操作 w/ Zod

追加で CRUD 操作を実装します。
また、ついでにzod, drizzle-zod, @hono/zod-validator を用いたバリデーションも追加してみます
Drizzle で定義したスキーマをもとにバリデーションをかけられるのがかなり強いです。

Zod 周りのインストール

まずは 関連パッケージをインストールします。

$ pnpm add zod drizzle-zod @hono/zod-validator

Zod スキーマの定義

次に src/types/todo.tszod, drizzle-zod を用いてバリデーションスキーマを定義します。
createInsertSchema() で Drizzle で定義したスキーマをもとに Zod スキーマを生成し、pick() で各リクエストに必要なフィールドを抽出します。

src/types/todo.ts
import { z } from "zod";
import { todos } from "../../drizzle/schema";
import { createInsertSchema } from "drizzle-zod";

const insertTodoSchema = createInsertSchema(todos, {
  id: z.string().uuid(),
  title: z
    .string()
    .min(1, "タイトルは必須です")
    .max(100, "タイトルは100文字以内にしてください"),
  isCompleted: z.boolean(),
});

export const createTodoSchema = insertTodoSchema.pick({ title: true });
export const updateTodoSchema = insertTodoSchema.pick({
  title: true,
  isCompleted: true,
});
export const todoIdSchema = insertTodoSchema.pick({ id: true });

エンドポイント の追加

そして、src/indext.ts に上で定義したスキーマと @hono/zod-validator でバリデーションを行いつつ、追加のエンドポイントを生やします。
zValidator() ミドルウェアによリバリデーションを行い、c.req.valid() で型安全に取り出すことができます。

src/index.ts
import { todos } from "../drizzle/schema";
import { createTodoSchema, todoIdSchema, updateTodoSchema } from "./types/todo";
import { eq } from "drizzle-orm";
import { zValidator } from "@hono/zod-validator";

~ 中略 ~

// Create TODO
app.post("/todos", zValidator("json", createTodoSchema), async (c) => {
  const { title } = await c.req.valid("json");
  const db = drizzle(c.env.DB);
  const id = crypto.randomUUID();
  const newTodo = await db
    .insert(todos)
    .values({ id, title })
    .returning()
    .get();

  return c.json(newTodo, 201);
});

// Read Single TODO
app.get("/todos/:id", zValidator("param", todoIdSchema), async (c) => {
  const db = drizzle(c.env.DB);
  const id = c.req.valid("param").id;
  const todo = await db.select().from(todos).where(eq(todos.id, id)).get();

  if (!todo) {
    return c.text("Not Found", 404);
  }

  return c.json(todo, 200);
});

// Update TODO
app.put(
  "/todos/:id",
  zValidator("param", todoIdSchema),
  zValidator("json", updateTodoSchema),
  async (c) => {
    const db = drizzle(c.env.DB);
    const id = c.req.param("id");
    const body = await c.req.valid("json");

    const updatedTodo = await db
      .update(todos)
      .set(body)
      .where(eq(todos.id, id))
      .returning()
      .get();

    if (!updatedTodo) {
      return c.text("Not Found", 404);
    }

    return c.json(updatedTodo, 200);
  }
);

// Delete TODO
app.delete("/todos/:id", zValidator("param", todoIdSchema), async (c) => {
  const db = drizzle(c.env.DB);
  const id = c.req.valid("param").id;

  const deletedTodo = await db
    .delete(todos)
    .where(eq(todos.id, id))
    .returning()
    .get();

  if (!deletedTodo) {
    return c.text("Not Found", 404);
  }

  return c.newResponse(null, { status: 204 });
});

export default app;

Cloudflare Workers へデプロイ

最後に Cloudflare Workers にデプロイします。
ぜひ実際に発行されたエンドポイントを叩いて動作確認をしてみてください。

$ pnpm run deploy

 ⛅️ wrangler 3.92.0
-------------------

Total Upload: 89.48 KiB / gzip: 27.23 KiB
Worker Startup Time: 2 ms
Your worker has access to the following bindings:
- D1 Databases:
  - DB: <データベース名> (<データベースID>)
Uploaded <プロジェクト名> (2.82 sec)
Deployed <プロジェクト名> triggers (0.48 sec)
  https://<プロジェクト名>.<ユーザー名>.workers.dev
Current Version ID: yyyy-yyyy-yyyy-yyyy

おわりに

今回は サーバーレス SQL データーベースな Cloudflare D1 を、 エッジ動作に最適な Web アプリケーションフレームワーク Hono と ORM ライブラリ Drizzle を使って触ってみました。
また、CRUD 操作の実装では Zod による型安全なバリデーションもご紹介しました。

本来 D1 の紹介を主軸にするつもりでしたが、実装内容の都合などにより Drizzle, Zod 等に関する情報も増し増しになってしまいました。
もしどなたかの参考になれば幸いです。

また、DeNA 25 新卒 Advent Calendar 2024 の他の記事もぜひご覧ください。
ここまで読んでいただきありがとうございました。

脚注
  1. 実行には Cloudflare アカウントの認証が必要です。 ↩︎

  2. 現在 D1 は SQLite のスナップショット分離によって一貫性を実現しています。リードレプリカを用意しているわけではありませんが、Cloudflare のネットワークインフラを活用することで効率的なアクセスを可能にしています。(https://blog.cloudflare.com/ja-jp/building-d1-a-global-database/) ↩︎

  3. 2024 年 12 月時点での料金です。詳細は公式 docsをご参照ください。 ↩︎

  4. 実は D1 の内部でも Hono が使われているそうです。 ↩︎

  5. 別の TS 製 ORM として Prisma も候補に上がると思いますがD1 の Prisma サポートは現在プレビュー版となっています。そのため採用を見送りました。 ↩︎

  6. ただし Drizzle Studio でローカルの DB に接続するには、ファイルパスの指定などひと工夫必要になります。筆者はその代わりに TablePlus という GUI ツールを使っています。 ↩︎

Discussion