Koa 路由、CORS 与健康检查:把 API 骨架搭起来
用 koa-router 按方法与路径组织接口,用 @koa/cors 放行跨域,并设计返回固定 JSON 的健康检查。本文讲清浏览器预检请求的原理、统一错误格式、存活与就绪探针的区别、端口配置与排错清单。
- Koa
- 操作
- 入门
- 更新于 2026-10-31
写 Node.js 服务时,有三件事几乎必然出现在同一个仓库里,而它们出错的时机又特别刁钻:接口要按方法和路径分清楚;前端在另一个端口上跑时,浏览器会把请求拦掉;容器起来之后得有个接口告诉运维「我活着,而且能干活了」。
这三件事看起来是三个独立功能,实际上共用同一条 Koa 中间件链。顺序错了、职责重叠了,症状还会互相伪装:CORS 配对了但被路由挡在后面,浏览器报跨域错误,后端日志里却干干净净;健康检查返回 200,但容器里的数据库连接早断了;路由漏了 allowedMethods(),前端拿到的 405 变成了 404。
所以这篇不按「三个功能」写,而按「一次请求怎么穿过这条链」来写。读完你应该能自己回答:这条请求在哪一层被拦下、是谁给它回的、响应头是谁加的,以及线上出问题时该先看哪一层。
一、这套骨架要解决的四类问题
先把问题定义清楚,后面的每一层设计都有对应:
| 问题 | 典型症状 | 对应的层 |
|---|---|---|
| 接口没有统一入口 | 路径各写各的,前端拿不到稳定契约 | 路由层 |
| 前端跨端口被拦 | 控制台报 CORS,后端日志却显示请求到了 | CORS 层 |
| 失败格式五花八门 | 同一个客户端要写四套错误分支 | 错误层 |
| 进程活着但不可用 | 容器一直收流量,真正的工作却做不了 | 健康检查层 |
注意最后一行是重点:「进程在」和「服务能用」是两件事。数据库连接断了、缓存连不上、事件循环被同步计算堵死,进程都还活着,kill -0 都成功,但这个实例不该继续接收流量。健康检查这一层必须把这两件事拆开,后面第六节会具体讲。
二、全局:一次请求会穿过哪些层
先看全局。Koa 的中间件是洋葱模型(细节见 koa/onion-model),但本文只关心顺序这件事,因为后面所有坑都源于顺序。
一个请求从上到下依次经过:
| 顺序 | 中间件 | 职责 | 为什么在这个位置 |
|---|---|---|---|
| 1 | 错误处理 | 把异常收敛成统一 JSON | 必须最外层,才能兜住后面所有中间件抛出的错 |
| 2 | 访问日志 | 记录方法、路径、状态码、耗时 | 要在最外层才能统计到总耗时;探针路径要跳过 |
| 3 | CORS | 给响应加跨域头、回应预检请求 | 必须在路由之前,见 4.5 节 |
| 4 | 请求体解析 | 把 JSON 请求体变成 ctx.request.body |
必须在路由之前,否则路由里读到 undefined |
| 5 | 路由 routes() |
按方法和路径匹配到处理函数 | 命中后默认不再往下走 |
| 6 | 路由 allowedMethods() |
处理 OPTIONS 与「方法不允许」的 405 | 依赖第 5 层写入的匹配信息 |
这张表是全文的骨架,后面每一节都会回到它。
先记住一条贯穿全文的规则:Koa 里命中路由后,路由处理函数不调用 next(),注册在 routes() 之后的中间件就不会执行。CORS、日志这类全局能力必须放在路由之前,否则会出现「有时生效有时不生效」的诡异现象——因为只有没命中任何路由的请求才会继续往下走。
三、路由层:把接口按方法和路径组织起来
3.1 路由本身就是中间件
koa-router 提供的是一个 Router 类,用法如下(这是 koa-router 12.x 的写法,也是绝大多数老项目里的样子):
import Router from 'koa-router';
const router = new Router({ prefix: '/api' }); // prefix 统一前缀,省掉每条路由重复写 /api
router.get('/articles/:id', (ctx) => {
const id = ctx.params.id; // 路径参数从 ctx.params 取
ctx.body = { ok: true, data: { id } };
});
三点值得注意:
- Router 是中间件工厂。
new Router()只是把路由规则登记到一个栈里,真正参与请求处理的是router.routes()返回的那个函数。 prefix是路由级别的统一前缀。new Router({ prefix: '/api' })之后,router.get('/articles/:id', ...)实际响应的是/api/articles/:id。- 路径参数进
ctx.params。:id是参数名,ctx.params.id取值;多个参数就是/articles/:id/comments/:commentId这样并列。
3.2 两个注册缺一不可:routes() 与 allowedMethods()
这是最常见的错误点。只注册 routes() 不注册 allowedMethods(),路由能正常匹配,但两件事没人做:
- OPTIONS 请求没人正确回应。浏览器发来的预检请求会落到 404;
- 405 不会出现。路径存在但方法不允许时(比如对一个只支持 GET 的路径发 DELETE),返回的仍是 404,而不是带
Allow头的 405。
正确写法是两个都注册,而且有先后:
app.use(router.routes()); // 先匹配路径
app.use(router.allowedMethods()); // 再根据匹配结果决定 OPTIONS / 405
allowedMethods() 之所以必须放在 routes() 之后,是因为它依赖 routes() 写入的匹配信息(路径匹配上了哪些路由、这些路由允许哪些方法)。它的工作方式也很特别——它是一个回程中间件:先 await next() 让后面的逻辑跑完,再回头看状态码决定要不要改成 405。
按 koa-router 12.x 与 @koa/router 15.x 的源码,这块逻辑是:
- 只有当响应还没设置(状态码为空或 404)时它才介入,不会覆盖你已经写好的响应;
- 请求方法 OPTIONS 且路径能匹配到路由 → 返回 200,并写上
Allow头,列出允许的方法; - 路径能匹配但方法不在允许列表里 → 返回 405,同样带
Allow头; - 方法连路由器都没实现(比如
TRACE)→ 返回 501。
3.3 参数分别从哪来
一个接口的参数通常来自三个地方,别混:
| 来源 | 写法 | 取值位置 |
|---|---|---|
| 路径参数 | /articles/:id |
ctx.params.id |
| 查询参数 | /articles?page=2 |
ctx.query.page(是字符串) |
| 请求体 | POST 的 JSON | ctx.request.body(需要请求体解析中间件) |
查询参数永远是字符串,ctx.query.page 拿到的是 "2" 不是 2,需要自己转换并校验。
3.4 koa-router 与 @koa/router:两个包名,一套 API
这块必须说清楚,否则你会踩到「装完包就一堆警告」的坑。
- 社区里这个路由包有两个名字:老的
koa-router和现在的@koa/router。npm 上koa-router的最新版本是 14.0.0,并且已经被标记为废弃(deprecation 提示写明从 v9 起改用@koa/router)。 @koa/router是同一个仓库(koajs/router)维护的正式包,API 完全同构:new Router({ prefix })、router.get/post/put/delete、ctx.params、router.routes()、router.allowedMethods()一模一样。- 两者唯一的实际差别之一是类型:
@koa/router自带 TypeScript 类型;而koa-router12.x 不带类型,需要装@types/koa-router。
所以迁移基本是改一行 import:
// 老项目(koa-router 12.x):默认导出
import Router from 'koa-router';
// 新项目(@koa/router 15.x):自带类型,默认导出
import Router from '@koa/router';
另一个真实的迁移坑:两者底层的
path-to-regexp大版本不同。koa-router12.x 用的是 path-to-regexp 6.x,路由里可以写:id(\\d+)这种内联正则;@koa/router15.x 用的是 path-to-regexp 8.x,内联正则写法不再被支持,需要改用参数校验中间件。接手老项目时,先全局搜一遍路由里的:xxx(模式。
选型结论:新项目直接用 @koa/router;老项目继续用 koa-router 12.x 也不影响运行,本文后面的代码统一用 @koa/router,两者的差异只有上面这一处 import。
四、CORS 层:浏览器为什么要拦你
4.1 简单请求与预检请求
浏览器的同源策略默认禁止跨源读取响应。「跨源」指协议、域名、端口三者中任意一个不同——前端在 http://localhost:5173,后端在 http://localhost:3001,端口不同就已经跨源了。
跨源请求分两类:
| 类型 | 判定标准 | 浏览器行为 |
|---|---|---|
| 简单请求 | GET / HEAD / POST,且 Content-Type 只在 application/x-www-form-urlencoded、multipart/form-data、text/plain 之内,且没有自定义头 |
直接发,响应需要带 Access-Control-Allow-Origin 才能被 JS 读取 |
| 预检请求 | 其它一切:application/json、带 Authorization 或任何自定义头、方法是 PUT / DELETE / PATCH |
先发一个 OPTIONS,预检通过后才发真正的请求 |
所以「我用的是 GET,为什么还要 OPTIONS」这个疑问,答案通常是:请求带了 Content-Type: application/json,或者带了 Authorization 头。
4.2 预检请求长什么样
预检请求本身不带业务数据,只有两个头是给服务器看的:
OPTIONS /api/articles HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,authorization
服务器要在响应里回答三件事:
Access-Control-Allow-Origin:允许哪个来源(必须是具体来源,不能是*);Access-Control-Allow-Methods:允许哪些方法;Access-Control-Allow-Headers:允许哪些请求头。
外加一个可选但很值得加的头:
Access-Control-Max-Age:告诉浏览器这次预检结果可以缓存多少秒。缓存生效后,同一个接口的同类请求就不会每次都先发 OPTIONS 了。
4.3 @koa/cors 的选项
@koa/cors 的当前版本是 5.0.0,选项不多但每个都有明确用途:
| 选项 | 作用 | 对应的响应头 | 备注 |
|---|---|---|---|
origin |
允许的来源 | Access-Control-Allow-Origin |
支持字符串、正则、数组,或 (ctx) => string 函数;不传时默认 * |
allowMethods |
允许的方法 | Access-Control-Allow-Methods |
默认 GET,HEAD,PUT,POST,DELETE,PATCH |
allowHeaders |
允许的请求头 | Access-Control-Allow-Headers |
不设置时自动回显请求里的 Access-Control-Request-Headers |
exposeHeaders |
允许 JS 读取的响应头 | Access-Control-Expose-Headers |
跨域下 JS 默认读不到 Content-Range 之类的头,要显式暴露 |
credentials |
是否允许带凭据 | Access-Control-Allow-Credentials |
布尔值,或 (ctx) => boolean |
maxAge |
预检缓存秒数 | Access-Control-Max-Age |
只在预检响应里生效 |
keepHeadersOnError |
出错时是否保留已设置的跨域头 | —— | 默认开启 |
secureContext |
附加 COOP / COEP 头 | Cross-Origin-Opener-Policy 等 |
默认关闭 |
privateNetworkAccess |
处理私有网络预检 | Access-Control-Allow-Private-Network |
默认关闭 |
注意这个包没有 preflightContinue 选项——那是 Express 生态里 cors 包的选项(preflightContinue: true 表示预检响应后继续往下传给下一个处理器)。在 Koa 里 @koa/cors 的行为是固定的:预检请求由它直接终结(状态码 204),不会交给后面的中间件。
4.4 凭据、origin 回显与 Vary
两条硬规则:
- 凭据模式(
credentials: true)下,Access-Control-Allow-Origin不能是*。浏览器会直接拒绝这种组合。@koa/cors5.0.0 的源码里做了保护:当credentials为真而origin是*时,它会把*换成请求里的真实来源。但更稳妥、也更常见的做法是自己写白名单函数,明确回显来源。 - 回显来源必须配
Vary: Origin。否则同一份响应被 CDN 或反向代理缓存后,会把 A 站点的跨域头发给 B 站点。@koa/cors会自动给每个响应加Vary: Origin,这一点省心。
4.5 注册顺序:必须在路由之前
这是跨域问题里最高频的一条。原因在第二节已经说过:命中路由后,请求不会再往下走。如果 CORS 注册在 router.routes() 之后,正常请求(命中路由的那些)根本走不到 CORS 中间件,于是响应里没有跨域头,浏览器报跨域错误——而后端日志里明明有一条 200 记录。
正确顺序:
app.use(cors({ origin: 'http://localhost:5173' })); // 先放行
app.use(router.routes()); // 再匹配路由
app.use(router.allowedMethods());
4.6 预检到底是谁回的
最后说一个容易困惑的细节:OPTIONS 请求可能有两个不同的回复者。
- 带
Access-Control-Request-Method头的真预检:被@koa/cors拦下,返回 204 和一组Access-Control-Allow-*头,请求不会再进入路由。 - 不带这个头的普通 OPTIONS(比如你手动
curl -X OPTIONS):@koa/cors判断这不是预检,放它继续往下走,于是由router.allowedMethods()回应,返回 200 和Allow头。
两个都不是「错误」,但排查时要知道自己在测哪一个。
五、错误层:一份统一的失败格式
前端最怕的不是接口报错,而是每个接口报得不一样。统一错误格式的做法是在最外层放一个 try/catch 中间件,把所有异常收敛成一种结构:
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
const status = err.status ?? 500; // ctx.throw(404, '...') 会带上 status
ctx.status = status;
ctx.body = {
ok: false,
error: {
code: status === 500 ? 'INTERNAL_ERROR' : 'REQUEST_ERROR',
message: status === 500 ? '服务器内部错误' : err.message,
},
};
}
});
这里有三个设计决定值得展开:
- 错误中间件必须最外层。它要兜住后面所有中间件抛出的异常;如果它自己被注册在路由之后,命中的路由永远不会走到它。
status优先取自异常。Koa 的ctx.throw(404, '文章不存在')会抛出一个带status的错误,所以业务代码只要「按语义抛错」,格式就由这一层统一。- 5xx 不把内部报错原文透给客户端。堆栈、SQL 语句、内部路径对攻击者都是信息,对客户端只需要一句「服务器内部错误」;详细错误应该进服务端日志。
顺带一句:Koa 自己在 ctx.onerror 里也会兜底,但那套输出是纯文本、且按 err.expose 决定是否暴露 message,格式和你的业务契约对不上,所以业务项目里几乎都要自己加这一层。
六、健康检查层:存活与就绪是两件事
6.1 两种探针的差别
| 存活探针(liveness) | 就绪探针(readiness) | |
|---|---|---|
| 回答的问题 | 进程还在吗 | 依赖可用吗 |
| 检查内容 | 事件循环能不能响应 | 数据库、缓存、消息队列等 |
| 失败含义 | 进程卡死或崩了 | 暂时做不了业务 |
| 失败后的动作 | 重启容器 | 摘掉流量,不要重启 |
| 典型路径 | /api/health |
/api/ready |
把两者混成一个接口是最常见的错误:把数据库检查写进存活探针,结果数据库抖动时容器被反复重启,把一次短暂故障放大成一场雪崩。
6.2 探针的纪律
探针是机器发起的调用,特点是高频、无人在意、失败会被反复重试。因此:
- 必须轻量:不查业务大表,不调外部接口,不写数据;
- 不写访问日志:每几秒一条日志,几小时就能刷爆磁盘,正确的做法是在日志中间件里显式跳过探针路径;
- 只在内网暴露:探针不该有公网入口,用网络策略或网关限制来源;
- 返回固定 JSON:比如
{ ok: true }。探针只看状态码,body 保持稳定可以让人和机器用同一套判断。
router.get('/health', (ctx) => {
ctx.body = { ok: true }; // 固定内容,不带时间戳、不带依赖状态
});
就绪探针则要检查依赖,失败时返回 503(服务不可用),成功返回 200:
router.get('/ready', async (ctx) => {
const storageOk = await checkStorage(); // 只做轻量 ping,不查业务表
ctx.status = storageOk ? 200 : 503;
ctx.body = { ok: storageOk, checks: { storage: storageOk ? 'ok' : 'fail' } };
});
七、动手:从零到跑起来
下面这一节是可以照抄的完整工程。为了让示例不依赖数据库,数据源用内存 Map,接口形状与接数据库时保持一致。
7.1 目录结构
koa-api-skeleton/
├── package.json
├── tsconfig.json
└── src/
├── config.ts # 端口与跨域白名单
├── dependencies.ts # 依赖探针
├── app.ts # 组装中间件与路由
├── server.ts # 监听端口
├── middleware/
│ ├── error-handler.ts # 统一错误格式
│ └── request-log.ts # 访问日志(跳过探针)
└── routes/
├── index.ts # 根路由,统一 /api 前缀
├── health.ts # 存活与就绪探针
└── articles.ts # 业务路由示例
7.2 工程配置
package.json(版本号取自本文写作时 npm 上的在架版本):
{
"name": "koa-api-skeleton",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js"
},
"dependencies": {
"@koa/bodyparser": "^6.1.0",
"@koa/cors": "^5.0.0",
"@koa/router": "^15.7.0",
"koa": "^3.2.1"
},
"devDependencies": {
"@types/koa": "^3.0.3",
"@types/node": "^26.1.0",
"typescript": "^5.9.3"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
装依赖并编译:
# 安装依赖并把 TypeScript 编译成 dist/
npm install
npm run build
# 启动服务(另开一个终端做后面的验证)
npm start
7.3 配置与依赖检查
src/config.ts:
// 端口只从环境变量读:本地默认 3001,部署时由平台注入 PORT 覆盖
export const config = {
port: Number(process.env.PORT ?? 3001),
// 只放行明确的前端来源;生产环境用环境变量注入,别把域名写死进代码
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
.split(',')
.map((origin) => origin.trim()),
};
src/dependencies.ts:
// 依赖探针:真实项目里这里应该是一次轻量 ping,而不是查业务表
export async function checkStorage(): Promise<boolean> {
// 接 SQLite 时换成一次 SELECT 1 之类的空查询
return true;
}
7.4 错误处理中间件
src/middleware/error-handler.ts:
import type { Middleware } from 'koa';
// 最外层的错误中间件:把异常收敛成同一种 JSON,前端只需要处理一种失败格式
export const errorHandler: Middleware = async (ctx, next) => {
try {
await next();
} catch (err) {
const e = err as { status?: number; message?: string };
const status = e.status ?? 500; // ctx.throw(404, '...') 会带上 status
// 5xx 打服务端日志,4xx 是客户端问题,不刷错误日志
if (status >= 500) console.error('[unhandled]', err);
ctx.status = status;
ctx.body = {
ok: false,
error: {
code: status === 500 ? 'INTERNAL_ERROR' : 'REQUEST_ERROR',
// 5xx 不把内部报错原文透出去,避免泄露堆栈与表结构
message: status === 500 ? '服务器内部错误' : (e.message ?? '请求失败'),
},
};
}
};
7.5 访问日志中间件
src/middleware/request-log.ts:
import type { Middleware } from 'koa';
// 探针路径不写访问日志:探针调用频率高,日志会被刷爆
const PROBE_PATHS = new Set(['/api/health', '/api/ready']);
// 访问日志:记录方法、路径、状态码与耗时
export const requestLog: Middleware = async (ctx, next) => {
const start = Date.now();
await next();
if (PROBE_PATHS.has(ctx.path)) return;
console.log(`${ctx.method} ${ctx.originalUrl} ${ctx.status} ${Date.now() - start}ms`);
};
7.6 路由模块
src/routes/index.ts:
import Router from '@koa/router';
import { registerHealthRoutes } from './health.js';
import { registerArticleRoutes } from './articles.js';
// 根路由统一带 /api 前缀,各功能模块只关心自己那几段路径
export function createApiRouter(): Router {
const router = new Router({ prefix: '/api' });
registerHealthRoutes(router);
registerArticleRoutes(router);
return router;
}
src/routes/health.ts:
import type { Router } from '@koa/router';
import { checkStorage } from '../dependencies.js';
// 探针路由:只回答「进程还在吗」「依赖可用吗」,不碰业务数据
export function registerHealthRoutes(router: Router): void {
// 存活探针:进程能响应就返回 200,不碰任何外部依赖
router.get('/health', (ctx) => {
ctx.body = { ok: true };
});
// 就绪探针:依赖不可用返回 503,让上游摘流量而不是重启容器
router.get('/ready', async (ctx) => {
const storageOk = await checkStorage();
ctx.status = storageOk ? 200 : 503;
ctx.body = { ok: storageOk, checks: { storage: storageOk ? 'ok' : 'fail' } };
});
}
src/routes/articles.ts:
import type { Router } from '@koa/router';
type Article = {
id: string;
title: string;
body: string;
};
// 演示用的内存数据源;换成数据库时保持这几个接口的形状不变
const articles = new Map<string, Article>([
['1', { id: '1', title: 'Koa 中间件实战', body: '洋葱模型决定中间件的进出顺序。' }],
]);
// 把文章相关路由挂到已有的根路由上
export function registerArticleRoutes(router: Router): void {
router.get('/articles', (ctx) => {
ctx.body = { ok: true, data: [...articles.values()] };
});
router.get('/articles/:id', (ctx) => {
const article = articles.get(ctx.params.id); // 路径参数从 ctx.params 取
if (!article) ctx.throw(404, '文章不存在'); // 抛给最外层错误中间件统一处理
ctx.body = { ok: true, data: article };
});
router.post('/articles', (ctx) => {
const payload = ctx.request.body as { title?: string; body?: string } | undefined;
if (!payload?.title) ctx.throw(400, '缺少 title'); // 参数不对是 4xx,不是 500
const id = String(articles.size + 1);
const article: Article = { id, title: payload.title, body: payload.body ?? '' };
articles.set(id, article);
ctx.status = 201;
ctx.body = { ok: true, data: article };
});
router.put('/articles/:id', (ctx) => {
const article = articles.get(ctx.params.id);
if (!article) ctx.throw(404, '文章不存在');
const payload = ctx.request.body as { title?: string; body?: string } | undefined;
article.title = payload?.title ?? article.title;
article.body = payload?.body ?? article.body;
ctx.body = { ok: true, data: article };
});
router.delete('/articles/:id', (ctx) => {
if (!articles.delete(ctx.params.id)) ctx.throw(404, '文章不存在');
ctx.status = 204; // 204 表示成功且无响应体
});
}
7.7 组装应用与启动
src/app.ts:
import Koa from 'koa';
import cors from '@koa/cors';
import { bodyParser } from '@koa/bodyparser';
import { config } from './config.js';
import { errorHandler } from './middleware/error-handler.js';
import { requestLog } from './middleware/request-log.js';
import { createApiRouter } from './routes/index.js';
export function createApp(): Koa {
const app = new Koa();
// 1. 错误中间件放最外层,才能兜住后面所有中间件抛出的异常
app.use(errorHandler);
// 2. 访问日志
app.use(requestLog);
// 3. CORS 必须在路由之前:命中路由的请求不会往下走,跨域头就加不上
app.use(cors({
origin: (ctx) => {
const requestOrigin = ctx.get('Origin');
// 不在白名单里就返回空串:不写跨域头,浏览器会拦下这次响应
return config.corsOrigins.includes(requestOrigin) ? requestOrigin : '';
},
credentials: true, // 需要带 Cookie 时必须配合具体来源,不能用 *
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 600, // 预检结果缓存 10 分钟,减少 OPTIONS 往返
}));
// 4. 解析 JSON 请求体,必须在路由之前,否则 ctx.request.body 是 undefined
app.use(bodyParser({ enableTypes: ['json'] }));
// 5. 路由:routes() 负责匹配,allowedMethods() 负责 OPTIONS 与 405
const router = createApiRouter();
app.use(router.routes());
app.use(router.allowedMethods());
return app;
}
src/server.ts:
import { createApp } from './app.js';
import { config } from './config.js';
const app = createApp();
app.listen(config.port, () => {
console.log(`API 已启动:http://localhost:${config.port}/api`);
});
7.8 验证:六条命令
服务起来之后,按下面六条逐个验。以下响应是按上面的代码推导出的预期值,不是实测输出,你本地实测时个别头部字段可能略有差异,以实际返回为准。
# 1) 存活探针:应返回 ok: true
Invoke-RestMethod http://localhost:3001/api/health | ConvertTo-Json
预期响应:
{
"ok": true
}
# 2) 预检请求:模拟浏览器先发的 OPTIONS,应拿到 204 与跨域响应头
curl.exe -i -X OPTIONS http://localhost:3001/api/articles `
-H "Origin: http://localhost:5173" `
-H "Access-Control-Request-Method: POST" `
-H "Access-Control-Request-Headers: content-type,authorization"
预期响应头(关键几行):
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 600
Vary: Origin
# 3) 方法不允许:对只支持 GET 的路径发 DELETE,应拿到 405 与 Allow 头
curl.exe -i -X DELETE http://localhost:3001/api/health -H "Origin: http://localhost:5173"
预期响应:
HTTP/1.1 405 Method Not Allowed
Allow: HEAD, GET
# 4) 路径不存在:应拿到统一错误格式的 404
curl.exe -i http://localhost:3001/api/articles/999
预期响应:
{
"ok": false,
"error": {
"code": "REQUEST_ERROR",
"message": "文章不存在"
}
}
# 5) 写接口:带 JSON 请求体,验证请求体解析与 201
curl.exe -i -X POST http://localhost:3001/api/articles `
-H "Content-Type: application/json" `
-d '{"title":"第一篇","body":"正文"}'
预期响应:状态码 201,body 是 {"ok":true,"data":{"id":"2","title":"第一篇","body":"正文"}}。
# 6) 端口从环境变量读:换个端口启动,验证部署时的覆盖行为
$env:PORT = 3101
npm start
预期:新进程监听 3101,Invoke-RestMethod http://localhost:3101/api/health 依然返回 ok: true。
八、进阶与边界
8.1 拆分模块与嵌套路由
路由一多就该拆文件。本文的做法是一个根路由 + 若干注册函数:根路由统一挂 /api 前缀,各模块只注册自己那几段路径。好处是模块之间不互相知道前缀,将来要把 /api 改成 /v2,只改根路由一处。
另一种常见做法是嵌套路由:子路由自己带前缀,用 router.use() 挂载。
const articles = new Router({ prefix: '/articles' });
articles.get('/:id', (ctx) => { /* ... */ });
const api = new Router({ prefix: '/api' });
api.use(articles.routes(), articles.allowedMethods()); // 实际路径 /api/articles/:id
两种都能用。嵌套路由要记得把子路由的 allowedMethods() 一起挂上,否则子路由下的 405 同样不会出现。
8.2 探针只在内网暴露
三种常见做法,按部署环境选:
| 做法 | 怎么做 | 适合 |
|---|---|---|
| 网络层隔离 | 只允许内网网段访问探针路径 | 已有网关或防火墙 |
| 独立端口 | 探针单独监听一个只在内网可达的端口 | 容器化部署,探针与应用同镜像 |
| 开关控制 | 用环境变量决定是否注册探针路由 | 需要临时关闭探针时 |
无论哪种,探针路径都不应该出现在公网可访问的域名上。
8.3 端口从环境变量读
const PORT = Number(process.env.PORT ?? 3001); // 本地默认 3001,部署时被平台注入的 PORT 覆盖
app.listen(PORT, () => console.log(`API 已启动: http://localhost:${PORT}`));
要点有三个:默认值只在本地起作用;Number() 转换避免字符串端口带来的类型问题;代码里不出现任何写死的端口,否则平台注入的 PORT 不生效,你会以为「环境变量没生效」,其实是代码没读。
8.4 多实例与缓存:Vary 不要漏
服务有多实例、前面有 CDN 或反向代理时,跨域响应头会被缓存。如果回显了具体来源却没有 Vary: Origin,A 站点的跨域头可能被发给 B 站点,症状是「有时候好有时候不好」。@koa/cors 默认会加 Vary: Origin;如果你手写 CORS 中间件,这个头必须自己加。
同理,如果你在 CORS 之后又挂了缓存中间件,记得让它按 Origin 分桶。
九、选型对照表
路由方案
| 方案 | 适用场景 | 代价 | 本文采用 |
|---|---|---|---|
@koa/router 15.x |
新项目 | 无,自带类型 | 是 |
koa-router 12.x |
维护老项目 | npm 已标记废弃,新装会有警告 | 迁移对照 |
手写 ctx.path 分支 |
只有两三个接口的小服务 | 路径匹配、参数解析、405 全要自己写 | 否 |
跨域方案
| 方案 | 预检由谁回应 | 适用 | 备注 |
|---|---|---|---|
应用层 @koa/cors |
中间件回 204 | 单体应用、前后端分端口开发 | 本文做法 |
| 网关 / Nginx 层统一加头 | 网关回 | 多个服务共用一个域名 | 应用层仍建议保留,避免有人直连实例绕过网关 |
前端调同域 /api(反向代理) |
不产生跨域,不产生预检 | 生产环境最常见 | 从根上消除预检开销 |
健康检查方案
| 方案 | 存活/就绪 | 依赖检查由谁做 | 适用 |
|---|---|---|---|
应用内 /api/health + /api/ready |
都提供 | 应用自己实现 | 单体服务、容器编排 |
| 平台托管探针 | 由平台定义 | 平台负责 | 云负载均衡、Kubernetes |
| 独立探针进程 | 都提供 | 探针进程自己查 | 依赖多、需要按依赖分组报告 |
十、排错清单
按「问题 → 原因 → 修法」三列排查,从上往下查:
| 问题 | 原因 | 修法 |
|---|---|---|
| 浏览器报跨域错误,但后端日志里有 200 记录 | CORS 中间件注册在路由之后,命中的请求走不到它 | 把 CORS 移到 router.routes() 之前 |
| 预检请求返回 404 | 预检路径没被任何路由匹配,或 CORS 注册在路由之后 | 先确认 CORS 在前;再确认路径前缀一致 |
预检返回 204 但缺 Access-Control-Allow-Headers |
allowHeaders 白名单没覆盖前端实际发的头 |
补全白名单,或去掉 allowHeaders 让它自动回显 |
| 路径存在却返回 404 而不是 405 | 没注册 allowedMethods() |
补上 app.use(router.allowedMethods()) |
只注册了 allowedMethods() 就报错 |
它依赖 routes() 写入的匹配信息 |
两个都注册,且 routes() 在前 |
ctx.request.body 是 undefined |
请求体解析中间件注册在路由之后 | 把 bodyparser 移到路由之前 |
| 写接口能通但带 Cookie 就失败 | 凭据模式下回了 *(@koa/cors 5 会自动改成回显来源,手写中间件不会) |
回显具体来源,并确认响应带 Vary: Origin |
| 某些用户偶发跨域失败 | 缓存了带 Origin 的响应但没 Vary: Origin |
补 Vary: Origin,或让缓存按 Origin 分桶 |
| 健康检查一直 200,但接口大面积 500 | 探针只回答「进程还在吗」 | 依赖检查放到就绪探针里,别塞进存活探针 |
| 数据库抖动时容器被反复重启 | 存活探针里做了依赖检查 | 拆成两个探针,存活探针只做轻量响应 |
| 日志文件被刷爆 | 探针请求也进了访问日志 | 在日志中间件里跳过探针路径 |
| 部署后端口没变 | 代码里写死了端口 | 改成 Number(process.env.PORT ?? 3001) |
| 500 响应把堆栈暴露给前端 | 错误中间件直接透传了 err.message |
5xx 统一返回「服务器内部错误」,细节只进日志 |
十一、发布前的自检清单
- 路由前缀集中在一处,前端拿到的路径与文档一致;
-
router.routes()与router.allowedMethods()都注册了,且routes()在前; - CORS 中间件在路由之前;
- 跨域来源是白名单而不是
*,响应带Vary: Origin; - 预检响应里有
Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers; - 请求体解析在路由之前;
- 错误响应格式统一,5xx 不泄露内部信息;
- 存活探针与就绪探针分开,且探针路径不写访问日志;
- 探针只在内网可达;
- 端口从环境变量读,代码里没有写死端口。
参考资料
- Koa 官方文档 —— 中间件模型、
ctxAPI 与错误处理 - koajs/router(@koa/router / koa-router 仓库) —— 路由 API、
allowedMethods()行为与两个包名的迁移说明 - koajs/cors(@koa/cors) —— CORS 选项、预检响应与
Vary处理 - MDN:跨源资源共享(CORS) —— 简单请求与预检请求的判定规则
- MDN:OPTIONS 方法 —— 预检请求里
Access-Control-Request-Method的含义 - koajs/bodyparser(@koa/bodyparser) —— 请求体解析选项与
enableTypes