Routes层设计
要点
- Routes 层是整个 Hono 项目的 HTTP 入口——它接收请求、调用服务、返回响应,职责边界需要划清楚
- 路由处理函数应该尽量薄,核心工作是参数提取、校验、委托给 Service、格式化响应
- 业务逻辑、数据库查询、复杂数据转换都不该出现在路由文件里
- 路由文件的组织可以按资源(users.ts、posts.ts)或按功能模块(auth/、admin/)划分
app.route()配合 index 聚合文件,能把大量路由模块统一注册到入口- 类型安全通过 validator 中间件把 Zod schema 传导到 handler,避免手动
as断言 - 路由层的测试重点在于 HTTP 契约:状态码、响应结构、参数校验行为,而不是业务逻辑
1. Routes 层的职责边界
Routes 层是请求进入系统后到达的第一层业务代码。一个请求从客户端发出,经过全局中间件(日志、CORS、鉴权等),最终命中的就是路由处理函数。
Routes 层的核心工作可以归纳为四件事:
- 提取参数 — 从 URL 路径、Query String、请求体中取出需要的值
- 校验输入 — 确认参数的类型、格式、范围符合预期
- 委托处理 — 把校验通过的请求交给 Service 层
- 返回响应 — 将 Service 层的结果格式化为 HTTP 响应
这四件事之外的任何逻辑——比如「这条记录是否允许删除」「两个字段之间的业务规则校验」「跨表查询拼装」——都不属于 Routes 层。
这样划分的出发点比较实际:路由处理函数的调用频率和请求直接相关,如果里面塞了太多逻辑,每次修改业务规则都要在路由文件里改代码,文件膨胀的速度会快。
2. 薄路由:只做四件事
一个典型的「获取单个用户」路由处理函数,可以写成这样:
// src/routes/users.ts
import { Hono } from 'hono'
import { validator } from 'hono/validator'
import { z } from 'zod'
import type { AppEnv } from '../types'
import { userService } from '../services/user'
const usersApp = new Hono<AppEnv>()
// GET /users/:userId
usersApp.get(
'/:userId',
validator('param', (value, c) => {
const result = z.object({
userId: z.string().regex(/^\d+$/).transform(Number),
}).safeParse(value)
if (!result.success) {
return c.json({ error: 'invalid user id' }, 400)
}
return result.data
}),
async (c) => {
const { userId } = c.req.valid('param')
const user = await userService.findById(userId)
if (!user) {
return c.json({ error: 'user not found' }, 404)
}
return c.json({ data: user })
}
)
export { usersApp }handler 里的每一步都很短:取参数、调 service、判断结果、返回 JSON。没有 SQL 查询,没有字符串拼接,没有复杂的条件分支。
如果把业务逻辑写在路由里,会变成什么样:
// ❌ 胖路由:所有事情都在这里做
usersApp.get('/:userId', async (c) => {
const userId = Number(c.req.param('userId'))
if (Number.isNaN(userId)) {
return c.json({ error: 'invalid id' }, 400)
}
// 直接在路由里写 SQL
const db = c.get('db')
const user = await db.select().from(usersTable).where(eq(usersTable.id, userId)).get()
if (!user) {
return c.json({ error: 'not found' }, 404)
}
// 直接在路由里做业务判断
if (user.deletedAt && user.deletedAt < new Date()) {
await db.delete(usersTable).where(eq(usersTable.id, userId))
return c.json({ error: 'user has been purged' }, 410)
}
// 直接在路由里做数据转换
const formatted = {
id: user.id,
displayName: `${user.firstName} ${user.lastName}`,
age: Math.floor((Date.now() - new Date(user.birthday).getTime()) / 31536000000),
hasActiveSubscription: user.subscriptionEnd ? new Date(user.subscriptionEnd) > new Date() : false,
}
return c.json({ data: formatted })
})这个 handler 做了参数转换、数据库查询、业务规则判断、数据格式化四件事。任何一个逻辑发生变化,都要改路由文件。如果另一个接口也需要「计算用户年龄」或「判断订阅是否有效」,这段代码就要么被复制一份,要么被抽出去——不如一开始就放到 Service 层。
3. 什么该放在路由文件里
把前面的「四件事」展开,路由文件里通常会出现这些内容:
- 路由定义 —
app.get()、app.post()等 HTTP 方法和路径声明 - 参数提取与校验 — 通过
c.req.param()、c.req.query()、c.req.json()取值,用 validator 中间件或手动校验 - 调用 Service —
await userService.findById(userId)这类委托调用 - 响应构造 —
c.json()、c.text()、c.body()配合状态码返回结果 - 中间件引用 — 在路由上挂
authMiddleware、rateLimitMiddleware等
这些是路由文件该做的事。如果某段代码同时出现在多个路由文件里——比如「从 token 解析 user 信息」「把数据库行转成 API 响应格式」——就该考虑把它抽到中间件或 Service 层。
4. 什么不该放在路由文件里
明确列一下不属于 Routes 层的内容:
- 数据库查询 — 不写
db.select()、db.insert()这类操作。数据访问交给 Repository 或 Service 层。 - 业务规则 — 不写「如果用户状态是 X 则不允许操作 Y」这类判断。业务规则属于 Service 层。
- 复杂数据转换 — 不写多行的对象映射、字段计算。转换逻辑属于 Service 层或专门的 mapper 函数。
- 外部服务调用 — 不直接
fetch()第三方 API。外部调用封装在 Service 层。 - 缓存操作 — 不在路由里直接读写 Redis 或 KV。缓存策略由 Service 层管理。
一个判断标准比较直接:如果这段代码在没有 HTTP 上下文的情况下也需要运行,它就不该在路由里。 Service 层的函数可以被路由调用,也可以被定时任务、消息队列消费者、CLI 命令调用。如果代码写在路由里,这些场景都用不了。
5. 路由文件的组织方式
路由文件的组织有两种常见策略,按项目规模选择。
按资源划分
适合中小型项目,每个文件对应一个资源:
src/routes/
users.ts // 用户 CRUD
posts.ts // 文章 CRUD
comments.ts // 评论
auth.ts // 登录、注册、刷新 token
upload.ts // 文件上传每个文件内部结构清晰——一个文件里只有同一个资源相关的接口。查找某个资源的接口时,直接打开对应文件就行。
按功能模块划分
适合中大型项目,一个目录下放一个功能模块的所有路由:
src/routes/
user/
index.ts // 聚合并导出
get-users.ts
get-user.ts
create-user.ts
update-user.ts
post/
index.ts
get-posts.ts
get-post.ts
create-post.ts
auth/
index.ts
login.ts
register.ts
refresh.ts每个路由拆成独立文件,好处是单个文件始终很短(通常不超过 50 行),多人协作时不容易冲突。缺点是文件数量多,需要 index 文件做聚合。
用 index 文件做路由聚合
无论用哪种组织方式,当路由模块多到一定程度,都需要一个聚合点。index 文件承担这个职责:
// src/routes/index.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { usersApp } from './users'
import { postsApp } from './posts'
import { authApp } from './auth'
const routes = new Hono<AppEnv>()
routes.route('/users', usersApp)
routes.route('/posts', postsApp)
routes.route('/auth', authApp)
export { routes }入口文件只需要引入这一个聚合模块:
// src/index.ts
import { Hono } from 'hono'
import type { AppEnv } from './types'
import { routes } from './routes'
import { logger } from 'hono/logger'
const app = new Hono<AppEnv>()
app.use('*', logger())
app.route('/api', routes)
export default app这样所有业务路由都挂在 /api 前缀下,入口文件只需要关心全局中间件和路由挂载。新增路由模块时,在 routes/index.ts 加一行 route() 就行。
6. 路由注册:app.route() 的工作方式
app.route() 是 Hono 的路由组合机制。它接受一个路径前缀和一个 Hono 实例,把实例中注册的所有路由都挂到这个前缀下。
// src/index.ts
import { Hono } from 'hono'
import type { AppEnv } from './types'
import { usersApp } from './routes/users'
const app = new Hono<AppEnv>()
// usersApp 内部的 '/' 对应外部的 '/api/v1/users'
// usersApp 内部的 '/:userId' 对应外部的 '/api/v1/users/:userId'
app.route('/api/v1/users', usersApp)usersApp 内部写的是相对路径 / 和 /:userId,通过 app.route() 挂载后才获得完整路径。这意味着路由模块本身不关心自己最终被挂在哪个路径下——同一个 usersApp 可以同时挂在 /api/v1/users 和 /admin/users 下,做不同程度的权限控制。
路径前缀可以嵌套多层:
// src/index.ts
const api = new Hono<AppEnv>()
const v1 = new Hono<AppEnv>()
v1.route('/users', usersApp)
v1.route('/posts', postsApp)
api.route('/v1', v1)
app.route('/api', api)
// 最终路径:
// /api/v1/users
// /api/v1/users/:userId
// /api/v1/posts这种嵌套适合需要多版本共存的 API。新增 /v2 时,只需创建新的 Hono 实例,不影响 v1 的路由。
7. 路由层的错误处理
路由层的错误处理分两种情况:可预期的业务错误和不可预期的运行时错误。
业务错误
业务错误是正常流程的一部分——用户不存在、参数不合法、余额不足。这类错误由 handler 直接处理,返回对应的状态码:
// src/routes/users.ts
usersApp.post('/', async (c) => {
const body = await c.req.json()
const email = body.email as string
const existing = await userService.findByEmail(email)
if (existing) {
return c.json({ error: 'email already registered' }, 409)
}
const user = await userService.create(body)
return c.json({ data: user }, 201)
})409 表示「冲突」,是一个标准的 HTTP 状态码。路由层负责选择合适的状态码,业务判断本身由 Service 层返回的信号驱动。
运行时错误
运行时错误是意料之外的——数据库连接断开、第三方 API 超时。这类错误不该在每个 handler 里单独 try/catch,适合用 app.onError() 统一处理:
// src/index.ts
import { Hono } from 'hono'
import { AppError } from './lib/errors'
import type { AppEnv } from './types'
const app = new Hono<AppEnv>()
app.onError((err, c) => {
// 自定义业务错误:保留状态码和消息
if (err instanceof AppError) {
return c.json({ error: err.message }, err.statusCode)
}
// 未知错误:不暴露内部细节
console.error('Unhandled error:', err)
return c.json({ error: 'internal server error' }, 500)
})Service 层抛出的 AppError 带有 statusCode 属性,onError 会原样返回。未预期的错误统一返回 500,避免把堆栈信息或数据库连接串泄露给客户端。
404 处理
当请求不匹配任何路由时,Hono 默认返回纯文本「404 Not Found」。如果需要统一返回 JSON 格式:
// src/index.ts
app.notFound((c) => {
return c.json({ error: 'endpoint not found' }, 404)
})8. 响应格式的一致性
客户端解析响应时,如果每个接口的结构都不一样,处理起来会很麻烦。比较常见的做法是统一用 { data, error } 包裹:
// src/lib/response.ts
// 成功响应的统一结构
export function successResponse<T>(data: T, status = 200) {
return { data, error: null, meta: { status } }
}
// 错误响应的统一结构
export function errorResponse(message: string, status = 400) {
return { data: null, error: { message }, meta: { status } }
}在路由里使用:
// src/routes/users.ts
import { successResponse, errorResponse } from '../lib/response'
usersApp.get('/:userId', async (c) => {
const { userId } = c.req.valid('param')
const user = await userService.findById(userId)
if (!user) {
return c.json(errorResponse('user not found'), 404)
}
return c.json(successResponse(user))
})客户端拿到任何接口返回,都可以先用 response.error 是否为 null 来判断成功与否,不需要逐个接口适配不同结构。
9. 路由层的类型安全
Hono 的类型系统可以从 validator 一路传导到 handler。前面第 2 节的例子里已经展示了这种模式——validator 中间件校验 param,handler 里通过 c.req.valid('param') 拿到类型推导后的值。
同样的方式适用于 json(请求体)和 query(查询参数):
// src/routes/posts.ts
import { Hono } from 'hono'
import { validator } from 'hono/validator'
import { z } from 'zod'
import type { AppEnv } from '../types'
import { postService } from '../services/post'
const postsApp = new Hono<AppEnv>()
// 请求体的 Zod schema
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
})
postsApp.post(
'/',
validator('json', (value, c) => {
const result = createPostSchema.safeParse(value)
if (!result.success) {
return c.json({ error: 'invalid request body', details: result.error.issues }, 400)
}
return result.data
}),
async (c) => {
// body 的类型由 Zod schema 自动推导
const body = c.req.valid('json')
// body.title: string
// body.content: string
// body.tags: string[] | undefined
const post = await postService.create(body)
return c.json({ data: post }, 201)
}
)validator 中间件把 schema 校验和类型推导合在同一步完成。handler 内部不需要 as 断言,也不需要手动检查字段是否存在——Zod schema 已经保证了。
封装可复用的校验中间件
如果多个路由都需要类似的校验结构,可以抽一个通用函数:
// src/middleware/validate.ts
import { validator } from 'hono/validator'
import type { z } from 'zod'
export function validateBody<T extends z.ZodType>(schema: T) {
return validator('json', (value, c) => {
const result = schema.safeParse(value)
if (!result.success) {
return c.json(
{ error: 'invalid request body', details: result.error.issues },
400
)
}
return result.data
})
}
export function validateQuery<T extends z.ZodType>(schema: T) {
return validator('query', (value, c) => {
const result = schema.safeParse(value)
if (!result.success) {
return c.json(
{ error: 'invalid query params', details: result.error.issues },
400
)
}
return result.data
})
}路由文件里只需要定义 schema,校验逻辑由这两个函数统一处理:
// src/routes/posts.ts
import { validateBody } from '../middleware/validate'
postsApp.post(
'/',
validateBody(createPostSchema),
async (c) => {
const body = c.req.valid('json')
const post = await postService.create(body)
return c.json({ data: post }, 201)
}
)10. 路由层的测试
路由层的测试重点在于 HTTP 契约——请求格式正确时返回什么状态码和响应结构,请求格式错误时返回什么错误信息。业务逻辑的正确性由 Service 层的测试覆盖,路由测试不需要重复验证。
测试时通常需要 mock Service 层的函数。下面用 Hono 自带的测试工具展示:
// tests/routes/users.test.ts
import { describe, it, expect, vi } from 'vitest'
import { Hono } from 'hono'
import { usersApp } from '../../src/routes/users'
import * as userService from '../../src/services/user'
// mock Service 层
vi.mock('../../src/services/user', () => ({
userService: {
findById: vi.fn(),
create: vi.fn(),
},
}))
const app = new Hono().route('/users', usersApp)
describe('GET /users/:userId', () => {
it('返回 200 和 user 对象', async () => {
vi.mocked(userService.findById).mockResolvedValue({
id: 1,
name: 'Alice',
email: '[email protected]',
})
const res = await app.request('/users/1')
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data).toEqual({
id: 1,
name: 'Alice',
email: '[email protected]',
})
})
it('用户不存在时返回 404', async () => {
vi.mocked(userService.findById).mockResolvedValue(null)
const res = await app.request('/users/999')
expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBe('user not found')
})
it('userId 不是数字时返回 400', async () => {
const res = await app.request('/users/abc')
expect(res.status).toBe(400)
})
})测试关注的是:
- 参数合法 + 用户存在 → 200 + 正确的响应结构
- 参数合法 + 用户不存在 → 404
- 参数不合法 → 400
Service 层的函数被 mock 掉,测试不会因为数据库状态变化而失败。如果未来路由的响应格式发生变化(比如从 { data: user } 改成 { user }),这个测试会立即捕获到。
11. 常见的反模式
胖路由(Fat Routes)
前面第 2 节已经展示过。路由文件超过 100 行,handler 内部有超过 10 行的逻辑,通常是业务逻辑泄露到路由层的信号。
判断标准比较直接:如果 handler 里的代码在没有 HTTP 上下文时也需要运行,它就该移到 Service 层。
路由间复制粘贴
两个路由的 handler 里有大段相似的代码——比如 getUser 和 getProfile 都做了同样的「取用户 → 检查状态 → 格式化输出」流程。这种情况说明缺少一个共享的 Service 函数。
直接在路由里访问 c.env
路由文件里出现 c.env.DATABASE_URL 或 c.env.JWT_SECRET,说明环境变量的使用没有收口。环境变量应该通过中间件注入到 c.get('db') 这类变量中,或者在 Service 层通过配置模块读取。
路由直接操作数据库
路由文件里直接 import { db } from '../db' 然后写查询语句。这样做的问题前面提过——代码和 HTTP 上下文绑死,无法被其他场景复用。数据访问统一走 Service 或 Repository 层。
在路由里构造复杂响应对象
handler 里有 10 行以上的字段映射、条件赋值、数据拼装。这些逻辑属于 Service 层或专门的 DTO(Data Transfer Object)映射函数。路由只负责把 Service 返回的结果包一层 { data } 返回。
延伸阅读
- 02-中大型项目结构 — 项目目录结构和入口文件的设计
- 02-动态路由参数 — 路由参数的底层机制和类型安全校验
- 07-Validator 中间件 — validator 中间件的详细用法
- Hono Routing 文档 —
app.route()的官方说明
总结
Routes 层的设计目标可以归纳为一句话:让路由文件成为一份接口清单。
打开任何一个路由文件,应该能快速看出这个项目有哪些 HTTP 接口、每个接口接受什么参数、返回什么状态码。具体的业务逻辑、数据操作、格式转换都藏在 Service 层里,路由只负责把它们串起来。
顺着这个目标往回看前面几节的内容:
- 路由只做四件事——提取参数、校验输入、委托 Service、返回响应
- 路由文件按资源或功能模块组织,用 index 文件做聚合
- 类型安全通过 validator + Zod 传导,不需要手动断言
- 错误处理分业务错误(handler 内处理)和运行时错误(
onError统一处理) - 响应格式统一用
{ data, error }包裹 - 测试重点在 HTTP 契约,Service 层通过 mock 隔离
路由层设计清楚了,Service 层的边界也就自然浮现出来——凡是路由不该做的事,都是 Service 层该做的事。
下一篇进入 Services 层设计,看看 Service 函数如何组织、如何与 Repository 和外部服务交互。