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(),路由能正常匹配,但两件事没人做:

  1. OPTIONS 请求没人正确回应。浏览器发来的预检请求会落到 404;
  2. 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-router 12.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-router 12.x 用的是 path-to-regexp 6.x,路由里可以写 :id(\\d+) 这种内联正则;@koa/router 15.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

两条硬规则:

  1. 凭据模式(credentials: true)下,Access-Control-Allow-Origin 不能是 *。浏览器会直接拒绝这种组合。@koa/cors 5.0.0 的源码里做了保护:当 credentials 为真而 origin 是 * 时,它会把 * 换成请求里的真实来源。但更稳妥、也更常见的做法是自己写白名单函数,明确回显来源。
  2. 回显来源必须配 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
  • #router
  • #cors
  • #health

相关课程