某天凌晨,一个刚上线的服务收到告警:DELETE /api/v1/orders/42 返回了 404,但这个接口明明存在。值班同学翻遍了路由表,最后发现删单接口只注册了 POST /orders/42/delete——路径对了、方法错了,koa-router 直接判定未命中。这一节从这类事故出发,把路由匹配的规则一次讲透:怎么注册、怎么捕获参数、怎么挂路由级中间件、多模块路由怎么组织。
koa-router 的匹配是二元组匹配:请求的 HTTP 方法与路径必须同时命中才算匹配。这带来两个直接推论。推论一:同一路径可以注册多个方法,各走各的处理器——
const Router = require('koa-router'); const router = new Router({ prefix: '/api/v1/orders' }); router.get('/', listOrders); // GET /api/v1/orders 列表 router.post('/', createOrder); // POST /api/v1/orders 创建 router.get('/:id', getOrder); // GET /api/v1/orders/42 详情 router.patch('/:id', updateOrder); // PATCH /api/v1/orders/42 部分更新 router.delete('/:id', deleteOrder); // DELETE /api/v1/orders/42 删除
推论二:方法没注册而路径存在时,router.allowedMethods() 中间件负责把"路径存在但方法不对"翻译成 405(并按规范回 Allow 头),第 3.2 节强调过它与 routes 成对出现。开头那起事故的正确修法正是:注册 DELETE /orders/:id,让"删除"回归 DELETE 语义,而不是再造一个动词路径。
:id 语法定义命名参数,值落在 ctx.params。匹配顺序遵循注册顺序:先注册的规则先试,命中即停。这让优先级有了明确的表达方式——特殊的写前面,通用的写后面:
router.get('/special', fromSpecial); // 精确路径,先注册 router.get('/:id', byId); // 通配参数,后注册 // GET /special 命中 fromSpecial(精确规则在前) // GET /42 命中 byId // 若两条注册顺序颠倒,GET /special 会被 byId 吃掉,ctx.params.id === 'special'
参数还支持前缀修饰与正则约束,/file/:name(.+\\.pdf) 捕获带后缀的路径段,/date/:year(\\d{4}) 限定数字。用得上的场景不多但救急——需要按后缀分发、按格式约束时,比在处理器里手写正则清晰。命名参数之外,router.get(/^\/px-(\d+)$/, ...) 也接受正则对象,捕获组落在 ctx.match,属于冷知识,知道有这条路即可。
koa-router 的真正威力在于每条路由可以带自己的前置层,写在路径与处理器之间:
const { auth, requireRole } = require('../middleware/auth'); const { validate } = require('../middleware/validate'); router.post('/', auth(), // 登录校验 validate(createOrderSchema), // 参数校验(4.3 节实现) createOrder // 最后才是业务 ); router.delete('/:id', auth(), requireRole('admin'), // 角色校验只有删单需要 deleteOrder );
这层机制让"哪些接口需要登录、哪些需要管理员"成为结构化事实——看路由声明就知道,不用点进处理器翻代码。与之配合的纪律是:路由级中间件只放横切关注点(鉴权、校验、限流、审计),业务逻辑永远待在最后一个函数里。
多路由器的组织按资源拆分,主入口聚合挂载:
// routes/index.js const Router = require('koa-router'); const router = new Router(); router.use(orders.routes(), orders.allowedMethods()); router.use(users.routes(), users.allowedMethods()); router.use(files.routes(), files.allowedMethods()); app.use(router.routes()).use(router.allowedMethods());
如果不同模块想挂不同前缀的全局层(比如 orders 全员限流、files 单独放宽),用 router.use('/orders', orderLimiter()) 做命名空间级中间件——它作用于该前缀下所有路由,介于全局层与单路由层之间,是第三档粒度。
koa-router 支持 nestedRouter.routes() 式的嵌套挂载,但经验是嵌套超过一层就该改成扁平拆分:嵌套层会拉长匹配链、模糊前缀归属,排查匹配问题时多一层心智负担。扁平结构(每资源一个顶层 router,前缀各自声明)配合入口聚合,足以支撑几十个资源的体量;真到了需要"路由树"的规模,先考虑的应该是拆服务,而不是玩路由嵌套。
⚠️ 常见坑:注册路由在
app.use(router.routes())之后才执行。koa-router 的路由注册发生在 require 阶段,而匹配表在 routes() 挂载时定稿——把 use 写在业务模块 import 之前、路由定义散落在延迟加载的模块里,都会造成"明明注册了却 404"。装配顺序纪律(入口先装配、路由模块只声明)能把这类问题根除。
把这些规则合起来,"请求没找到归宿"就有了两种精确形态。路径完全未注册 → 404 Not Found;路径存在但方法未注册 → 405 Method Not Allowed(带 Allow 头)。开篇事故的排查路径因此可以标准化:先看 404 还是 405,404 查路径与前缀,405 查方法注册——分清两者,路由类告警的处理时间能省一半。
💡 关键直觉:路由表是服务的第一份 API 文档。资源名是否规范、方法是否齐备、哪些路由带鉴权——读懂一张路由表,服务的一半行为已经清楚。反过来,写路由时保持这份"文档自觉",命名与层次自然不会跑偏。
路由把请求送到了归宿,但归宿里怎么把"订单"这个业务概念落成规范的资源操作——下一节用 RESTful 的方法矩阵完整实现订单接口,让语义与代码对齐。