😽

Hono + Drizzle + Cloudflare D1の構成を試してみた

2024/03/20に公開

HonoとDrizzleを使ってCloudflare D1にアクセスする構成を試してみました。この記事では、マイグレーションからデプロイまでの手順を共有します。

手順

プロジェクトの作成

npm create hono@latest hono-drizzle

今回はcloudflare-pagesを選択します。

インストール

Drizzleのパッケージをインストールします。

npm i drizzle-orm
npm i -D drizzle-kit

バリデータとしてZodとZodのプラグインをインストールします。

npm i zod @hono/zod-validator

DBの作成

d1にDBを作成します。

npx wrangler d1 create hono-drizzle

出力されたdbの接続情報をwrangler.tomlに追加します。

drizzleの設定

drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
  schema: "./src/schema/*",
  out: "./migrations",
  driver: "d1",
  dbCredentials: {
    wranglerConfigPath: "./wrangler.toml",
    dbName: "hono-drizzle"
  }
} satisfies Config;

DBスキーマの作成

今回はユーザーの

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

export const users = sqliteTable("users", {
  id: text("id").primaryKey().notNull(),
  displayId: text("displayId").notNull(),
  name: text("name").notNull(),
});

マイグレーションファイルの作成

以下のコマンドでマイグレーションファイルを生成します。

npx drizzle-kit generate:sqlite

マイグレーションの実施

以下のコマンドでマイグレーションを実施します。

npx wrangler d1 migrations apply hono-drizzle

アプリケーションコードの作成

続いて、CRUDを実装していきます。

型定義

src/types/users.ts
import { z } from "zod";

export const UserName = z.string()
  .min(3, "ユーザー名は3文字以上の必要があります。")
  .max(32, "ユーザー名は32文字以下の必要があります。");
export type UserName = z.infer<typeof UserName>;

export const DisplayId = z.string()
  .min(3, "ユーザー名は3文字以上の必要があります。")
  .max(32, "ユーザー名は32文字以下の必要があります。")
  .regex(/^[a-zA-Z0-9-]+$/, "ユーザー名は英数字とハイフンのみが使用できます。");
export type DisplayId = z.infer<typeof DisplayId>;

Create

src/api/users/createUser.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { drizzle } from "drizzle-orm/d1";
import { users } from "../../schema/users";
import { nanoid } from "nanoid/non-secure";
import { DisplayId, UserName } from "../../types/users";

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();

app.post ("/", zValidator(
  "json",
  z.object({
    displayId: DisplayId,
    name: UserName,
  }),
), async (c) => {
  const db = drizzle(c.env.DB);
  const { displayId, name } = c.req.valid("json");
  const id = nanoid();
  try {
    await db.insert(users).values({ id, displayId, name });
    return c.body("", 201);
  } catch (e) {
    if (e instanceof Error) {
      const uniqueConstraintDisplayIdError = "D1_ERROR: UNIQUE constraint failed: users.displayId";
      if (e.message === uniqueConstraintDisplayIdError) {
        return c.json({
          error: "このidはすでに使用されています"
        }, 409);
      }
      console.error({ message: "エラー", errorMessage: e.message });
      return c.body("", 500);    
    }
    console.error({ message: "不明なエラー" });
    return c.body("", 500);
  }
});

export default app;

Read

src/api/users/findUser.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { drizzle } from "drizzle-orm/d1";
import { users } from "../../schema/users";
import { eq } from "drizzle-orm/sqlite-core/expressions";
import { DisplayId } from "../../types/users";

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();

app.get(
  "/:displayId",
  zValidator(
    "param",
    z.object({
      displayId: DisplayId,
    })
  ),
  async (c) => {
    try {
      const db = drizzle(c.env.DB);
      const displayId = c.req.valid("param").displayId;
      const result = await db.select().from(users).where(eq(users.displayId, displayId));
      const selectedUser = result.at(0);
      if ( selectedUser === undefined) {
        return c.body("", 404);
      }
      return c.json({ user: {
        displayId: selectedUser.displayId,
        name: selectedUser.name,
      }});
    } catch (e) {
      if (e instanceof Error) {
        console.error({ message: "エラー", errorMessage: e.message });
        return c.body("", 500);
      }
      console.error({ message: "不明なエラー" });
      return c.body("", 500);
    }
  }
);

export default app;

Update

src/api/users/patch.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { drizzle } from "drizzle-orm/d1";
import { users } from "../../schema/users";
import { eq } from "drizzle-orm/sqlite-core/expressions";
import { DisplayId, UserName } from "../../types/users";

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();

app.put(
  "/:displayId",
  zValidator(
    "param",
    z.object({
      displayId: DisplayId,
    })
  ),
  zValidator(
    "json",
    z.object({
      displayId: DisplayId,
      name: UserName
    })
  ),
  async (c) => {
    try {
      const db = drizzle(c.env.DB);
      const displayId = c.req.valid("param").displayId;
      const newData = c.req.valid("json");
      console.log(newData);
      const result = await db.update(users).set({
        displayId: newData.displayId,
        name: newData.name
      }).where(eq(users.displayId, displayId));
      if (result.meta.changes === 0) {
        console.error({ message: "更新対象が見つかりませんでした", displayId: displayId });
        return c.body(null, 404);
      }
      console.log({isSuccess: result.success, changes: result.meta.changes, data: JSON.stringify(result)});
      return c.body(null, 204);
    } catch (e) {
      if (e instanceof Error) {
        console.error({ message: "エラー", errorMessage: e.message, obj: JSON.stringify(e) });
        if (e.message === "D1_ERROR: UNIQUE constraint failed: users.displayId") {
          return c.json({error: "このidはすでに使用されています"}, 409);
        }
        return c.body(null, 500);
      }
      console.error({ message: "不明なエラー" });
      return c.body(null, 500);
    }
  }
);

export default app;

Delete

src/api/users/delete.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { drizzle } from "drizzle-orm/d1";
import { users } from "../../schema/users";
import { eq } from "drizzle-orm/sqlite-core/expressions";
import { DisplayId } from "../../types/users";

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();

app.delete(
  "/:id",
  zValidator(
    "param",
    z.object({
      id: DisplayId,
    })
  ),
  async (c) => {
    try {
      const db = drizzle(c.env.DB);
      const displayId = c.req.valid("param").id;
      const result = await db.delete(users).where(eq(users.displayId, displayId));
      console.log({isSuccess: result.success, changes: result.meta.changes, data: JSON.stringify(result)});
      if (result.meta.changes === 0) {
        console.error({ message: "削除対象が見つかりませんでした", displayId: displayId });
        return c.body(null, 404);
      }
      return c.body(null, 204);
    } catch (e) {
      if (e instanceof Error) {
        console.error({ message: "エラー", errorMessage: e.message, obj: JSON.stringify(e) });
        return c.body(null, 500);
      }
      console.error({ message: "不明なエラー" });
      return c.body(null, 500);
    }
  }
);

export default app;

ルーティング

作成したハンドラーをルーティングに設定します。

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


type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();
app.use(logger());

app.route("/api", api);

app.get("/health", (c) => {
  return c.text("OK");
});

export default app;
src/api/index.ts
import { Hono } from "hono";
import users from "./users";

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();
app.route("/users", users);

export default app;
src/api/users/index.ts
import findUser from "./findUser";
import createUser from "./createUser";
import updateUser from "./updateUser";
import deleteUser from "./deleteUser";
import { Hono } from "hono";

type Bindings = {
  DB: D1Database;
};

const app = new Hono<{ Bindings: Bindings }>();
app.route("/", findUser);
app.route("/", createUser);
app.route("/", updateUser);
app.route("/", deleteUser);

export default app;

デプロイ

npm run deply

動作確認

リクエストをして動作を確認します。Readに関しては100msぐらいで、Writeに関しては150msから200msぐらいでレスポンスが返ってきました😊

感想

ORMが使えると、クエリごとに型定義をしなくてもよくなるのは好印象です。
一方で、Drizzleを使う必要がある煩わしさはあるかもしれません。UNIQUE制約のエラーハンドリングに関しはあまり綺麗ではないので、ほかに良い方法があればなと思います。Prismaを試した方がいい?
また、Cloudflare D1のマイグレーションにはリバート機能が無いのもこれからの期待だな~と思います。

コラボスタイル Developers

Discussion