Hono + Drizzle ORM + Cloudflare D1:边缘 API 数据库实践
Hono + Drizzle ORM + Cloudflare D1:边缘 API 数据库实践
Hono 和 D1 是很自然的一组搭配。
Hono 负责轻量 API。
D1 负责结构化数据。
Drizzle ORM 负责让 SQL schema、查询和 TypeScript 类型保持一致。
这套组合适合:
1 2 3 4 5 6 7
| 个人项目 小型 SaaS 轻量后台 博客评论系统 Webhook 数据记录 边缘 API Cloudflare 全家桶项目
|
但也要先说清楚:D1 不是“无限大的云数据库”。它是 Cloudflare 托管的 serverless SQL 数据库,语义接近 SQLite,非常适合中小型业务和边缘应用。高频写入、大型复杂事务、重型分析查询,不一定是它最舒服的场景。
技术栈快照时间:2026-06-23。D1、Wrangler、Drizzle D1 适配层更新较快,实际项目以 Cloudflare 和 Drizzle 官方文档为准。
一、项目结构
推荐结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| hono-d1-drizzle/ ├── migrations/ ├── src/ │ ├── db/ │ │ ├── schema.ts │ │ └── index.ts │ ├── repositories/ │ │ └── todo-repository.ts │ ├── routes/ │ │ └── todos.ts │ └── index.ts ├── drizzle.config.ts ├── package.json └── wrangler.jsonc
|
不要把所有东西都写在 src/index.ts。
Hono 很轻,但项目一复杂,还是要拆:
1 2 3 4
| schema 数据库表结构 repository 数据查询 route HTTP 输入输出 index 应用入口和中间件
|
二、安装依赖
1 2
| pnpm add hono drizzle-orm pnpm add -D drizzle-kit wrangler typescript
|
创建 D1 数据库:
1
| pnpm exec wrangler d1 create hono_orm_demo
|
创建后,Wrangler 会输出 database_id,写入 wrangler.jsonc:
1 2 3 4 5 6 7 8 9 10 11 12 13
| { "name": "hono-orm-demo", "main": "src/index.ts", "compatibility_date": "2026-06-23", "d1_databases": [ { "binding": "DB", "database_name": "hono_orm_demo", "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "migrations_dir": "migrations" } ] }
|
Hono 里给 binding 加类型:
1 2 3
| type Bindings = { DB: D1Database }
|
后面就能通过 c.env.DB 访问 D1。
三、定义 Drizzle schema
写 src/db/schema.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' import { sql } from 'drizzle-orm'
export const todos = sqliteTable('todos', { id: integer('id').primaryKey({ autoIncrement: true }), title: text('title').notNull(), completed: integer('completed', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), updatedAt: text('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`), })
export type Todo = typeof todos.$inferSelect export type NewTodo = typeof todos.$inferInsert
|
D1 用 SQLite 语义,所以 Drizzle 这里使用 sqlite-core。
有一个小细节:SQLite 没有传统意义上的 boolean 类型,通常用 integer 存 0/1。Drizzle 的 { mode: 'boolean' } 可以让 TypeScript 层更舒服。
四、配置 Drizzle Kit
写 drizzle.config.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import type { Config } from 'drizzle-kit'
export default { schema: './src/db/schema.ts', out: './migrations', dialect: 'sqlite', driver: 'd1-http', dbCredentials: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID!, databaseId: process.env.D1_DATABASE_ID!, token: process.env.CLOUDFLARE_D1_TOKEN!, }, } satisfies Config
|
生成迁移:
1
| pnpm exec drizzle-kit generate
|
应用迁移:
1 2
| pnpm exec wrangler d1 migrations apply hono_orm_demo --local pnpm exec wrangler d1 migrations apply hono_orm_demo --remote
|
我更喜欢让 Drizzle 负责生成 SQL,让 Wrangler 负责应用到 D1。
这样你能保留 D1 自己的 migration tracking,也能保持 schema 来源清楚。
五、创建 db 实例
写 src/db/index.ts:
1 2 3 4 5 6 7 8
| import { drizzle } from 'drizzle-orm/d1' import * as schema from './schema'
export function createDb(d1: D1Database) { return drizzle(d1, { schema }) }
export type Db = ReturnType<typeof createDb>
|
不要在模块顶层创建 db。
因为 D1 binding 来自每次请求的 c.env.DB,你应该在请求上下文里创建:
1
| const db = createDb(c.env.DB)
|
六、Repository 层
写 src/repositories/todo-repository.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import { desc, eq } from 'drizzle-orm' import type { Db } from '../db' import { todos } from '../db/schema'
export function createTodoRepository(db: Db) { return { async list() { return db .select() .from(todos) .orderBy(desc(todos.id)) .limit(100) },
async create(title: string) { const result = await db .insert(todos) .values({ title }) .returning()
return result[0] },
async toggle(id: number) { const row = await db .select() .from(todos) .where(eq(todos.id, id)) .limit(1)
const todo = row[0] if (!todo) return null
const result = await db .update(todos) .set({ completed: !todo.completed, }) .where(eq(todos.id, id)) .returning()
return result[0] },
async remove(id: number) { await db.delete(todos).where(eq(todos.id, id)) }, } }
|
route 不应该知道 Drizzle 查询细节。
Drizzle 待在 repository 层,Hono 待在 route 层。边界清楚,后面会舒服很多。
七、Hono 路由
写 src/routes/todos.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| import { Hono } from 'hono' import { z } from 'zod' import { zValidator } from '@hono/zod-validator' import { createDb } from '../db' import { createTodoRepository } from '../repositories/todo-repository'
type Bindings = { DB: D1Database }
const createTodoSchema = z.object({ title: z.string().min(1).max(100), })
const todosRoute = new Hono<{ Bindings: Bindings }>()
todosRoute.get('/', async (c) => { const db = createDb(c.env.DB) const repo = createTodoRepository(db) const items = await repo.list()
return c.json({ data: { items, }, }) })
todosRoute.post( '/', zValidator('json', createTodoSchema), async (c) => { const input = c.req.valid('json') const db = createDb(c.env.DB) const repo = createTodoRepository(db) const todo = await repo.create(input.title)
return c.json({ data: todo }, 201) } )
todosRoute.patch('/:id/toggle', async (c) => { const id = Number(c.req.param('id'))
if (!Number.isInteger(id)) { return c.json({ error: { code: 'INVALID_ID', message: '无效的 Todo ID', }, }, 400) }
const db = createDb(c.env.DB) const repo = createTodoRepository(db) const todo = await repo.toggle(id)
if (!todo) { return c.json({ error: { code: 'TODO_NOT_FOUND', message: 'Todo 不存在', }, }, 404) }
return c.json({ data: todo }) })
todosRoute.delete('/:id', async (c) => { const id = Number(c.req.param('id')) const db = createDb(c.env.DB) const repo = createTodoRepository(db)
await repo.remove(id)
return c.body(null, 204) })
export default todosRoute
|
主入口 src/index.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import { Hono } from 'hono' import todosRoute from './routes/todos'
type Bindings = { DB: D1Database }
const app = new Hono<{ Bindings: Bindings }>()
app.get('/api/health', (c) => c.json({ ok: true })) app.route('/api/todos', todosRoute)
export default app
|
八、D1 的几个关键注意点
第一,D1 适合中小型结构化数据。
它很适合 Todo、用户配置、评论、轻量订单、后台表单、Webhook 记录。不要一开始就拿它承载超高写入、复杂分析和大型事务。
第二,分页不要无限 OFFSET。
小数据量 OFFSET 没问题,但数据变大后建议改 cursor pagination:
1 2 3 4
| SELECT * FROM todos WHERE id < ? ORDER BY id DESC LIMIT 20;
|
第三,索引要跟查询走。
如果你常查:
1 2
| WHERE user_id = ? AND completed = ? ORDER BY created_at DESC
|
就应该考虑组合索引:
1 2
| CREATE INDEX idx_todos_user_completed_created ON todos(user_id, completed, created_at);
|
第四,避免 N+1 查询。
能 JOIN 就 JOIN。D1 也是 SQL 数据库,不要在循环里一条条查。
九、什么时候不要用这套
Hono + Drizzle + D1 不适合所有项目。
不建议的场景:
1 2 3 4 5
| 大型复杂事务系统 强依赖传统数据库连接池 大量长时间分析查询 超高频写入 需要 PostgreSQL 特有能力
|
这些场景更适合 PostgreSQL / MySQL,或者 Cloudflare Hyperdrive 连接外部数据库。
D1 的优势是轻、近、少运维。
不要把它当成传统数据库的完全替代品。
十、最后的建议
如果你的 Hono 项目部署在 Cloudflare Workers,并且数据库规模不大:
1
| Drizzle + D1 是非常值得优先考虑的组合。
|
项目结构上保持:
1
| Hono route -> service/repository -> Drizzle -> D1
|
迁移上保持:
1
| schema.ts -> drizzle-kit generate -> wrangler d1 migrations apply
|
生产上记住:
1 2 3 4 5
| 限制结果集 加索引 避免 N+1 做好备份和迁移 不要把 D1 当无限数据库
|
这样写,Hono API 会很轻,数据库代码也不会失控。
参考资料