Schemas层设计
要点
- 前面写路由时,请求体的校验和数据库表结构散落在各个文件里,改了一处类型,其他地方要跟着手动同步
- Schemas 层把所有「数据结构定义」集中到同一个目录,包括 Zod 校验规则、Drizzle 表结构、请求/响应类型
- Zod schema 是整条数据流的起点——校验规则、TypeScript 类型、OpenAPI 文档都可以从它推导出来
- Drizzle schema 负责数据库表结构,配合
zod插件可以从表定义直接生成 Zod schema,避免手写两份 - 请求 schema 和响应 schema 分开定义,前端拿到的响应结构有明确的契约,不依赖后端的实现细节
zValidator中间件接收 Zod schema 后,在请求进入路由处理函数之前完成校验,路由里拿到的数据已经是类型安全的- Repositories 层从 Schemas 层获取表定义和类型,不自己维护数据库结构
1. 从一个典型问题开始
回顾前面几篇的代码,数据结构的定义通常散落在这些地方:
- 路由文件里直接写
z.object({ name: z.string() })做请求校验 db/schema.ts里定义 Drizzle 表结构- 某个
types.ts里手写interface User { id: number; name: string }做类型声明
一个 User 的数据结构出现在三个地方。给 User 加一个 avatar 字段,你需要同时修改这三处:
- Drizzle 表定义加
avatar列 - Zod schema 加
avatar: z.string().optional() - TypeScript interface 加
avatar?: string
漏掉任何一个,要么数据库写入时报错,要么校验拒绝合法请求,要么类型推断缺失。
这种重复定义在功能少的时候还能靠人记住。项目功能一多,每次加字段都变成一次「找不同」游戏。
2. Schemas 层的职责
Schemas 层要做的事情很明确:把项目中所有数据结构的「形状定义」集中到一个地方。
具体来说,它负责三类定义:
- 数据库表结构:用 Drizzle 的
pgTable/sqliteTable定义字段类型、约束、索引 - 请求校验规则:用 Zod 定义接口入参的校验逻辑,包括请求体、查询参数、路径参数
- 响应数据结构:用 Zod 定义接口返回值的结构,作为前后端契约
这三类定义都放在 src/schemas/ 目录下,按业务模块拆分文件:
// 目录结构
src/
schemas/
index.ts 统一导出
users.ts 用户相关的 schema
posts.ts 文章相关的 schema
auth.ts 认证相关的 schema
common.ts 公共 schema(分页、排序等)
3. Drizzle schema:定义数据库表结构
先从底层开始。Drizzle schema 定义了数据库表的字段、类型和约束,是整个数据层的根基。
// src/schemas/users.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { sql } from 'drizzle-orm'
export const usersTable = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
avatar: text('avatar'),
role: text('role', { enum: ['admin', 'user'] })
.notNull()
.default('user'),
createdAt: text('created_at')
.notNull()
.default(sql`(datetime('now'))`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(datetime('now'))`),
})几个值得注意的写法:
- 字段名用 camelCase(
createdAt),数据库列名用 snake_case(created_at)——Drizzle 会自动处理映射 - 枚举值用
enum选项约束,避免在业务代码里用魔法字符串 - 时间字段的默认值用 SQL 表达式,让数据库负责,不依赖应用层
这张表定义是其他所有 schema 的源头。后面的 Zod schema 可以从它推导,而不是反过来。
4. 从 Drizzle 表推导 Zod schema
手动给每张表写一份对应的 Zod schema,是前面提到的重复劳动之一。Drizzle 提供了 @drizzle-zod 插件,可以直接从表定义生成 Zod schema:
// src/schemas/users.ts
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { z } from 'zod'
import { usersTable } from './users' // 假设表定义在同文件
// 插入用的 schema——对应 INSERT 语句
export const insertUserSchema = createInsertSchema(usersTable, {
// 覆盖或补充自动推导的规则
email: z.string().email('邮箱格式不正确'),
name: z.string().min(1, '名称不能为空').max(50),
avatar: z.string().url().optional(),
})
// 查询用的 schema——对应 SELECT 结果
export const selectUserSchema = createSelectSchema(usersTable)createInsertSchema 会自动处理几件事:
id(自增主键)标记为 optional,插入时不需要传createdAt、updatedAt(有 default 值)标记为 optionalnotNull()的字段标记为 required
createSelectSchema 则把所有字段标记为 required,因为 SELECT 返回的结果里每个字段都有值。
这样,当你给 usersTable 加一个 avatar 字段时,Zod schema 自动跟着更新,不用再手动维护两份。
手动补充校验规则
自动推导覆盖了类型约束,但业务校验规则需要手动补充。比如 email 字段,Drizzle 只知道它是 text,不会自动加 .email() 校验。这就是上面 createInsertSchema 第二个参数的作用——用同名覆盖来添加更严格的规则。
// 常见的手动补充
export const insertUserSchema = createInsertSchema(usersTable, {
email: z.string().email('邮箱格式不正确'),
name: z.string().min(1, '名称不能为空').max(50),
avatar: z.string().url('头像必须是合法 URL').optional(),
})5. 请求 schema:定义接口入参
有了基础的表 schema,接下来按接口的实际需求组装请求 schema。一个接口可能需要多个 schema 配合——请求体、查询参数、路径参数各有一份。
// src/schemas/users.ts
import { z } from 'zod'
// 创建用户:请求体
export const createUserSchema = insertUserSchema
// 更新用户:请求体(所有字段可选)
export const updateUserSchema = insertUserSchema.partial()
// 用户列表:查询参数
export const listUsersQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(['admin', 'user']).optional(),
keyword: z.string().optional(),
})
// 用户详情:路径参数
export const userParamsSchema = z.object({
id: z.coerce.number().int().positive(),
})几个要点:
updateUserSchema用.partial()让所有字段变成 optional,因为 PATCH 请求通常只传需要修改的字段z.coerce.number()把查询参数中的字符串自动转成数字——URL query 参数都是字符串,这一步不能省- 分页参数给默认值,路由处理函数里不需要再判空
6. 响应 schema:定义前后端契约
响应 schema 定义了接口返回值的数据结构。它是前后端的契约——前端根据这个结构来消费数据,后端保证返回的数据符合这个结构。
// src/schemas/users.ts
// 单个用户的响应结构
export const userResponseSchema = selectUserSchema
// 用户列表的响应结构
export const userListResponseSchema = z.object({
data: z.array(selectUserSchema),
pagination: z.object({
page: z.number(),
pageSize: z.number(),
total: z.number(),
totalPages: z.number(),
}),
})把响应 schema 从路由处理函数里抽出来,有几个好处:
- 前端可以直接引用这份 schema 做类型检查,前后端的契约只有一个来源
- 响应结构变了(比如加了一个
pagination对象),只需要改 schema 定义,不需要改每个路由 - 配合 OpenAPI 生成工具,可以自动产出 API 文档
7. zValidator 配合:在中间件层拦截非法请求
schema 定义好了,下一步是在请求进入路由处理函数之前完成校验。前面 06 篇介绍过 zValidator 中间件,这里看它在 Schemas 层协作中的位置。
// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import {
createUserSchema,
updateUserSchema,
listUsersQuerySchema,
userParamsSchema,
} from '../schemas/users'
import type { AppEnv } from '../types'
import { usersRepository } from '../repositories/users'
export const usersApp = new Hono<AppEnv>()
// 获取用户列表
usersApp.get(
'/',
zValidator('query', listUsersQuerySchema),
async (c) => {
const query = c.req.valid('query')
// query 的类型已经从 listUsersQuerySchema 推导出来
// { page: number, pageSize: number, role?: 'admin' | 'user', keyword?: string }
const repo = usersRepository(c.get('db'))
const result = await repo.findAll(query)
return c.json(result)
}
)
// 获取单个用户
usersApp.get(
'/:id',
zValidator('param', userParamsSchema),
async (c) => {
const { id } = c.req.valid('param')
const repo = usersRepository(c.get('db'))
const user = await repo.findById(id)
if (!user) {
return c.json({ code: 'NOT_FOUND', message: '用户不存在' }, 404)
}
return c.json(user)
}
)
// 创建用户
usersApp.post(
'/',
zValidator('json', createUserSchema),
async (c) => {
const body = c.req.valid('json')
// body 的类型是 z.infer<typeof createUserSchema>
// 如果请求体不符合 schema,请求到不了这里
const repo = usersRepository(c.get('db'))
const newUser = await repo.create(body)
return c.json(newUser, 201)
}
)
// 更新用户
usersApp.patch(
'/:id',
zValidator('param', userParamsSchema),
zValidator('json', updateUserSchema),
async (c) => {
const { id } = c.req.valid('param')
const body = c.req.valid('json')
const repo = usersRepository(c.get('db'))
const updated = await repo.update(id, body)
return c.json(updated)
}
)zValidator 在这个流程里做的事情很简单:
- 从请求中提取对应位置的数据(
'query'、'param'、'json') - 用传入的 Zod schema 做校验
- 校验失败直接返回 400,请求不会进入处理函数
- 校验通过后,
c.req.valid()返回的数据已经带有完整的 TypeScript 类型
路由处理函数里拿到的 query、body、param 都是类型安全的,不需要再做 as 类型断言或手动检查。
一个 schema 多处复用
同一个 userParamsSchema 可以同时用在 GET /:id、PATCH /:id、DELETE /:id 三个路由上。schema 定义一次,路由只负责引用——这也是把 schema 抽到独立目录的直接收益。
8. 与 Repositories 层的协作
Repositories 层负责数据库操作(增删改查),它需要知道表结构。这部分信息由 Schemas 层提供,Repositories 层不自己定义表。
// src/repositories/users.ts
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import { eq } from 'drizzle-orm'
import { usersTable } from '../schemas/users'
import type { insertUserSchema } from '../schemas/users'
import type { z } from 'zod'
// 从 schema 推导出的类型
type NewUser = z.infer<typeof insertUserSchema>
export function usersRepository(db: DrizzleD1Database) {
return {
async findAll(query: { page: number; pageSize: number; role?: string; keyword?: string }) {
const offset = (query.page - 1) * query.pageSize
let stmt = db.select().from(usersTable)
if (query.role) {
stmt = stmt.where(eq(usersTable.role, query.role)) as typeof stmt
}
const [data, countResult] = await Promise.all([
stmt.limit(query.pageSize).offset(offset),
db.$count(usersTable),
])
return {
data,
pagination: {
page: query.page,
pageSize: query.pageSize,
total: countResult,
totalPages: Math.ceil(countResult / query.pageSize),
},
}
},
async findById(id: number) {
const users = await db
.select()
.from(usersTable)
.where(eq(usersTable.id, id))
.limit(1)
return users[0] ?? null
},
async create(input: NewUser) {
const result = await db.insert(usersTable).values(input).returning()
return result[0]
},
async update(id: number, input: Partial<NewUser>) {
const result = await db
.update(usersTable)
.set({ ...input, updatedAt: new Date().toISOString() })
.where(eq(usersTable.id, id))
.returning()
return result[0]
},
async delete(id: number) {
await db.delete(usersTable).where(eq(usersTable.id, id))
},
}
}协作关系可以整理成这样:
- Schemas 层定义
usersTable(表结构)和insertUserSchema(校验规则) - Repositories 层从 Schemas 层导入表定义,执行数据库操作
- Repositories 层的输入类型从 Zod schema 推导,不手写 interface
- 路由层从 Schemas 层导入请求 schema 给
zValidator,从 Repositories 层获取数据
每一层只依赖 Schemas 层的定义,层与层之间不互相导入业务逻辑。
9. 类型推导:让 TypeScript 替你工作
整套设计的核心收益是类型推导链。从 Drizzle 表定义开始,TypeScript 类型一路自动推导,不需要手写:
// src/schemas/users.ts
// 1. Drizzle 表定义(源头)
export const usersTable = sqliteTable('users', { /* ... */ })
// 2. Zod schema(从 Drizzle 推导)
export const insertUserSchema = createInsertSchema(usersTable, { /* ... */ })
export const selectUserSchema = createSelectSchema(usersTable)
// 3. TypeScript 类型(从 Zod 推导)
export type User = z.infer<typeof selectUserSchema>
export type NewUser = z.infer<typeof insertUserSchema>
export type UpdateUser = z.infer<typeof updateUserSchema>推导链路是单向的:
Drizzle 表 → Zod schema → TypeScript 类型
不能反过来。如果你发现自己在手写 interface User,说明类型推导链断了——应该从 schema 推导,而不是手动维护。
这条链路的好处是,改表结构时只需要改一个地方(Drizzle 表定义),后面的 Zod schema 和 TypeScript 类型会自动跟着更新。编译时如果哪个地方用错了类型,TypeScript 会直接报错。
统一导出
src/schemas/index.ts 把所有 schema 集中导出,其他模块只需要从这个入口导入:
// src/schemas/index.ts
export * from './users'
export * from './posts'
export * from './auth'
export * from './common'这样路由文件或 Repositories 层在导入时写 import { usersTable, insertUserSchema } from '../schemas',不需要关心具体在哪个文件里。以后调整目录结构,只要 index.ts 的导出不变,上层代码不用改。
总结
回顾一下这篇的要点:
- Schemas 层把数据库表结构、Zod 校验规则、请求/响应 schema 集中到
src/schemas/目录 - Drizzle schema 是数据定义的源头,用
drizzle-zod从中推导 Zod schema,避免手写两份 - 请求 schema 按位置拆分:请求体(
json)、查询参数(query)、路径参数(param) - 响应 schema 定义前后端契约,和请求 schema 分开维护
zValidator在中间件层完成校验,路由处理函数拿到的是类型安全的数据- Repositories 层从 Schemas 层导入表定义,不自己维护数据库结构
- 类型推导链是单向的:Drizzle 表 → Zod schema → TypeScript 类型,改源头即可全局同步
下一篇我们来看 Middlewares 层的设计,讨论如何组织认证、日志、错误处理等中间件。
所属分组
08-Hono工程化项目结构