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 层的核心工作可以归纳为四件事:

  1. 提取参数 — 从 URL 路径、Query String、请求体中取出需要的值
  2. 校验输入 — 确认参数的类型、格式、范围符合预期
  3. 委托处理 — 把校验通过的请求交给 Service 层
  4. 返回响应 — 将 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. 什么该放在路由文件里

把前面的「四件事」展开,路由文件里通常会出现这些内容:

  1. 路由定义app.get()app.post() 等 HTTP 方法和路径声明
  2. 参数提取与校验 — 通过 c.req.param()c.req.query()c.req.json() 取值,用 validator 中间件或手动校验
  3. 调用 Serviceawait userService.findById(userId) 这类委托调用
  4. 响应构造c.json()c.text()c.body() 配合状态码返回结果
  5. 中间件引用 — 在路由上挂 authMiddlewarerateLimitMiddleware

这些是路由文件该做的事。如果某段代码同时出现在多个路由文件里——比如「从 token 解析 user 信息」「把数据库行转成 API 响应格式」——就该考虑把它抽到中间件或 Service 层。

4. 什么不该放在路由文件里

明确列一下不属于 Routes 层的内容:

  1. 数据库查询 — 不写 db.select()db.insert() 这类操作。数据访问交给 Repository 或 Service 层。
  2. 业务规则 — 不写「如果用户状态是 X 则不允许操作 Y」这类判断。业务规则属于 Service 层。
  3. 复杂数据转换 — 不写多行的对象映射、字段计算。转换逻辑属于 Service 层或专门的 mapper 函数。
  4. 外部服务调用 — 不直接 fetch() 第三方 API。外部调用封装在 Service 层。
  5. 缓存操作 — 不在路由里直接读写 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. 响应格式的一致性

客户端解析响应时,如果每个接口的结构都不一样,处理起来会很麻烦。比较常见的做法是统一用 &#123; data, error &#125; 包裹:

// 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)
  })
})

测试关注的是:

  1. 参数合法 + 用户存在 → 200 + 正确的响应结构
  2. 参数合法 + 用户不存在 → 404
  3. 参数不合法 → 400

Service 层的函数被 mock 掉,测试不会因为数据库状态变化而失败。如果未来路由的响应格式发生变化(比如从 &#123; data: user &#125; 改成 &#123; user &#125;),这个测试会立即捕获到。

11. 常见的反模式

胖路由(Fat Routes)

前面第 2 节已经展示过。路由文件超过 100 行,handler 内部有超过 10 行的逻辑,通常是业务逻辑泄露到路由层的信号。

判断标准比较直接:如果 handler 里的代码在没有 HTTP 上下文时也需要运行,它就该移到 Service 层。

路由间复制粘贴

两个路由的 handler 里有大段相似的代码——比如 getUsergetProfile 都做了同样的「取用户 → 检查状态 → 格式化输出」流程。这种情况说明缺少一个共享的 Service 函数。

直接在路由里访问 c.env

路由文件里出现 c.env.DATABASE_URLc.env.JWT_SECRET,说明环境变量的使用没有收口。环境变量应该通过中间件注入到 c.get('db') 这类变量中,或者在 Service 层通过配置模块读取。

路由直接操作数据库

路由文件里直接 import &#123; db &#125; from '../db' 然后写查询语句。这样做的问题前面提过——代码和 HTTP 上下文绑死,无法被其他场景复用。数据访问统一走 Service 或 Repository 层。

在路由里构造复杂响应对象

handler 里有 10 行以上的字段映射、条件赋值、数据拼装。这些逻辑属于 Service 层或专门的 DTO(Data Transfer Object)映射函数。路由只负责把 Service 返回的结果包一层 &#123; data &#125; 返回。

延伸阅读

总结

Routes 层的设计目标可以归纳为一句话:让路由文件成为一份接口清单

打开任何一个路由文件,应该能快速看出这个项目有哪些 HTTP 接口、每个接口接受什么参数、返回什么状态码。具体的业务逻辑、数据操作、格式转换都藏在 Service 层里,路由只负责把它们串起来。

顺着这个目标往回看前面几节的内容:

  1. 路由只做四件事——提取参数、校验输入、委托 Service、返回响应
  2. 路由文件按资源或功能模块组织,用 index 文件做聚合
  3. 类型安全通过 validator + Zod 传导,不需要手动断言
  4. 错误处理分业务错误(handler 内处理)和运行时错误(onError 统一处理)
  5. 响应格式统一用 &#123; data, error &#125; 包裹
  6. 测试重点在 HTTP 契约,Service 层通过 mock 隔离

路由层设计清楚了,Service 层的边界也就自然浮现出来——凡是路由不该做的事,都是 Service 层该做的事。

下一篇进入 Services 层设计,看看 Service 函数如何组织、如何与 Repository 和外部服务交互。