大型项目路由组织
要点
- 当路由数量超过 50 条时,按文件平铺的组织方式会遇到查找困难、命名冲突和职责混乱的问题
- 按业务域分组(users、billing、content、ai)让每个模块文件只关注自己的路由,主入口只做挂载
- 分层架构把公开路由、认证路由和管理员路由拆到不同的 Hono 实例上,中间件的作用范围跟着实例边界走
- Feature module 模式让每个功能模块自带 routes、handlers、services、types,形成自包含的目录结构
- 路由聚合器(
routes/index.ts)负责收集所有子模块,新增模块只需在聚合器加一行注册 - 路由元数据(metadata)可以在注册时附加权限、文档描述等信息,供中间件统一消费
1. 路由数量增长后会遇到什么
01 篇的路由组织方式是「一个文件写所有路由」或「按模块拆成几个文件再挂载」。路由数量在 10-20 条时,这两种方式没有本质差别。
当项目扩展到 50 条以上路由、涉及 5 个以上业务域时,平铺结构开始出现具体问题:
- 单个路由文件超过 300 行,新增路由时要在大量
app.get()/app.post()中定位插入位置 - 不同业务域的中间件需求不同,但平铺结构下只能在每个 handler 内部单独调用
- 团队成员同时在同一个文件里改路由,合并冲突频率升高
- 删除某个功能时,需要在一堆混杂的路由中找到属于该功能的所有条目
也许你会觉得「把文件拆细一点就好」。但拆文件只是把问题推迟了——如果 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 usershandlers.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这里有一个关键细节:authenticated 和 admin 是独立的 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 }新增一个功能域时的步骤变成了:
- 在
src/routes/下创建新目录,写好routes.ts、handlers.ts、services.ts - 在对应层的聚合器(
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.ts、routes/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 users8.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 节的代码已经展示了这个模式的具体写法。
延伸阅读
- 01-基础路由 —
app.route()和basePath()的基础用法 - 07-路由分组与命名空间 — 路由分组的前缀管理细节
- 03.02-路由匹配原理 — Smart Router 的 trie 匹配机制
- Hono Routing 文档 — Hono 路由 API 参考
总结
大型项目的路由组织需要解决三个层面的问题:
- 横向拆分:按业务域划分目录,每个域包含 routes、handlers、services,文件职责单一
- 纵向分层:公开路由、认证路由、管理员路由使用独立的 Hono 实例,中间件作用范围跟着实例边界走
- 聚合注册:顶层聚合器和分层聚合器各司其职,新增模块只改对应层的聚合器文件
路由元数据和环境路由是规模继续增长后的自然延伸——元数据让中间件可以按路由做差异化处理,环境路由让调试接口不会出现在生产环境。
这一篇的路由组织解决了「路由放在哪里」的问题。但路由挂载好之后,接下来要处理的是每条路由前面的中间件如何按路由粒度配置——这就是下一篇「路由级中间件」要展开的内容。