大型项目路由组织

要点

  • 当路由数量超过 50 条时,按文件平铺的组织方式会遇到查找困难、命名冲突和职责混乱的问题
  • 按业务域分组(users、billing、content、ai)让每个模块文件只关注自己的路由,主入口只做挂载
  • 分层架构把公开路由、认证路由和管理员路由拆到不同的 Hono 实例上,中间件的作用范围跟着实例边界走
  • Feature module 模式让每个功能模块自带 routes、handlers、services、types,形成自包含的目录结构
  • 路由聚合器(routes/index.ts)负责收集所有子模块,新增模块只需在聚合器加一行注册
  • 路由元数据(metadata)可以在注册时附加权限、文档描述等信息,供中间件统一消费

1. 路由数量增长后会遇到什么

01 篇的路由组织方式是「一个文件写所有路由」或「按模块拆成几个文件再挂载」。路由数量在 10-20 条时,这两种方式没有本质差别。

当项目扩展到 50 条以上路由、涉及 5 个以上业务域时,平铺结构开始出现具体问题:

  1. 单个路由文件超过 300 行,新增路由时要在大量 app.get() / app.post() 中定位插入位置
  2. 不同业务域的中间件需求不同,但平铺结构下只能在每个 handler 内部单独调用
  3. 团队成员同时在同一个文件里改路由,合并冲突频率升高
  4. 删除某个功能时,需要在一堆混杂的路由中找到属于该功能的所有条目

也许你会觉得「把文件拆细一点就好」。但拆文件只是把问题推迟了——如果 20 个模块文件全部平铺在 routes/ 目录下,主入口的 app.route() 调用也会变成 20 行,找起来同样费力。

需要在拆文件的基础上再引入一层组织规则。

2. 按业务域分组

业务域是产品功能的一级分类。一个 SaaS 平台通常包含 users、billing、content、ai、notifications 这样的域。每个域对应 routes/ 下的一个子目录:

src/routes/
  users/
    index.ts        # 聚合器,导出 users 域的完整路由
    routes.ts       # 路由定义
    handlers.ts     # 请求处理函数
    services.ts     # 业务逻辑
    types.ts        # 域内类型定义
  billing/
    index.ts
    routes.ts
    handlers.ts
    services.ts
  content/
    index.ts
    routes.ts
    handlers.ts
  ai/
    index.ts
    routes.ts
    handlers.ts
  index.ts          # 顶层聚合器,收集所有域

每个域的 routes.ts 创建自己的 Hono 实例并定义路由:

// src/routes/users/routes.ts
import { Hono } from 'hono'
import { listUsers, getUser, createUser, updateUser, deleteUser } from './handlers'
 
const users = new Hono()
 
users.get('/', listUsers)
users.get('/:id', getUser)
users.post('/', createUser)
users.put('/:id', updateUser)
users.delete('/:id', deleteUser)
 
export default users

handlers.ts 只放处理函数,不创建 Hono 实例:

// src/routes/users/handlers.ts
import type { Context } from 'hono'
import { userService } from './services'
 
export const listUsers = async (c: Context) => {
  const users = await userService.findAll()
  return c.json({ users })
}
 
export const getUser = async (c: Context) => {
  const id = c.req.param('id')
  const user = await userService.findById(id)
  if (!user) {
    return c.json({ error: 'User not found' }, 404)
  }
  return c.json(user)
}

index.ts 是这个域的聚合器,只做一件事——把 routes 导出来:

// src/routes/users/index.ts
export { default } from './routes'

这种拆分把「路由定义」「请求处理」「业务逻辑」分到不同文件。handlers 可以被测试直接导入而不需要启动 HTTP 服务器,services 可以脱离 HTTP 上下文单独测试。

3. 分层架构:公开路由、认证路由、管理员路由

业务域是横向切分,权限层级是纵向切分。一个 SaaS 后端通常有三层路由:

  • 公开路由:注册、登录、健康检查、文档,不需要任何认证
  • 认证路由:普通用户操作,需要有效的 JWT 或 session
  • 管理员路由:后台管理操作,需要 admin 角色

这三层对应的中间件不同,把它们放在同一个 Hono 实例上会导致中间件条件分支过多。更稳妥的做法是为每一层创建独立的 Hono 实例:

// src/routes/index.ts
import { Hono } from 'hono'
import { authMiddleware } from '@/middleware/auth'
import { adminMiddleware } from '@/middleware/admin'
 
// 公开路由
import authRoutes from './auth/routes'
import healthRoutes from './health/routes'
 
// 认证路由
import usersRoutes from './users/routes'
import contentRoutes from './content/routes'
import billingRoutes from './billing/routes'
 
// 管理员路由
import adminUsersRoutes from './admin/users/routes'
import adminContentRoutes from './admin/content/routes'
import adminBillingRoutes from './admin/billing/routes'
 
const app = new Hono().basePath('/api/v1')
 
// 公开路由 — 不挂载认证中间件
app.route('/auth', authRoutes)
app.route('/health', healthRoutes)
 
// 认证路由 — 挂载认证中间件
const authenticated = new Hono().use('*', authMiddleware)
authenticated.route('/users', usersRoutes)
authenticated.route('/content', contentRoutes)
authenticated.route('/billing', billingRoutes)
app.route('/', authenticated)
 
// 管理员路由 — 挂载认证 + 管理员权限中间件
const admin = new Hono()
  .use('*', authMiddleware)
  .use('*', adminMiddleware)
admin.route('/users', adminUsersRoutes)
admin.route('/content', adminContentRoutes)
admin.route('/billing', adminBillingRoutes)
app.route('/admin', admin)
 
export default app

这里有一个关键细节:authenticatedadmin 是独立的 Hono 实例,中间件挂在实例级别。authMiddleware 只会在 authenticated.route()admin.route() 挂载的路由上生效,不会影响前面的公开路由。

01 篇讲过 app.route() 的子模块挂载机制。这里利用了同一条规则:中间件的作用范围跟着实例走。

4. 顶层聚合器模式

当域的数量超过 8 个时,主入口的 app.route() 列表会变得很长。可以在 routes/ 下维护一个 index.ts,负责按层收集所有子模块:

// src/routes/index.ts
import { Hono } from 'hono'
import { authMiddleware } from '@/middleware/auth'
import { adminMiddleware } from '@/middleware/admin'
 
// 按层导入
import { publicRoutes } from './public'
import { authenticatedRoutes } from './authenticated'
import { adminRoutes } from './admin'
 
export function createRouter() {
  const app = new Hono().basePath('/api/v1')
 
  app.route('/', publicRoutes)
 
  const auth = new Hono().use('*', authMiddleware)
  auth.route('/', authenticatedRoutes)
  app.route('/', auth)
 
  const admin = new Hono()
    .use('*', authMiddleware)
    .use('*', adminMiddleware)
  admin.route('/', adminRoutes)
  app.route('/admin', admin)
 
  return app
}
 
export default createRouter()

每层的聚合器再收集自己那一层的域:

// src/routes/authenticated/index.ts
import { Hono } from 'hono'
import usersRoutes from '../users/routes'
import contentRoutes from '../content/routes'
import billingRoutes from '../billing/routes'
import aiRoutes from '../ai/routes'
import notificationRoutes from '../notifications/routes'
 
const authenticatedRoutes = new Hono()
 
authenticatedRoutes.route('/users', usersRoutes)
authenticatedRoutes.route('/content', contentRoutes)
authenticatedRoutes.route('/billing', billingRoutes)
authenticatedRoutes.route('/ai', aiRoutes)
authenticatedRoutes.route('/notifications', notificationRoutes)
 
export { authenticatedRoutes }

新增一个功能域时的步骤变成了:

  1. src/routes/ 下创建新目录,写好 routes.tshandlers.tsservices.ts
  2. 在对应层的聚合器(authenticated/index.ts)里加一行 import + 一行 route()

不需要改主入口,不需要改其他域的代码。

5. 路由元数据

当项目规模继续增长,你会开始需要给路由附加额外信息:这条路由需要哪些权限、是否需要限流、属于哪个 API 版本、文档描述是什么。

Hono 没有内置的路由元数据机制,但可以通过 app.get('/:path', handler, middleware) 的中间件链来间接实现。另一种做法是在路由注册时用变量收集元数据:

// src/routes/content/routes.ts
import { Hono } from 'hono'
import type { RouteMeta } from '@/types/route-meta'
 
const content = new Hono()
 
// 在 handler 链中通过 c.set() 传递元数据
content.get('/articles', async (c, next) => {
  c.set('meta', {
    description: '获取文章列表',
    requiresAuth: true,
    rateLimit: { max: 100, window: '1m' },
  } satisfies RouteMeta)
  await next()
}, listArticles)
 
content.get('/articles/:id', async (c, next) => {
  c.set('meta', {
    description: '获取单篇文章',
    requiresAuth: false,
    rateLimit: { max: 200, window: '1m' },
  } satisfies RouteMeta)
  await next()
}, getArticle)
 
export default content

下游中间件可以读取这些元数据做统一处理:

// src/middleware/rate-limit.ts
import type { RouteMeta } from '@/types/route-meta'
 
export const rateLimitMiddleware = async (c, next) => {
  const meta = c.get('meta') as RouteMeta | undefined
  if (!meta?.rateLimit) {
    await next()
    return
  }
  // 根据 meta.rateLimit 执行限流逻辑
  // ...
  await next()
}

这种方式的代价是每条路由多一个中间件调用,用于 c.set('meta', ...)。如果路由数量在几百条以内,这个开销可以忽略。

6. 环境特定路由

有些路由只应该在开发或测试环境中存在。典型的例子是调试接口、测试数据播种接口、mock 接口。

// src/routes/index.ts
import { Hono } from 'hono'
import { env } from '@/config/env'
 
export function createRouter() {
  const app = new Hono().basePath('/api/v1')
 
  // 所有环境都挂载的路由
  app.route('/auth', authRoutes)
  app.route('/users', usersRoutes)
 
  // 仅开发/测试环境
  if (env.NODE_ENV !== 'production') {
    const devRoutes = new Hono()
    devRoutes.post('/seed', seedData)
    devRoutes.get('/debug/routes', listRegisteredRoutes)
    devRoutes.post('/mock/external/:service', mockExternalService)
    app.route('/dev', devRoutes)
  }
 
  return app
}

环境判断通过配置模块完成,不要在路由文件里直接读 process.env。01 篇的容易出错归属表里已经标过这个边界。

生产构建时 if 条件为 false,整个 dev 路由块不会被注册,也不会出现在生产环境的内存中。如果使用支持 tree-shaking 的构建工具,可以把 dev 路由放到独立的入口文件中,在生产构建时直接排除。

动态 import() 在 Node.js、Deno、Bun 运行时都支持。但 Cloudflare Workers 不支持运行时动态导入——如果部署目标是 Workers,环境路由需要在构建时通过入口分离来处理。

7. 路由匹配性能

Hono 的路由匹配器(Smart Router)会在注册阶段对所有路由构建一棵 trie 树。匹配请求时,trie 查找的时间复杂度接近 O(k),k 是路径段数,和路由总数无关。

这意味着即使你有 500 条路由,单次匹配的性能也不会明显下降。Hono 官方基准测试显示,1000 条路由的匹配延迟在微秒级。

但有几个细节值得注意:

通配符路由的代价app.get('/*', handler)app.get('/api/:path{.+}', handler) 这类宽泛匹配会在 trie 中产生回溯分支。少量通配符没有影响,但如果同一个前缀下有 10 个以上的通配符路由,匹配速度会退化。

路由注册顺序。01 篇讲过优先级规则:精确路由先注册,通配符路由后注册。当路由数量超过 100 条时,这条规则更重要——如果通配符路由注册在前面,后续注册的精确路由可能被跳过。

避免重复注册。如果同一个路径被注册了两次,Hono 不会报错,但只有先注册的那个会生效。在多人协作的大项目中,这种重复注册不容易被发现。可以在开发环境中维护一份路由注册表,用诊断路由列出所有已注册路由,方便排查。

8. 常见错误模式

8.1 god-router 模式

所有 100+ 条路由写在一个文件里,文件超过 1000 行。这个问题在 01 篇已经提过,这里只补一条判断标准:当你需要在文件内用注释分隔符(// ========== Users ========== )来区分不同模块时,拆分时机已经到了。

8.2 混合组织策略

同一个项目里,有的域按业务功能拆分(routes/users/routes/billing/),有的按 HTTP 资源拆分(routes/get-user.tsroutes/create-user.ts)。两种策略本身都没问题,但混在同一个项目里会让新成员无法建立稳定预期。

判断用哪种策略的依据是域内的路由数量。如果一个域只有 3-5 条路由,单文件就够了,不需要拆目录。如果一个域有 15 条以上路由,按目录拆分更合适。

8.3 前缀重复定义

子模块自己写了前缀,挂载时主入口又加了一层前缀:

// src/routes/users/routes.ts — 错误示范
const users = new Hono()
users.get('/users', listUsers)     // 注意这里写了 /users
users.get('/users/:id', getUser)
export default users
 
// src/routes/index.ts
app.route('/users', users)
// 最终路径:GET /users/users — 前缀重复了

子模块的路由路径不应该包含自己被挂载时的前缀。子模块只需要写「挂载点之后」的路径:

// src/routes/users/routes.ts — 正确写法
const users = new Hono()
users.get('/', listUsers)          // 相对于挂载点的路径
users.get('/:id', getUser)
export default users

8.4 中间件挂载位置不当

把本应只在特定路由生效的中间件挂到了全局:

// 错误:authMiddleware 会拦截公开路由
app.use('*', authMiddleware)
app.route('/auth', authRoutes)  // 登录接口也要认证?

中间件的作用范围跟着实例和挂载路径走。如果只有部分路由需要认证,把认证中间件挂到这些路由的父实例上,而不是全局。第 3 节的分层架构已经在处理这个问题。

9. 实战参考:10 个功能模块的 SaaS 后端

把前面的模式组合起来,一个包含 10 个功能模块的 SaaS 后端目录结构大致如下:

src/routes/
  index.ts                 # 顶层聚合器
  public/
    index.ts               # 公开路由聚合
    health/routes.ts
    auth/routes.ts
  authenticated/
    index.ts               # 认证路由聚合
    users/routes.ts
    teams/routes.ts
    content/routes.ts
    billing/routes.ts
    ai/routes.ts
    notifications/routes.ts
    webhooks/routes.ts
  admin/
    index.ts               # 管理员路由聚合
    users/routes.ts
    billing/routes.ts
    analytics/routes.ts
  dev/
    index.ts               # 开发环境路由聚合
    seed/routes.ts
    debug/routes.ts
  middleware/
    auth.ts
    admin.ts
    rate-limit.ts
  types/
    route-meta.ts

顶层聚合器负责按层挂载,每层聚合器负责收集自己那一层的域。新增模块只改对应层的聚合器,不动其他层。第 4 节的代码已经展示了这个模式的具体写法。

延伸阅读

总结

大型项目的路由组织需要解决三个层面的问题:

  1. 横向拆分:按业务域划分目录,每个域包含 routes、handlers、services,文件职责单一
  2. 纵向分层:公开路由、认证路由、管理员路由使用独立的 Hono 实例,中间件作用范围跟着实例边界走
  3. 聚合注册:顶层聚合器和分层聚合器各司其职,新增模块只改对应层的聚合器文件

路由元数据和环境路由是规模继续增长后的自然延伸——元数据让中间件可以按路由做差异化处理,环境路由让调试接口不会出现在生产环境。

这一篇的路由组织解决了「路由放在哪里」的问题。但路由挂载好之后,接下来要处理的是每条路由前面的中间件如何按路由粒度配置——这就是下一篇「路由级中间件」要展开的内容。