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 字段,你需要同时修改这三处:

  1. Drizzle 表定义加 avatar
  2. Zod schema 加 avatar: z.string().optional()
  3. 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,插入时不需要传
  • createdAtupdatedAt(有 default 值)标记为 optional
  • notNull() 的字段标记为 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 在这个流程里做的事情很简单:

  1. 从请求中提取对应位置的数据('query''param''json'
  2. 用传入的 Zod schema 做校验
  3. 校验失败直接返回 400,请求不会进入处理函数
  4. 校验通过后,c.req.valid() 返回的数据已经带有完整的 TypeScript 类型

路由处理函数里拿到的 querybodyparam 都是类型安全的,不需要再做 as 类型断言或手动检查。

一个 schema 多处复用

同一个 userParamsSchema 可以同时用在 GET /:idPATCH /:idDELETE /: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 &#123; usersTable, insertUserSchema &#125; 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工程化项目结构