嵌套路由

要点

  • 嵌套路由是 REST 资源层级关系在 URL 上的直接映射,形如 /users/:userId/posts/:postId
  • Hono 通过 app.route() 的链式挂载来实现嵌套路由,每一层对应一个独立的 Hono 实例
  • 嵌套层级每加一层,URL 复杂度和路径参数数量都会增加,维护和测试的负担也随之上升
  • 扁平化(用查询参数代替路径嵌套)与深度嵌套之间的取舍,取决于子资源是否必须依赖父资源才有意义
  • 各层级注册的路由参数都能在最终的处理函数中通过 c.req.param() 一次性取出
  • 中间件可以挂载在不同嵌套层级上,实现按资源粒度生效的鉴权、日志等横切逻辑
  • 超过三层的嵌套通常值得重新审视,看是否可以扁平化或拆成独立资源

1. 什么是嵌套路由

资源之间存在从属关系时,会自然地产生嵌套 URL。

一个典型的例子:用户拥有文章,文章下有评论:

/users/:userId           → 某个用户
/users/:userId/posts     → 某用户的所有文章
/users/:userId/posts/:postId         → 某用户的某篇文章
/users/:userId/posts/:postId/comments → 某篇文章下的所有评论

嵌套路由的核心特征是:子资源的路径中包含父资源的参数。如果子资源可以脱离父资源独立存在,通常不需要嵌套。

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
app.get('/users/:userId/posts', (c) => {
  const userId = c.req.param('userId')
  return c.json({ userId, posts: [] })
})
 
app.get('/users/:userId/posts/:postId', (c) => {
  return c.json({ userId: c.req.param('userId'), postId: c.req.param('postId') })
})
 
export default app

这种写法能用,但所有路由堆在一个文件里,一旦资源层级超过两层,维护成本会快速上升。

2. 用 app.route() 实现嵌套

01 篇已经介绍过 app.route() 的基本用法。嵌套路由的关键在于:子模块可以继续挂载子模块,形成链式结构。下面用一个三层嵌套(用户 → 文章 → 评论)展示完整的拆分方式:

// src/routes/comments.ts
import { Hono } from 'hono'
 
const comments = new Hono()
 
// 这里的 / 对应最终的 /users/:userId/posts/:postId/comments/
comments.get('/', (c) => {
  const userId = c.req.param('userId')
  const postId = c.req.param('postId')
  return c.json({ userId, postId, comments: [] })
})
 
comments.post('/', async (c) => {
  const userId = c.req.param('userId')
  const postId = c.req.param('postId')
  const body = await c.req.json()
  return c.json({ userId, postId, ...body }, 201)
})
 
export default comments
// src/routes/posts.ts
import { Hono } from 'hono'
import comments from './comments'
 
const posts = new Hono()
 
posts.get('/', (c) => c.json({ userId: c.req.param('userId'), posts: [] }))
 
posts.get('/:postId', (c) => {
  return c.json({ userId: c.req.param('userId'), postId: c.req.param('postId') })
})
 
// 把 comments 挂载到 posts 下的 /:postId/comments
posts.route('/:postId/comments', comments)
 
export default posts
// src/routes/users.ts
import { Hono } from 'hono'
import posts from './posts'
 
const users = new Hono()
 
users.get('/:userId', (c) => c.json({ userId: c.req.param('userId') }))
 
// 把 posts 挂载到 users 下的 /:userId/posts
users.route('/:userId/posts', posts)
 
export default users
// src/index.ts
import { Hono } from 'hono'
import users from './routes/users'
 
const app = new Hono().basePath('/api/v1')
app.route('/users', users)
export default app

三层嵌套最终注册到 Hono 路由表中的路径:

GET  /api/v1/users/:userId
GET  /api/v1/users/:userId/posts/
GET  /api/v1/users/:userId/posts/:postId
GET  /api/v1/users/:userId/posts/:postId/comments/
POST /api/v1/users/:userId/posts/:postId/comments/

每一层模块只关心自己往下的路径段,不需要知道最终被挂到多深的前缀下。

3. 深层嵌套的模式与代价

REST 资源层级越丰富,嵌套就越容易往深处走。一个典型的三层嵌套:

/orgs/:orgId/teams/:teamId/members/:memberId

这条 URL 包含三个路径参数。在 Hono 中,处理函数可以一次性取出所有层级的参数:

// src/routes/members.ts — 最深层的处理函数
import { Hono } from 'hono'
 
const members = new Hono()
 
members.get('/:memberId', (c) => {
  // 三层嵌套的参数都能在这里取到
  const orgId = c.req.param('orgId')     // 来自 orgs 模块
  const teamId = c.req.param('teamId')   // 来自 teams 模块
  const memberId = c.req.param('memberId') // 来自本模块
  return c.json({ orgId, teamId, memberId })
})
 
export default members
// src/routes/teams.ts
import { Hono } from 'hono'
import members from './members'
 
const teams = new Hono()
teams.route('/:teamId/members', members)
export default teams
// src/routes/orgs.ts + 主入口
import { Hono } from 'hono'
import teams from './teams'
 
const orgs = new Hono()
orgs.route('/:orgId/teams', teams)
// 主入口: app.route('/orgs', orgs)
export default orgs

请求 GET /orgs/acme/teams/engineering/members/alice,返回 { orgId: "acme", teamId: "engineering", memberId: "alice" }

Hono 的路由匹配引擎会把各层注册的路径段合并成完整模式,匹配成功后所有参数统一放入 matchResult,处理函数通过 c.req.param() 就能拿到任意层级的参数值。

不过,嵌套层数增加带来的问题也随之而来:

  1. URL 长度膨胀。每层嵌套至少多两个路径段(参数名 + 值),三层嵌套的 URL 已经相当长
  2. 参数数量增多。处理函数需要接收并验证 3 个以上参数,业务逻辑的入口条件变复杂
  3. 客户端调用成本高。前端或第三方调用方需要拼接完整路径,任何一层 ID 缺失都无法发起请求
  4. 重构成本大。如果业务调整导致某一层资源独立出来,所有嵌套在该层之下的路由都要迁移

这些问题在三层嵌套时还可控,到四层、五层时就会成为明显的维护负担。

4. 扁平化 vs 嵌套的取舍

并非所有资源关系都适合用嵌套 URL 表达。选择嵌套还是扁平,取决于一个核心判断:子资源脱离父资源后是否有独立意义

以文章和用户为例:

  • 如果文章只属于某个用户,且不会在用户上下文之外被访问,嵌套合理:/users/:userId/posts
  • 如果文章需要被独立搜索、跨用户聚合、或按标签浏览,扁平化更灵活:/posts?userId=xxx

两种方式的对比:

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
// 嵌套写法:文章从属于用户
app.get('/users/:userId/posts', (c) => {
  const userId = c.req.param('userId')
  // 查询 userId 对应的文章
  return c.json({ userId, posts: [] })
})
 
// 扁平写法:文章是独立资源,userId 作为过滤条件
app.get('/posts', (c) => {
  const userId = c.req.query('userId')
  // userId 可选,不传就返回所有文章
  return c.json({ userId, posts: [] })
})
 
export default app

嵌套写法的优势:

  • URL 自带语义,看到路径就知道资源关系
  • 父资源的存在性可以由路由层保证——/users/不存在的ID/posts 在中间件层就能拦截

扁平写法的优势:

  • 支持不带过滤条件的列表查询(GET /posts 返回所有文章)
  • 多个过滤条件组合更自然(GET /posts?userId=xxx&tag=yyy&status=draft
  • 嵌套层级固定,不会因为新增过滤维度而加深路径

实际操作中,两种写法经常共存。一种常见的模式是:嵌套路由处理「在某父资源上下文中操作子资源」的场景,列表查询和跨资源搜索用扁平路由加查询参数:

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
// 嵌套:创建文章(必须属于某个用户)
app.post('/users/:userId/posts', async (c) => {
  const userId = c.req.param('userId')
  const body = await c.req.json()
  return c.json({ userId, ...body }, 201)
})
 
// 扁平:按条件查询文章(userId 可选)
app.get('/posts', (c) => {
  const userId = c.req.query('userId')
  const tag = c.req.query('tag')
  return c.json({ userId, tag, posts: [] })
})
 
export default app

5. 参数访问与上下文共享

嵌套路由中,各层注册的路径参数最终汇总到同一个 Context 上。处理函数可以直接访问任意层级的参数,不需要额外的传递机制——前面的代码示例已经反复展示了这一点。

更实用的场景是:在中间件中根据参数查询业务数据,然后通过 c.set() / c.get() 把结果挂到 Context 上,让后续路由复用:

// src/routes/posts.ts
import { Hono } from 'hono'
 
const posts = new Hono()
 
// 中间件:预加载用户信息,后续路由通过 c.get('user') 复用
posts.use('/:postId/*', async (c, next) => {
  const userId = c.req.param('userId')
  // 假设这里查了数据库
  const user = { id: userId, name: 'Alice', role: 'author' }
  c.set('user', user)
  await next()
})
 
posts.get('/:postId', (c) => {
  const user = c.get('user')
  const postId = c.req.param('postId')
  return c.json({ user, postId })
})
 
export default posts

c.set() / c.get() 的详细机制在 03.04 Context 篇讲过,这里只需要记住:Context 的生命周期覆盖单个请求的完整处理链路,嵌套路由的各层中间件和处理函数共享同一个 Context 实例。

需要注意的是,c.set() 设置的变量只在当前请求内有效,不同请求之间的 Context 互相独立。

6. 分层中间件应用

嵌套路由的一个实用能力是:中间件可以挂载在不同层级,只对该层级下的路由生效

这个特性在 09 中间件专题中会完整展开,这里先看嵌套路由场景下的基本用法。

// src/routes/posts.ts
import { Hono } from 'hono'
import comments from './comments'
 
const posts = new Hono()
 
// posts 层级的鉴权中间件:只作用于 /users/:userId/posts 下的路由
posts.use('*', async (c, next) => {
  const authHeader = c.req.header('Authorization')
  if (!authHeader) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  await next()
})
 
posts.get('/', (c) => c.json({ posts: [] }))
 
// comments 层级的中间件只作用于评论路由
comments.use('*', async (c, next) => {
  // 评论操作需要额外的频率限制
  await next()
})
 
posts.route('/:postId/comments', comments)
 
export default posts

分层中间件的生效范围取决于挂载位置:

  • 挂在主 app 上 → 所有路由生效
  • 挂在 users 模块 → 只对 /users/* 生效
  • 挂在 posts 模块 → 只对 /users/:userId/posts/* 生效
  • 挂在 comments 模块 → 只对 /users/:userId/posts/:postId/comments/* 生效

每层模块可以独立管理自己关心的横切逻辑,不需要把所有鉴权、日志、限流规则集中到一个地方。

7. 类型安全

Hono 的类型系统能推导出嵌套路由中的路径参数类型。子模块的路径参数会逐层累积到类型中:

// src/routes/posts.ts
import { Hono } from 'hono'
 
const posts = new Hono()
 
posts.get('/:postId', (c) => {
  const postId = c.req.param('postId')   // 类型: string
  const userId = c.req.param('userId')   // 来自父级,类型: string
  return c.json({ userId, postId })
})
 
export default posts

如果需要对参数做更严格的约束(比如限制 userId 必须是数字),可以用 createMiddleware 写一个参数校验中间件,挂在父路由上:

// src/middleware/validate-params.ts
import { createMiddleware } from 'hono/factory'
 
export const validateNumericParam = (paramName: string) =>
  createMiddleware(async (c, next) => {
    const value = c.req.param(paramName)
    if (value && !/^\d+$/.test(value)) {
      return c.json({ error: `${paramName} must be numeric` }, 400)
    }
    await next()
  })
// src/routes/users.ts
import { Hono } from 'hono'
import posts from './posts'
import { validateNumericParam } from '../middleware/validate-params'
 
const users = new Hono()
users.use('/:userId/*', validateNumericParam('userId'))
users.get('/:userId', (c) => c.json({ userId: c.req.param('userId') }))
users.route('/:userId/posts', posts)
export default users

关于 Hono 路由类型推导的更多细节,包括 Env 泛型和 Variables 类型在嵌套路由中的传递,会在后续文章(09 中间件专题)中展开。

8. 常见反模式

反模式一:嵌套超过三层

/users/:userId/posts/:postId/comments/:commentId/likes

四层嵌套意味着请求一条评论的点赞需要拼出极长的 URL,且处理函数需要接收 4 个路径参数。这种情况通常可以拆解:

  • 评论的点赞可以独立建模为 /comments/:commentId/likes
  • 或者直接用一个扁平接口 POST /likes + body 里传 commentId

反模式二:同一资源混用嵌套和扁平风格

// 不一致的路由设计
app.get('/users/:userId/posts', handler)     // 嵌套
app.get('/posts/:postId', handler)           // 扁平
app.post('/users/:userId/posts/:postId/comments', handler) // 嵌套
app.get('/comments/:commentId', handler)     // 扁平

查询时扁平、创建时嵌套,调用方会感到困惑。要么统一风格,要么用明确的规则区分(嵌套只用于创建、扁平用于查询)。

反模式三:不必要的嵌套

// 分类是全局概念,不需要嵌套在用户下
app.get('/users/:userId/categories', handler)
 
// 更合理的写法
app.get('/categories', handler)
app.get('/categories/:id', handler)

如果子资源不依赖父资源就能独立存在(比如分类是全局的),硬塞进嵌套路径只会增加不必要的复杂度。判断标准回到前面那条:子资源脱离父资源后是否还有独立意义

延伸阅读

总结

嵌套路由本质上是 REST 资源层级在 URL 结构上的映射。Hono 通过 app.route() 的链式挂载来组织嵌套关系,每一层模块只关心自己往下的路径段,各层参数在最终的处理函数中统一可用。

嵌套层级控制在两到三层比较合适。超过三层时,可以先审视资源建模是否合理——子资源是否真的从属于父资源、是否可以通过扁平化减少路径深度。嵌套和扁平不必二选一,按场景混用是常见的做法。

分层中间件可以按嵌套层级生效,这个能力在 09 会详细展开。下一篇 05 路由分组会讨论另一种路由组织方式——和 app.route() 的区别在于,分组不引入新的路径前缀。