服务端5月28日 04:30
Koa 怎么写测试?从框架选型到中间件和 API 踩坑实录Koa 项目写测试,很多人第一反应是"随便装个 Jest 就完事了"。但真正上手之后才会发现:中间件的洋葱模型怎么测?数据库操作怎么隔离?Supertest 和 Koa 的 callback 模式怎么配合才不会内存泄漏?这篇文章把框架选型的思路和实际项目中最容易踩的坑一次讲清。
## Jest 还是 Mocha?先想清楚再选
选测试框架不需要纠结太久,关键是看你的项目阶段和团队习惯。
**Jest 的优势**:零配置开箱即用,内置断言、mock、覆盖率报告。测试并行执行,速度快。遇到问题时错误信息比 Mocha 友好得多,直接告诉你哪个断言失败、期望值和实际值分别是什么。
**Mocha 的优势**:灵活性高,断言库可以选 Chai、Should、Expect,mock 可以选 Sinon 或自己写。对于已有 Mocha 体系的存量项目,迁移成本为零。
实际经验:新项目直接用 Jest,别犹豫。Mocha 需要额外配 Chai + Sinon + Istanbul,搭环境的时间够你写十几个测试用例了。唯一需要考虑 Mocha 的场景是,你的 CI 环境内存特别紧张——Jest 并行执行会吃更多内存,Mocha 串行跑更稳。
## 安装和基础配置
```bash
npm install --save-dev jest supertest @types/jest @types/supertest
```
Supertest 是测试 Koa HTTP 接口的核心工具,它不需要真正启动服务器,直接调用 `app.callback()` 生成请求处理函数,避免了端口占用和进程管理的问题。
```javascript
// jest.config.js
module.exports = {
testEnvironment: 'node',
coverageDirectory: 'coverage',
collectCoverageFrom: [
'src/**/*.js',
'!src/**/*.test.js'
],
testMatch: [
'**/__tests__/**/*.js',
'**/?(*.)+(spec|test).js'
]
};
```
一个容易忽略的配置:如果你的项目用了 Babel 或 TypeScript,需要额外配 `transform` 字段,否则 Jest 无法识别 ES Module 的 import 语法。
## 路由测试:最基础也最容易出错
```javascript
const request = require('supertest');
const app = require('../app');
describe('Basic routes', () => {
test('GET / should return Hello Koa', async () => {
const response = await request(app.callback())
.get('/')
.expect(200);
expect(response.text).toBe('Hello Koa');
});
test('GET /not-found should return 404', async () => {
await request(app.callback())
.get('/not-found')
.expect(404);
});
});
```
**踩坑点**:`app.callback()` 而不是 `app.listen()`。用 `listen()` 会在每个测试文件启动一个新的 HTTP 服务器,Jest 跑完不关的话,进程不会退出,CI 直接卡住。`callback()` 返回的是一个标准的 Node.js request handler,Supertest 内部会自己创建临时服务器并自动关闭,不会有泄漏问题。
## 中间件测试:洋葱模型的坑
Koa 中间件是洋葱模型——请求从外层进去,响应从内层出来。测试中间件时最常见的错误是手动构造 `ctx` 对象:
```javascript
// 错误写法:手动造 ctx
const ctx = { headers: {}, state: {}, throw: jest.fn() };
await middleware(ctx, next);
```
这种写法绕过了 Koa 的上下文封装,`ctx` 上少一堆属性和方法(`ctx.request`、`ctx.response`、`ctx.set()` 等),测试结果和生产环境完全不一致。某个中间件在测试里通过了,上了线照样炸。
**正确写法**:创建一个最小的 Koa 实例,把中间件挂上去,用 Supertest 发请求:
```javascript
const Koa = require('koa');
const authMiddleware = require('../middleware/auth');
describe('Auth middleware', () => {
test('should allow access with valid token', async () => {
const app = new Koa();
app.use(authMiddleware);
app.use(async (ctx) => {
ctx.body = { user: ctx.state.user };
});
const response = await request(app.callback())
.get('/test')
.set('Authorization', 'Bearer valid-token')
.expect(200);
expect(response.body.user).toBeDefined();
});
test('should deny access without token', async () => {
const app = new Koa();
app.use(authMiddleware);
app.use(async (ctx) => {
ctx.body = 'ok';
});
await request(app.callback())
.get('/test')
.expect(401);
});
});
```
这样测试走的是完整的 Koa 请求生命周期,中间件拿到的 `ctx` 和生产环境一模一样。
还有一个容易忽略的问题:中间件里 `await next()` 前后的代码分别对应请求进入和响应返回两个阶段。如果你的中间件在 `await next()` 之后做了什么操作(比如记录响应时间、修改响应头),测试时必须验证响应结果而不仅仅是 `next()` 是否被调用。
## 数据库测试:隔离是第一优先级
数据库相关的测试最容易污染环境。几个关键原则:
1. **用独立的测试数据库**。永远不要在开发库里跑测试,`knex` 或 `sequelize` 配置里加一个 `test` 环境指向独立库。
2. **每个测试用例前清空数据**。用 `beforeEach` + `truncate` 比 `afterEach` 更安全——如果测试中途挂了,`afterEach` 可能没执行,脏数据就留下了。
3. **事务回滚是个好办法,但有陷阱**。把每个测试包在一个事务里,跑完回滚,这样数据库始终保持干净。但如果你用了多进程并发测试(Jest 默认行为),不同 worker 的事务可能互相看到未提交的数据,取决于数据库的隔离级别。PostgreSQL 默认的 Read Committed 读不到其他事务未提交的数据,问题不大;MySQL 的某些引擎就不是这样了。
```javascript
describe('User API with DB', () => {
let db;
beforeAll(async () => {
db = require('../models');
await db.sequelize.sync({ force: true });
});
afterAll(async () => {
await db.sequelize.close();
});
beforeEach(async () => {
await db.User.destroy({ truncate: true, cascade: true });
});
test('POST /api/users should create user', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
};
const response = await request(app.callback())
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('id');
expect(response.body).not.toHaveProperty('password');
});
});
```
**`password` 不应该出现在响应里**——这种断言看似简单,但能抓住最常见的安全漏洞。如果你的 API 返回了密码字段(即使是哈希),这个测试会立刻暴露出来。
## Mock 策略:别过度 Mock
Mock 是双刃剑。Mock 太多,测试变成了"验证我的 mock 逻辑",和真实代码没有任何关系;Mock 太少,测试依赖外部服务,CI 随时可能因为第三方 API 超时而失败。
**原则**:只 Mock 跨越边界的调用——外部 API、文件系统、邮件发送。内部函数调用不要 Mock,否则你测的不是代码逻辑,而是你对代码逻辑的假设。
```javascript
// 合理的 Mock:模拟第三方 API
const nock = require('nock');
test('should fetch user from external service', async () => {
nock('https://api.example.com')
.get('/users/1')
.reply(200, { id: 1, name: 'Test User' });
const response = await request(app.callback())
.get('/api/external-users/1')
.expect(200);
expect(response.body.name).toBe('Test User');
nock.cleanAll();
});
// 不推荐的 Mock:模拟内部数据库调用
// jest.spyOn(User, 'findById').mockResolvedValue({ id: 1 });
// 这样测的是 mock 的返回值,不是数据库查询逻辑
```
## 文件上传测试
文件上传是另一个容易遗漏的测试场景。Supertest 支持 `.attach()` 和 `.field()` 方法来模拟 `multipart/form-data` 请求:
```javascript
const path = require('path');
describe('File upload', () => {
test('should upload avatar and return URL', async () => {
const response = await request(app.callback())
.post('/api/upload/avatar')
.set('Authorization', `Bearer ${authToken}`)
.attach('avatar', path.join(__dirname, 'fixtures/test-avatar.png'))
.expect(200);
expect(response.body).toHaveProperty('url');
expect(response.body.url).toMatch(/^https?:\/\//);
});
test('should reject files over size limit', async () => {
await request(app.callback())
.post('/api/upload/avatar')
.set('Authorization', `Bearer ${authToken}`)
.attach('avatar', path.join(__dirname, 'fixtures/large-file.png'))
.expect(413);
});
});
```
踩坑提醒:测试用的文件放在 `__tests__/fixtures/` 目录下,别用线上真实用户上传的文件——一是隐私问题,二是文件可能随时被删导致测试莫名失败。
## 错误处理测试
错误处理是最容易遗漏的测试场景,但恰恰是线上出问题时最需要保障的部分:
```javascript
describe('Error handling', () => {
test('should handle validation errors with 400', async () => {
const response = await request(app.callback())
.post('/api/users')
.send({ name: '', email: 'invalid' })
.expect(400);
expect(response.body).toHaveProperty('code', 'VALIDATION_ERROR');
});
test('should handle unexpected errors with 500', async () => {
// 模拟数据库异常
jest.spyOn(User, 'create').mockRejectedValue(new Error('DB connection lost'));
const response = await request(app.callback())
.post('/api/users')
.send({ name: 'Test', email: 'test@example.com' })
.expect(500);
expect(response.body).toHaveProperty('code', 'INTERNAL_ERROR');
});
});
```
一个真实教训:某次部署后,数据库连接池耗尽导致所有 API 返回 500,但前端只显示"网络错误"。如果提前测了 500 的响应格式,前端至少能给用户一个像样的提示。
## 集成测试:串起完整流程
单元测试验证单个函数,集成测试验证整个流程能跑通。关键是在 `beforeAll` 里完成前置操作,`afterAll` 里清理数据:
```javascript
describe('Full user flow', () => {
let authToken;
beforeAll(async () => {
const response = await request(app.callback())
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'password123' });
authToken = response.body.token;
});
afterAll(async () => {
await request(app.callback())
.delete('/api/test/cleanup')
.set('Authorization', `Bearer ${authToken}`);
});
test('should create and retrieve a post', async () => {
const createRes = await request(app.callback())
.post('/api/posts')
.set('Authorization', `Bearer ${authToken}`)
.send({ title: 'Test Post', content: 'Hello' })
.expect(201);
const postId = createRes.body.id;
const getRes = await request(app.callback())
.get(`/api/posts/${postId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(getRes.body.title).toBe('Test Post');
});
});
```
集成测试不需要覆盖所有边界情况,那是单元测试的活。集成测试的价值在于确认"登录 -> 创建 -> 查询 -> 删除"这条主线不断。
## CI 里的测试配置
测试写好了,CI 里跑不起来是最让人崩溃的。几个常见问题:
- **Jest 超时**:数据库操作默认 5 秒超时不够用,在测试文件顶部加 `jest.setTimeout(30000)` 或在配置里统一设置。
- **端口冲突**:如果某个测试用了 `app.listen()` 而不是 `app.callback()`,并行执行时端口会被占。全局搜一下 `listen`,确保测试里没有直接调用。
- **数据库连接不关闭**:`afterAll` 里必须 `sequelize.close()` 或 `mongoose.disconnect()`,否则 Jest 进程挂起不退出。
- **环境变量**:CI 里用 `cross-env NODE_ENV=test jest`,确保代码里读到的数据库配置是测试库而不是生产库。
覆盖率方面,80% 是底线,但别追求 100%——有些代码(如启动脚本、配置文件)写测试纯属浪费时间。重点关注业务逻辑层和控制器的覆盖率。在 `jest.config.js` 里设置 `coverageThreshold`,低于阈值直接让 CI 失败:
```javascript
coverageThreshold: {
global: {
branches: 70,
functions: 80,
lines: 80,
statements: 80
}
}
```
## 测试数据管理
维护测试数据最省心的方式是用工厂函数,比硬编码 fixture 灵活,比每次手写对象不容易遗漏字段:
```javascript
const userFactory = (overrides = {}) => ({
name: 'Test User',
email: `test${Date.now()}@example.com`,
password: 'password123',
...overrides
});
test('should create user with custom name', async () => {
const userData = userFactory({ name: 'Custom Name' });
const response = await request(app.callback())
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body.name).toBe('Custom Name');
});
```
工厂函数里 `email` 加了时间戳后缀,避免并发测试时邮箱唯一约束冲突。这个小技巧省了无数调试时间。
写测试这件事,起步觉得麻烦,写顺手了你会发现:改代码的胆子大了很多,部署前不再心虚,凌晨三点的报警也少了。框架选型五分钟搞定,踩坑排查才花时间——把坑提前在测试里踩掉,比在线上踩便宜太多了。标签
Koa
在前端技术的跃进浪潮中,一个框架脱颖而出 —— Koa.js,它是由Express原班人马打造的新一代Node.js框架。为什么它能成为时下热议的焦点?因为Koa.js以其简洁的设计,强大的功能和对现代JavaScript特性(如async/await)的天然支持,重新定义了后端的开发模式。 简洁:Koa.js提供了一个轻量的函数库,让你能够快速搭建服务器。 现代化:它采用最新的JS特性,使得代码更加直观且易于管理。 灵活:通过中间件机制,你可以轻松扩展功能,实现定制化的解决方案。 性能:Koa.js注重性能优化,可以建立更快、更稳定的网络应用。 不仅如此,Koa.js的优雅编程体验和提升的开发效率,让前端工程师的技能得到了全方位的提升。它不是简单的技术更迭,而是前端领域的一次革新旅程。

服务端5月28日 04:27
Koa 文件上传实战:koa-body 与 koa-multer 的选择与安全防护Koa 本身不管文件上传——它只处理 HTTP 请求流,解析 multipart 数据得靠中间件。实际项目中 koa-body 和 koa-multer 是两个最主流的选择,选错了后面改起来很痛苦。
## koa-body:开箱即用,大多数场景的首选
koa-body 底层基于 formidable,既能解析普通请求体,又能处理文件上传,一个中间件搞定两件事。不需要额外装 body-parser,配置也少。
**安装与基本配置:**
```bash
npm install koa-body
```
```javascript
const koaBody = require('koa-body');
app.use(koaBody({
multipart: true,
formidable: {
maxFileSize: 100 * 1024 * 1024, // 100MB
keepExtensions: true,
uploadDir: './uploads',
multiples: true
}
}));
```
三个容易踩坑的配置项:
- `multipart: true` 必须显式开启,默认是 false,文件上传不生效时先检查这个
- `maxFileSize` 默认只有 2MB,上传个高清头像都不够,实际项目基本都要调大
- 新版 koa-body 通过 `ctx.request.files` 获取文件,旧版用 `ctx.request.body.files`——升级时这片坑踩的人最多
**单文件上传:**
```javascript
app.use(async (ctx) => {
const file = ctx.request.files.file;
if (!file) ctx.throw(400, 'No file uploaded');
ctx.body = {
message: 'File uploaded successfully',
file: {
name: file.name,
size: file.size,
path: file.path,
type: file.type
}
};
});
```
**多文件上传——注意单文件和多文件的返回格式不一致:**
只上传一个文件时 `ctx.request.files.files` 返回的是对象,多个文件时返回数组。不统一处理的话,`Array.map` 在单文件场景会报 "file.map is not a function":
```javascript
app.use(async (ctx) => {
const files = ctx.request.files.files;
if (!files) ctx.throw(400, 'No files uploaded');
// 统一转数组,这是 formidable 的坑
const fileList = Array.isArray(files) ? files : [files];
const uploadedFiles = fileList.map(file => ({
name: file.name,
size: file.size,
path: file.path,
type: file.type
}));
ctx.body = {
message: `${uploadedFiles.length} files uploaded`,
files: uploadedFiles
};
});
```
## koa-multer:精细控制文件名和存储路径
koa-multer 基于 Express 生态的 multer 改造,核心优势是 `diskStorage` 可以精确控制文件命名和目录结构——比如按日期分目录、按用户 ID 分目录,koa-body 做不到这么灵活。
**安装与存储配置:**
```bash
npm install koa-multer
```
```javascript
const multer = require('koa-multer');
const path = require('path');
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, './uploads/');
},
filename: function (req, file, cb) {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 100 * 1024 * 1024 },
fileFilter: function (req, file, cb) {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (allowedTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'), false);
}
}
});
```
**一个关键区别**:koa-multer 的文件挂在 `ctx.req.file` / `ctx.req.files` 上(走的是 Node 原生 http.IncomingMessage),不是 `ctx.request`(Koa 封装的)。拿文件的位置搞混是最常见的低级错误。
**单文件、多文件、混合上传:**
```javascript
// 单文件
app.use(upload.single('file'));
app.use(async (ctx) => {
const file = ctx.req.file;
ctx.body = { message: 'File uploaded', file };
});
// 多文件(最多 10 个)
app.use(upload.array('files', 10));
app.use(async (ctx) => {
const files = ctx.req.files;
ctx.body = { message: `${files.length} files uploaded`, files };
});
// 混合上传:同一请求中不同字段接收不同数量的文件
app.use(upload.fields([
{ name: 'avatar', maxCount: 1 },
{ name: 'documents', maxCount: 5 }
]));
app.use(async (ctx) => {
const files = ctx.req.files;
const body = ctx.req.body;
ctx.body = {
avatar: files.avatar[0],
documents: files.documents,
data: body
};
});
```
## koa-body 还是 koa-multer?
简单场景用 koa-body——一个中间件同时处理请求体解析和文件上传,少装一个包。需要按日期/用户分目录、自定义文件命名规则、按字段分组上传时用 koa-multer。两者也能配合使用,koa-body 处理普通请求体,koa-multer 专门处理上传路由,但要注意中间件加载顺序。
## 文件上传安全防线:三层校验缺一不可
文件上传是 Web 应用最常见的攻击入口。2024 年的数据显示,约 40% 的 Web 应用安全漏洞与文件上传校验不足有关。前端的 accept 属性和 JS 校验形同虚设,攻击者用 curl 或 Postman 直接绕过。
### 第一层:中间件级过滤
koa-body 的 `formidable.filter` 和 koa-multer 的 `fileFilter` 是第一道关:
```javascript
app.use(koaBody({
multipart: true,
formidable: {
maxFileSize: 10 * 1024 * 1024, // 10MB
keepExtensions: true,
uploadDir: './uploads',
filter: function ({ name, originalFilename, mimetype }) {
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'];
return allowedTypes.includes(mimetype);
}
}
}));
```
但这一层只检查 MIME type——MIME 是客户端声明的,可以伪造。攻击者把 PHP webshell 的 MIME 声明成 `image/jpeg` 就能过这一关。
### 第二层:业务逻辑校验
在业务层同时校验 MIME type 和文件扩展名,两个都对才放行:
```javascript
const path = require('path');
const fs = require('fs');
async function validateFile(ctx, next) {
const file = ctx.request.files?.file;
if (!file) ctx.throw(400, 'No file uploaded');
// 校验大小
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
fs.unlinkSync(file.path);
ctx.throw(400, 'File size exceeds limit');
}
// 校验 MIME type
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
fs.unlinkSync(file.path);
ctx.throw(400, 'Invalid file type');
}
// 校验扩展名(双重验证)
const ext = path.extname(file.name).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif'];
if (!allowedExts.includes(ext)) {
fs.unlinkSync(file.path);
ctx.throw(400, 'Invalid file extension');
}
await next();
}
app.use(validateFile);
```
### 第三层:运维层防护
代码层校验之外,还有几件事必须在运维层面做:
- **随机文件名**:不要用用户上传的原始文件名,防止路径遍历攻击(攻击者构造 `../../../etc/passwd` 这样的文件名)
- **上传目录隔离**:不要把上传目录放在 Web 静态资源目录下,否则上传的 `.html` 文件可能被执行 XSS
- **失败时清理临时文件**:formidable 会先把文件写入 `uploadDir`,校验失败后不删就留在磁盘上了,日积月累会撑满磁盘
- **速率限制**:用 `koa-ratelimit` 限制单 IP 上传频率,防止恶意大文件轰炸
## 图片处理:上传后的二次加工
用户上传的图片通常需要压缩和生成缩略图。sharp 是 Node.js 生态里性能最好的图片处理库,基于 libvips,比 GraphicsMagick 快 4-5 倍,内存占用也更低。
```bash
npm install sharp
```
```javascript
const sharp = require('sharp');
app.use(async (ctx) => {
const file = ctx.request.files.file;
if (!file) ctx.throw(400, 'No file uploaded');
// 生成缩略图
const thumbnailPath = file.path.replace(/(\.[\w\d]+)$/, '_thumb$1');
await sharp(file.path)
.resize(200, 200, { fit: 'cover', position: 'center' })
.toFile(thumbnailPath);
// 压缩原图
const compressedPath = file.path.replace(/(\.[\w\d]+)$/, '_compressed$1');
await sharp(file.path)
.jpeg({ quality: 80 })
.toFile(compressedPath);
ctx.body = {
message: 'Image processed successfully',
original: file.path,
thumbnail: thumbnailPath,
compressed: compressedPath
};
});
```
一个实战经验:压缩质量 80 是性价比最高的档位——肉眼几乎看不出和原图的差别,但文件体积能缩小 60-70%。
## 大文件分片上传
超过 100MB 的文件不适合一次性上传,网络波动一个中断就从头再来。分片上传把大文件切成小块逐个上传,某片失败了只重传那一片,最后服务端按顺序合并。
**分片上传实现:**
```javascript
const fs = require('fs');
const path = require('path');
app.use(async (ctx) => {
const { chunkIndex, totalChunks, fileId } = ctx.request.body;
const file = ctx.request.files.chunk;
// 按 fileId 创建临时分片目录
const chunkDir = path.join('./uploads/chunks', fileId);
if (!fs.existsSync(chunkDir)) {
fs.mkdirSync(chunkDir, { recursive: true });
}
// 保存当前分片
const currentChunkPath = path.join(chunkDir, `chunk_${chunkIndex}`);
const reader = fs.createReadStream(file.path);
const writer = fs.createWriteStream(currentChunkPath);
await new Promise((resolve, reject) => {
reader.pipe(writer);
writer.on('finish', resolve);
writer.on('error', reject);
});
// 检查是否所有分片都已到达
const uploadedChunks = fs.readdirSync(chunkDir).length;
if (uploadedChunks === parseInt(totalChunks)) {
// 合并所有分片
const finalPath = path.join('./uploads', `${fileId}${path.extname(file.name)}`);
const writeStream = fs.createWriteStream(finalPath);
for (let i = 0; i < totalChunks; i++) {
const chunkPath = path.join(chunkDir, `chunk_${i}`);
const chunkData = fs.readFileSync(chunkPath);
writeStream.write(chunkData);
fs.unlinkSync(chunkPath);
}
writeStream.end();
fs.rmdirSync(chunkDir);
ctx.body = { message: 'File upload completed', path: finalPath };
} else {
ctx.body = {
message: `Chunk ${chunkIndex} uploaded`,
progress: `${uploadedChunks}/${totalChunks}`
};
}
});
```
分片上传上生产之前,这几件事必须处理:
- **断点续传**:客户端上传前先请求服务端查已有分片列表,跳过已上传的分片,而不是从头开始
- **分片过期清理**:用户上传了 3 片然后关闭页面,分片永远留在磁盘上。设置定时任务(cron job),清理超过 24 小时未完成的分片目录
- **并发写入**:客户端用 Promise.all 同时传多个分片时,`fs.readdirSync` 读到的数量可能不准确,需要用文件锁或 Redis 计数器保证一致性
- **完整性校验**:合并完成后用 MD5 或 SHA256 校验文件哈希,和客户端传来的原始哈希对比,确保传输没有丢数据
## 生产环境清单
| 分类 | 要点 | 不做的后果 |
|------|------|-----------|
| 文件大小限制 | `maxFileSize` 或 `limits.fileSize` | 大文件撑爆内存或磁盘 |
| MIME + 扩展名双重校验 | 两个都检查,不能只靠一个 | 伪造 MIME 上传恶意文件 |
| 随机文件名 | UUID 或时间戳+随机数 | 路径遍历攻击、文件名冲突 |
| 上传目录隔离 | 不放在 static 目录下 | 上传的 HTML/JS 被直接执行 |
| 校验失败清临时文件 | `fs.unlinkSync(file.path)` | 磁盘被垃圾文件撑满 |
| 速率限制 | koa-ratelimit 限制单 IP | 恶意大文件轰炸 |
| 流式处理大文件 | createReadStream + pipe | 大文件一次性读进内存 OOM |
| 图片压缩 | sharp 质量设 80 | 原图直接存浪费存储和带宽 |
| 分片过期清理 | 定时任务清理 24h 未完成分片 | 孤立分片占满磁盘 |
| 友好错误提示 | "文件太大,最大 10MB" | 用户看到 400 Bad Request 一头雾水 |服务端5月28日 04:27
如何在 Koa 中正确使用路由?@koa/router 详解与实战Koa 核心不包含路由功能,需要通过中间件实现。最常用的路由中间件是 @koa/router(注意不是已停维的 koa-router),它提供了完整的路由定义、参数捕获、中间件编排等能力。
本文从安装配置开始,逐步覆盖参数处理、路由嵌套、中间件链、模块化拆分这些实际项目必用的功能,同时补充常见踩坑点。
## 安装和基本用法
```bash
npm install @koa/router
```
@koa/router 是 Koa 官方维护的路由库,原版 koa-router 已停止维护,新项目统一用 @koa/router。如果你的项目还在用 koa-router,建议尽快迁移——API 几乎一致,替换成本很低。
最简路由示例:
```javascript
const Koa = require("koa");
const Router = require("@koa/router");
const app = new Koa();
const router = new Router();
router.get("/", async (ctx) => {
ctx.body = "Hello Koa";
});
router.get("/users/:id", async (ctx) => {
ctx.body = `User ${ctx.params.id}`;
});
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(3000);
```
两个关键点:`router.routes()` 注册路由中间件,`router.allowedMethods()` 自动处理不支持的 HTTP 方法(返回 405 或 501)。漏掉 `allowedMethods()` 不会报错,但未匹配的方法会静默返回 404,排查起来很困惑。
## 路由参数的三种形式
### 路径参数
路径参数用 `:param` 语法捕获,通过 `ctx.params` 获取:
```javascript
router.get("/users/:id", async (ctx) => {
const { id } = ctx.params;
ctx.body = `User ID: ${id}`;
});
// 多个参数
router.get("/posts/:postId/comments/:commentId", async (ctx) => {
const { postId, commentId } = ctx.params;
ctx.body = `Post: ${postId}, Comment: ${commentId}`;
});
```
**踩坑提醒**:@koa/router v15+ 不再支持路径参数中内嵌正则(如 `:id(\\d+))`),因为底层 path-to-regexp 升级到了 v8。如果你需要校验参数格式,把校验逻辑放到路由处理函数或中间件里:
```javascript
// v15+ 正确做法:在 handler 中校验
router.get("/users/:id", async (ctx) => {
const { id } = ctx.params;
if (!/^\d+$/.test(id)) {
ctx.throw(400, "id 必须是数字");
}
ctx.body = `User ID: ${id}`;
});
```
### 查询参数
查询参数通过 `ctx.query` 获取,它会自动解析为对象:
```javascript
router.get("/search", async (ctx) => {
const { keyword, page = "1", limit = "10" } = ctx.query;
ctx.body = { keyword, page: Number(page), limit: Number(limit) };
});
```
注意 `ctx.query` 的值都是字符串,需要手动转数字。`ctx.querystring` 则是原始查询字符串。
### 正则表达式路由
如果路径匹配逻辑比较特殊,可以直接用正则:
```javascript
router.get(/^\/users\/(\d+)$/, async (ctx) => {
const id = ctx.params[0]; // 通过索引取捕获组
ctx.body = `User ID: ${id}`;
});
```
正则路由用得不多,大部分场景路径参数就够了。它的捕获组通过 `ctx.params[0]`、`ctx.params[1]` 按序获取。
## HTTP 方法与路由注册
@koa/router 支持所有常用 HTTP 方法:
```javascript
router.get("/resource", handler); // 查询
router.post("/resource", handler); // 创建
router.put("/resource/:id", handler); // 全量更新
router.patch("/resource/:id", handler); // 部分更新
router.delete("/resource/:id", handler); // 删除
router.all("/resource", handler); // 匹配所有方法
```
`router.all()` 适合给一组路由加统一的预处理逻辑,比如鉴权:
```javascript
router.all("/admin/*", authMiddleware);
```
## 路由前缀与嵌套
### 路由前缀
设置前缀后,该路由器下所有路径自动加上前缀:
```javascript
const apiRouter = new Router({ prefix: "/api/v1" });
apiRouter.get("/users", handler); // 实际匹配 /api/v1/users
apiRouter.post("/login", handler); // 实际匹配 /api/v1/login
```
也可以用 `router.prefix()` 方法动态设置:
```javascript
router.prefix("/api/v2");
```
### 路由嵌套
路由嵌套是把子路由挂载到父路由下,形成清晰的 URL 层级:
```javascript
const userRouter = new Router({ prefix: "/users" });
userRouter.get("/", async (ctx) => {
ctx.body = "User list";
});
userRouter.get("/:id", async (ctx) => {
ctx.body = `User ${ctx.params.id}`;
});
const commentRouter = new Router({ prefix: "/:userId/comments" });
commentRouter.get("/", async (ctx) => {
ctx.body = `Comments for user ${ctx.params.userId}`;
});
userRouter.use(commentRouter.routes());
app.use(userRouter.routes());
```
嵌套路由中,子路由可以访问父路由的路径参数(如上面的 `ctx.params.userId`),这一点很实用。
## 路由中间件
路由中间件是在特定路由上挂载的处理函数,可以挂一个或多个,按顺序执行:
### 单路由中间件
```javascript
router.get("/protected", authMiddleware, async (ctx) => {
ctx.body = "Protected content";
});
```
### 多个中间件串联
```javascript
router.post("/admin",
authMiddleware, // 先鉴权
adminCheckMiddleware, // 再检查权限
async (ctx) => {
ctx.body = "Admin content";
}
);
```
中间件链的核心是 `await next()`——只有调用 next,后面的中间件才会执行。忘记 await next 是最常见的 bug 之一:
```javascript
// 错误:没有 await next(),后续中间件不会执行
async function badMiddleware(ctx, next) {
console.log("before");
next(); // 缺少 await
console.log("after"); // 会在后续中间件完成前就执行
}
// 正确写法
async function goodMiddleware(ctx, next) {
console.log("before");
await next();
console.log("after"); // 后续中间件完成后才执行
}
```
### 路由级中间件
用 `router.use()` 给整个路由器加中间件,作用于该路由器下所有路由:
```javascript
router.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
```
## 路由模块化拆分
项目稍大一些,把所有路由写在一个文件里就很难维护了。标准做法是按功能模块拆分路由文件:
```javascript
// routes/users.js
const Router = require("@koa/router");
const router = new Router({ prefix: "/users" });
router.get("/", async (ctx) => {
ctx.body = "User list";
});
router.get("/:id", async (ctx) => {
ctx.body = `User ${ctx.params.id}`;
});
router.post("/", async (ctx) => {
ctx.body = { success: true, user: ctx.request.body };
});
module.exports = router;
```
```javascript
// app.js
const userRoutes = require("./routes/users");
const orderRoutes = require("./routes/orders");
app.use(userRoutes.routes());
app.use(userRoutes.allowedMethods());
app.use(orderRoutes.routes());
app.use(orderRoutes.allowedMethods());
```
如果模块很多,可以用 `require-directory` 自动加载:
```javascript
const requireDirectory = require("require-directory");
const modules = requireDirectory(module, "./routes");
for (const name in modules) {
const router = modules[name];
if (router.routes) {
app.use(router.routes());
app.use(router.allowedMethods());
}
}
```
## 路由命名和重定向
给路由起名字,可以通过名字生成 URL 或做重定向:
```javascript
router.get("user", "/users/:id", async (ctx) => {
ctx.body = `User ${ctx.params.id}`;
});
// 重定向
router.redirect("/old-path", "/new-path");
router.redirect("/old-user", "user", { id: 123 });
// 访问 /old-user 会 301 跳转到 /users/123
```
路由命名在模板渲染时特别有用——改了路径只需改一处定义,所有引用自动更新。
## 错误处理
### 路由内抛错
```javascript
router.get("/users/:id", async (ctx) => {
const user = await getUserById(ctx.params.id);
if (!user) {
ctx.throw(404, "User not found");
}
ctx.body = user;
});
```
`ctx.throw()` 会中断后续执行,Koa 会把这个错误传给全局错误处理中间件。
### 全局错误处理
在路由之前注册一个 try-catch 中间件,统一捕获所有路由中的错误:
```javascript
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = {
error: err.message,
status: ctx.status
};
// 触发 Koa 的 error 事件,方便日志记录
ctx.app.emit("error", err, ctx);
}
});
app.use(router.routes());
```
### allowedMethods 的错误处理
`router.allowedMethods()` 会自动处理不支持的 HTTP 方法:
```javascript
app.use(router.allowedMethods({
throw: true, // 不设置则返回 404,设置后抛出异常
notImplemented: () => new NotImplemented(),
methodNotAllowed: () => new MethodNotAllowed()
}));
```
## 常见踩坑
**1. 忘记 await 导致 404**
这是最频繁遇到的问题。路由处理函数里用了 Promise 但没有 await,Koa 会在 Promise resolve 之前就结束请求:
```javascript
// 总是返回 404
router.get("/data", async (ctx) => {
db.query("SELECT * FROM users").then(users => {
ctx.body = users; // 太晚了,请求已经结束
});
});
// 正确写法
router.get("/data", async (ctx) => {
ctx.body = await db.query("SELECT * FROM users");
});
```
**2. 中间件注册顺序**
Koa 中间件是洋葱模型,注册顺序决定了执行顺序。如果 `router.routes()` 在业务中间件之前注册,业务中间件就拿不到路由处理后的 ctx:
```javascript
// 日志中间件放在路由之后,能记录到响应状态
app.use(router.routes());
app.use(logger()); // logger 拿不到路由设置的 ctx.status
// 正确顺序
app.use(logger());
app.use(router.routes());
```
**3. 多个路由器的 allowedMethods**
每个路由器都应该调用自己的 `allowedMethods()`,否则一个路由器的路由匹配不上时,其他路由器的 HTTP 方法校验也不会生效。
**4. 路由参数是字符串**
`ctx.params` 和 `ctx.query` 的值永远是字符串类型,直接当数字用会出问题:
```javascript
router.get("/users/:id", async (ctx) => {
// ctx.params.id 是字符串 "123",不是数字 123
const id = Number(ctx.params.id); // 需要手动转换
});
```
## koa-router 与 @koa/router 的区别
旧项目里可能还在用 `koa-router`,两者的主要差异:
| 对比项 | koa-router | @koa/router |
|--------|-----------|-------------|
| 维护状态 | 已停维 | 官方维护 |
| TypeScript | 需要 @types/koa-router | 内置类型定义 |
| Node.js 版本 | 无明确要求 | v15+ 需要 Node ≥ 20 |
| 路径参数正则 | 支持 `:id(\\d+)` | v15+ 不支持内嵌正则 |
| API | 基本一致 | 基本一致 |
迁移很简单:把 `require("koa-router")` 改成 `require("@koa/router")`,然后把 package.json 里的依赖名换掉。如果用了内嵌正则参数,需要把校验逻辑移到处理函数里。服务端5月28日 04:27
Koa 性能优化实战:从中间件调优到多进程部署Koa 应用跑起来容易,跑得快却要费一番功夫。中间件越堆越多、数据库查询越来越慢、内存一天比一天高——这些问题在开发环境里不容易暴露,一到生产环境就全冒出来了。
这篇文章把 Koa 性能优化拆成几个实战方向,每个方向都从"问题是什么"讲到"怎么解决",跳过那些你本来就知道的基础知识,专注真正影响线上性能的环节。
## 先弄清楚瓶颈在哪
优化之前先测量,不然就是盲人摸象。Koa 应用最常见的性能瓶颈就这几个:
- 中间件链过长,每个请求白白穿越十几层不需要的中间件
- 数据库查询没做并行,串行 await 一个接一个
- 响应体没压缩,几 KB 的 JSON 外加几十 KB 的冗余字段原样发出去
- 静态资源全走 Node.js 进程,CPU 浪费在文件 I/O 上
- 连接池配置不当,高并发时排队等连接
用 `koa-logger` 或 `pino` 给每个请求打时间戳,先看哪个路由慢、哪个中间件耗时长,再针对性优化,别上来就乱改。
## 中间件:少就是快
Koa 的洋葱模型是它的灵魂,但也是性能陷阱。每个请求都要穿过整个中间件链,中间件越多,每个请求的额外开销越大。
几个实用的优化策略:
**合并功能相近的中间件**。安全相关的头(CSP、X-Frame-Options、HSTS)别一个一个挂,用一个 `koa-helmet` 搞定。CORS 和 body parser 也可以合并到统一的请求预处理中间件里。
**条件跳过不需要的中间件**。不是每个路由都需要 session 解析、CSRF 校验、文件上传处理。根据路径提前判断,不走无用中间件:
```javascript
app.use(async (ctx, next) => {
// 静态资源和健康检查不需要 session
if (ctx.path.startsWith('/static') || ctx.path === '/health') {
return await next();
}
// 只有需要鉴权的路由才走 session 解析
await sessionMiddleware(ctx, next);
});
```
**把轻量中间件放前面,重量级放后面**。日志、CORS 这种几乎不耗时的放前面尽早执行;认证、数据库查询这种可能失败的放后面,这样失败的请求不会白跑前面的重逻辑。
**注意中间件里的隐式阻塞**。在洋葱模型的 "upstream" 阶段(`await next()` 之后)执行重操作是最容易被忽略的性能坑:
```javascript
// 错误:在 upstream 做数据库查询,即使请求已经不需要了
app.use(async (ctx, next) => {
await next();
const user = await db.findUser(ctx.session.userId); // 白白查询
ctx.set('X-User', user.name);
});
```
## 异步:并行比串行快得多
Koa 天生支持 async/await,但这不意味着你写的异步代码就一定高效。最常见的坑是把本该并行的查询写成了串行:
```javascript
// 串行:三个查询依次等待,总耗时 = A + B + C
const user = await db.findUser(id);
const posts = await db.findPosts(id);
const stats = await db.findStats(id);
// 并行:三个查询同时发出,总耗时 = max(A, B, C)
const [user, posts, stats] = await Promise.all([
db.findUser(id),
db.findPosts(id),
db.findStats(id)
]);
```
看起来简单,实际项目中串行 await 的写法随处可见,尤其是跨服务的调用。养成习惯:多个不互相依赖的异步操作,一律 `Promise.all`。
对于需要容错的场景,用 `Promise.allSettled` 替代,避免一个请求失败导致整个页面挂掉:
```javascript
const [userResult, postsResult] = await Promise.allSettled([
userService.fetch(id),
postService.fetch(id)
]);
const user = userResult.status === 'fulfilled' ? userResult.value : null;
const posts = postsResult.status === 'fulfilled' ? postsResult.value : [];
```
## 缓存:别让重复查询拖垮响应
生产环境别用 `Map` 做缓存,那只是示例代码。真实场景用 Redis,带上合理的过期策略:
```javascript
const redis = require('ioredis');
const client = new redis({ host: '127.0.0.1', port: 6379 });
async function cached(key, ttl, fetcher) {
const cached = await client.get(key);
if (cached) return JSON.parse(cached);
const data = await fetcher();
await client.set(key, JSON.stringify(data), 'EX', ttl);
return data;
}
// 使用
app.use(async (ctx) => {
const user = await cached(`user:${ctx.params.id}`, 300, () =>
db.findUser(ctx.params.id)
);
ctx.body = user;
});
```
缓存策略的关键不是"加不加缓存",而是"什么时候让缓存失效"。写操作之后主动删缓存,比设一个固定 TTL 更可靠:
```javascript
// 更新后删缓存,下次查询自然刷新
await db.updateUser(id, data);
await client.del(`user:${id}`);
```
## 数据库连接池:别省这口配置
每个请求都新建数据库连接,连接建立的开销比查询本身还大。用连接池是基本操作,但很多人配了连接池却没调对参数:
```javascript
const { Pool } = require('pg');
const pool = new Pool({
max: 20, // 最大连接数,不是越多越好
min: 5, // 最少保持 5 个空闲连接
idleTimeoutMillis: 30000, // 空闲 30 秒回收
connectionTimeoutMillis: 2000 // 2 秒连不上就报错,别让请求卡住
});
```
`max` 设多少合适?经验值是 CPU 核心数的 2-4 倍,再多了数据库端反而会因上下文切换变慢。`connectionTimeoutMillis` 一定要设,不然连接池耗尽时请求会无限等待,拖垮整个服务。
用完连接必须 `release()`,不然连接泄漏,池子很快就干了。推荐用 `pool.query()` 自动管理连接生命周期,省心:
```javascript
// 自动获取和释放连接
const result = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
```
## 响应压缩:几行代码换来 70% 体积缩减
JSON API 的响应体通常有大量重复的 key 名和冗余空格,压缩效果非常明显。`koa-compress` 加上就行:
```javascript
const compress = require('koa-compress');
const zlib = require('zlib');
app.use(compress({
threshold: 1024, // 超过 1KB 才压缩,小响应不值得
gzip: { flush: zlib.constants.Z_SYNC_FLUSH }
}));
```
实际效果:一个 15KB 的 JSON 响应,gzip 后大约 3-4KB,减少 70% 以上传输量。对移动端用户尤其明显。
如果 NGINX 已经做了压缩,Node.js 层就不用再压了,重复压缩反而浪费 CPU。
## 静态资源:别让 Node.js 干 NGINX 的活
Node.js 处理静态文件是出名的慢——单线程忙着读文件,API 请求就排队等着。生产环境静态资源应该交给 NGINX 或 CDN:
```nginx
# NGINX 配置
location /static/ {
alias /var/www/static/;
expires 30d;
add_header Cache-Control "public, immutable";
gzip on;
}
```
如果一定要在 Koa 里处理(开发环境或小项目),用 `koa-static` 并配上缓存头:
```javascript
const serve = require('koa-static');
app.use(serve('./public', {
maxage: 30 * 24 * 60 * 60 * 1000 // 30 天浏览器缓存
}));
```
但记住,这只是开发便利,不是生产方案。
## 多进程:一个 CPU 核跑一个实例
Node.js 是单线程的,一个实例只能用一个 CPU 核心。4 核服务器只跑一个 Koa 进程,75% 的算力白白闲置。
用 cluster 模块最简单:
```javascript
const cluster = require('cluster');
const os = require('os');
if (cluster.isPrimary) {
const cpus = os.cpus().length;
for (let i = 0; i < cpus; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} 挂了,重启`);
cluster.fork();
});
} else {
// 你的 Koa 应用
app.listen(3000);
}
```
生产环境更推荐用 PM2,自带进程管理、日志、监控和自动重启:
```bash
pm2 start app.js -i max # 自动按 CPU 核心数启动
```
## 监控:没有数据就没有优化
性能优化不是一锤子买卖,上线后必须持续监控,否则优化效果没法量化,新出现的瓶颈也没法发现。
用 `prom-client` 暴露指标,配合 Prometheus + Grafana 做可视化:
```javascript
const client = require('prom-client');
const histogram = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Request duration',
labelNames: ['method', 'route', 'status']
});
app.use(async (ctx, next) => {
const end = histogram.startTimer();
await next();
end({ method: ctx.method, route: ctx.path, status: ctx.status });
});
```
重点看这几个指标:
- **P95/P99 延迟**——平均数会掩盖长尾问题
- **错误率**——5xx 突然升高说明后端可能有瓶颈
- **事件循环延迟**——Node.js 自带的 `perf_hooks` 可以测,超过 100ms 说明主线程被阻塞了
- **内存 RSS**——持续增长不回落,大概率有泄漏
内存泄漏排查可以用 `heapdump` 抓堆快照,用 Chrome DevTools 对比两次快照的差异,找出只增不减的对象。
## HTTP/2:条件成熟再上
HTTP/2 的多路复用、头部压缩确实能减少延迟,但对 Koa 应用来说,如果前面有 NGINX 做反向代理(生产环境通常都有),那 NGINX 到浏览器的链路启用 HTTP/2 就够了,Node.js 内部通信走 HTTP/1.1 完全没问题。
直接在 Koa 上启 HTTP/2 需要 TLS 证书,配置不简单,收益也有限。除非你的架构是 Node.js 直接对外暴露,否则优先级不高:
```javascript
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('server.key'),
cert: fs.readFileSync('server.crt')
}, app.callback());
server.listen(3000);
```
## 框架选择:该换就换
Koa 的定位是轻量级中间件框架,它把灵活性做到了极致,但性能不是它的首要目标。如果你的项目对吞吐量有硬性要求(比如 API 网关、高并发微服务),值得认真考虑 Fastify:
| 维度 | Koa | Fastify |
|------|-----|---------|
| 吞吐量 | 3-4.5 万 RPS | 5-8 万 RPS |
| 内存基线 | 30-50 MB | 50-80 MB |
| 中间件模型 | 洋葱模型(优雅) | 钩子模型(高效) |
| JSON 序列化 | 原生 JSON.parse | fast-json-stringify |
| 学习曲线 | 低 | 中 |
Fastify 通过 JSON Schema 预编译序列化、更扁平的中间件架构,在纯性能上比 Koa 快 50-80%。如果你的 Koa 应用已经优化到位还是扛不住,换框架可能比继续调参更实际。
这不是说 Koa 不好——它的洋葱模型在中间件编排上确实更优雅,错误处理也更自然。只是在极端性能场景下,架构选型的影响比代码优化大得多。服务端5月28日 04:26
Koa 中间件怎么写?洋葱模型和常见中间件一次搞懂Koa 中间件就是一个 async 函数,接收 `ctx`(请求上下文)和 `next`(调用下游中间件)两个参数。调用 `await next()` 把控制权交给下一个中间件,等下游全部执行完再返回——这种"先进后出"的执行流程叫洋葱模型。
自定义中间件的关键是理解 `await next()` 这一行:它前面是请求阶段的逻辑,后面是响应阶段的逻辑。忘了 `await`,后置代码会和下游并发执行,时序全乱。
```javascript
async function myMiddleware(ctx, next) {
// 请求阶段:进入洋葱
console.log(`${ctx.method} ${ctx.url}`);
await next(); // 交出控制权,等下游执行完再回来
// 响应阶段:出洋葱
console.log(`status: ${ctx.status}`);
}
app.use(myMiddleware);
```
中间件的注册顺序决定执行顺序——先 `app.use` 的先进入,但最后出来。实际项目中最常踩的坑:错误处理中间件没放在最前面,导致下游抛的异常它捕获不到。
## 追问
### 洋葱模型的执行顺序具体是怎样的?
两个中间件就能看清楚:
```javascript
app.use(async (ctx, next) => {
console.log('A 进');
await next();
console.log('A 出');
});
app.use(async (ctx, next) => {
console.log('B 进');
await next();
console.log('B 出');
});
// 输出:A 进 → B 进 → B 出 → A 出
```
底层实现是 `koa-compose`,它把中间件数组递归串联成一个 Promise 链。当你调用 `await next()` 时,实际调用的是 `compose` 生成的下一个函数;当所有中间件执行完,`next` 变成空函数,Promise 链开始回溯。
### 如何写可配置的中间件?
用工厂函数包一层,把配置项当参数传进去:
```javascript
function createLogger(opts = {}) {
return async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
};
}
app.use(createLogger({ format: 'tiny' }));
```
Koa 生态里的中间件(koa-bodyparser、koa-static 等)几乎都采用这个模式,好处是同一个中间件可以在不同路由上用不同配置。
### 常见中间件有哪些?各自解决什么问题?
| 类型 | 作用 | 代表实现 |
|------|------|----------|
| 错误处理 | 兜底捕获异常,统一错误响应格式 | 自写,放最前面 |
| 日志 | 记录请求方法、路径、耗时 | koa-logger |
| 认证 | 校验 token,拒绝未授权请求 | koa-jwt |
| CORS | 设置跨域响应头 | @koa/cors |
| 请求体解析 | 把请求体解析到 `ctx.request.body` | koa-bodyparser |
| 静态文件 | 托管静态资源 | koa-static |
| 会话 | 管理 cookie/session | koa-session |
| 路由 | 路径匹配和参数提取 | @koa/router |
Koa 本身只封装了 `ctx` 和中间件机制,这些能力全部靠中间件补齐。和 Express 的区别是:Express 中间件是线性的,Koa 是洋葱式的,所以 Koa 的错误处理和响应修改更自然。
### next() 不加 await 会怎样?
下游中间件照常执行,但你的后置逻辑会和下游并发运行,时序不可控。典型后果:日志中间件记录的 `ctx.status` 是默认的 404,因为真实状态码还没设置完你就读了;错误处理中间件捕获不到异常,因为错误还没抛出来你就已经退出了。只有一种场景可以不 `await`:发后即忘的埋点或异步通知,不阻塞响应。
### 多个中间件怎么组合管理?
用 `koa-compose` 把多个中间件合成一个,适合路由级别的中间件分组:
```javascript
const compose = require('koa-compose');
const authChain = compose([authMiddleware, rbacMiddleware]);
router.get('/admin', authChain, adminHandler);
```
大项目里建议按目录组织:`middleware/logger.js`、`middleware/auth.js`,在 `app.js` 里按顺序引入。注册顺序就是执行顺序,搞错了很难排查,所以保持 `app.use` 列表简洁清晰很重要。服务端5月28日 04:25
Koa 和 Express 有什么区别?洋葱模型原理是什么?Koa 和 Express 最大的区别是中间件模型:Express 是线性顺序执行,Koa 是洋葱模型——请求先进后出,每个中间件可以同时在请求阶段和响应阶段插入逻辑。
具体来说,Express 中间件按注册顺序依次执行,`next()` 调用后控制权交给下一个中间件,走完就结束了,不会再回来。Koa 通过 `async/await` 让 `await next()` 之后的代码在下游中间件执行完后继续运行,天然支持后置处理(比如统一加耗时日志、响应包装)。
其他关键区别:
- **体量**:Express 自带路由、静态文件服务等;Koa 核心只有约 550 行代码,路由等全靠第三方中间件
- **上下文**:Express 分开操作 `req` 和 `res`;Koa 封装成单个 `ctx` 对象,`ctx.request` / `ctx.response` 提供更友好的 API,同时 `ctx.req` / `ctx.res` 仍可访问原生对象
- **错误处理**:Express 需要手动在回调里 `if (err)` 或写错误中间件;Koa 可以在整个中间件链最外层用 `try/catch` 统一捕获,也可以监听 `app.on('error')`
- **异步**:Express 基于回调,异步错误容易丢失;Koa 原生 async/await,异步流程可预测
## 追问
### 洋葱模型具体怎么执行的?
```javascript
app.use(async (ctx, next) => {
console.log(1); // 请求进入
await next();
console.log(2); // 响应返回
});
app.use(async (ctx, next) => {
console.log(3);
await next();
console.log(4);
});
// 输出顺序:1 → 3 → 4 → 2
```
`await next()` 是分界线,前面是请求阶段逻辑,后面是响应阶段逻辑。这就是"洋葱"——从外层穿到内层,再从内层穿回外层。
### 为什么 Koa 不内置路由?
设计哲学不同。Koa 团队认为框架核心应该只做 HTTP 请求/响应的流转控制,路由、模板、静态文件这些属于应用层关注点,交给社区按需选择。好处是不用为不需要的功能买单,坏处是新手需要自己拼装中间件栈,上手成本稍高。
### Express 中间件能迁移到 Koa 吗?
不能直接迁移。Express 中间件签名是 `(req, res, next)`,Koa 是 `async (ctx, next)`,参数和异步模型完全不同。`koa-connect` 可以做桥接但不是长久之计,迁移基本等于重写。
### 实际项目怎么选?
快速出活、生态完善选 Express;需要精细控制请求生命周期、团队习惯 async/await 选 Koa。2026 年的新项目两者都不一定是首选——Fastify 性能更好,NestJS 提供更完整的工程化方案,按团队规模和技术偏好综合判断。服务端5月27日 19:46
Koa 中如何管理 Cookie 和 Session?## Cookie 和 Session 到底有什么区别?
HTTP 协议是无状态的,服务器收到请求后没办法知道这个请求是谁发的。Cookie 和 Session 都是为了解决这个问题,但路子完全不同:
- **Cookie** 是服务器写给浏览器的一小段数据,浏览器每次请求自动带上,容量约 4KB
- **Session** 是服务器自己存的数据,通过一个 Session ID 跟浏览器对应起来,大小没限制
两者的配合方式:服务端创建 Session,生成唯一的 Session ID,通过 `Set-Cookie` 响应头下发给浏览器;后续每次请求浏览器自动带着这个 Cookie,服务器拿 Session ID 去查对应的 Session 数据。
一个容易混淆的点:Session ID 本身就是通过 Cookie 传递的,所以 Session 依赖 Cookie,但 Cookie 可以独立使用(比如存用户偏好、主题设置等)。
## Koa 里怎么读写 Cookie?
Koa 内置了 Cookie 支持,不需要额外装中间件,直接用 `ctx.cookies` 就行。
### 设置 Cookie
```javascript
app.use(async (ctx) => {
// 最简单的写法
ctx.cookies.set('name', 'value');
// 带完整选项
ctx.cookies.set('token', 'abc123', {
maxAge: 86400000, // 有效期,单位毫秒,这里是一天
expires: new Date('2026-12-31'), // 过期时间点,和 maxAge 二选一
path: '/', // 生效路径,默认 /
domain: '.example.com', // 生效域名
secure: true, // 只在 HTTPS 下传输
httpOnly: true, // JS 不能读,防 XSS
sameSite: 'strict', // 同源才带,防 CSRF
signed: true // 签名防篡改
});
ctx.body = 'Cookie 已设置';
});
```
几个选项容易踩坑:
- `httpOnly: true` 不是可选项,是必选项。没有它,一段 XSS 脚本就能用 `document.cookie` 把你的登录凭证偷走
- `sameSite` 有三个值:`strict`(最严,跨站一律不带)、`lax`(导航到目标站点的 GET 请求会带,是浏览器默认值)、`none`(都带,但必须配 `secure: true`)
- `signed: true` 依赖 `app.keys`,没设置 keys 会报错。签名防的是篡改,不是加密——签过名的 Cookie 值客户端仍然能解码看到
### 读取 Cookie
```javascript
app.use(async (ctx) => {
const name = ctx.cookies.get('name');
ctx.body = `你好,${name}`;
});
```
设置了 `signed: true` 的 Cookie,`ctx.cookies.get()` 会自动校验签名。签名不对返回 `undefined`,不是报错——这一点要留意,调试时别以为是自己没存上。
### 删除 Cookie
```javascript
ctx.cookies.set('name', null, { maxAge: 0 });
```
把 `maxAge` 设成 0 就行。有个细节:`path` 和 `domain` 必须跟设置时完全一致,否则浏览器匹配不到那个 Cookie,删除操作会静默失败。这个坑在本地调试时特别容易遇到——设置了 `/api` 路径的 Cookie,删除时没带路径,结果怎么也删不掉。
## Koa 中 Session 怎么用?
Koa 核心不带 Session,需要装 `koa-session` 中间件。
### 安装和基本配置
```bash
npm install koa-session
```
```javascript
const session = require('koa-session');
// 必须先设置 keys,用于 Cookie 签名
app.keys = ['some-secret-key'];
app.use(session({
key: 'koa.sess', // Cookie 里存 Session ID 的字段名
maxAge: 86400000, // Session 有效期,毫秒
httpOnly: true, // JS 不可读
signed: true, // 签名防篡改
rolling: false, // 每次请求是否重置过期倒计时
renew: false // 快过期时是否自动续期
}, app));
```
`app.keys` 支持数组,用于密钥轮换:
```javascript
app.keys = ['new-key', 'old-key'];
```
签名用第一个密钥,校验按顺序尝试。换密钥时把新密钥放前面、旧密钥保留一段时间,已有的 Session 不会突然失效。
两个容易忽略的配置项:
- `rolling: true` -- 每次请求都刷新 Cookie 的过期时间,适合需要保持活跃会话的场景(比如后台管理系统),但会增加 Cookie 写入频率
- `renew: true` -- 只在 Session 快过期时自动续期,比 rolling 更轻量,是大多数场景的推荐选项
### 读写 Session
```javascript
// 登录 —— 写入 Session
app.use(async (ctx) => {
if (ctx.path === '/login' && ctx.method === 'POST') {
const { username, password } = ctx.request.body;
const user = await authenticate(username, password);
if (user) {
ctx.session.user = { id: user.id, name: user.name };
ctx.body = { message: '登录成功' };
} else {
ctx.throw(401, '用户名或密码错误');
}
}
});
// 受保护页面 —— 读取 Session
app.use(async (ctx) => {
if (ctx.path === '/profile') {
if (!ctx.session.user) {
ctx.throw(401, '请先登录');
}
ctx.body = `欢迎,${ctx.session.user.name}`;
}
});
// 登出 —— 销毁 Session
app.use(async (ctx) => {
if (ctx.path === '/logout') {
ctx.session = null;
ctx.body = '已登出';
}
});
```
销毁 Session 用 `ctx.session = null` 就够了,不需要逐个删属性。`koa-session` 会同时清掉服务端的 Session 数据和浏览器端的 Cookie。
## koa-session 默认把 Session 数据存在哪里?
这是个关键问题:`koa-session` 默认把 Session 数据序列化后直接塞进 Cookie 里。也就是说,浏览器每次请求都带着完整的 Session 数据。
这种默认行为有三个问题:
1. **4KB 上限** -- Cookie 有大小限制,Session 数据稍大就会被截断,而且报错不明显,容易排查半天才发现是 Cookie 溢出
2. **数据可读** -- 签名只防篡改,不防窥探。Session 数据只是 Base64 编码,浏览器开发者工具里一眼就能看到内容,敏感信息绝不能放进去
3. **带宽浪费** -- 每次请求都带着全量 Session 数据往返,用户量大了以后带宽开销不小
开发阶段用默认配置图方便没问题,上线之前必须换外部存储。
## 生产环境怎么用 Redis 存储 Session?
### 为什么选 Redis
- 纯内存操作,读写延迟在微秒级,Session 是高频读写场景,非常匹配
- 原生支持 TTL 过期,和 Session 的生命周期管理天然吻合
- 支持多实例共享,部署多个 Node 进程时只要连同一个 Redis 就行
### 配置方式
```bash
npm install koa-session ioredis
```
```javascript
const session = require('koa-session');
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
password: 'your-password',
db: 0
});
// koa-session 需要的 store 接口只有三个方法
const redisStore = {
async get(key) {
const data = await redis.get(`session:${key}`);
return data ? JSON.parse(data) : null;
},
async set(key, sess, maxAge) {
await redis.set(`session:${key}`, JSON.stringify(sess), 'EX', maxAge / 1000);
},
async destroy(key) {
await redis.del(`session:${key}`);
}
};
app.use(session({
store: redisStore,
key: 'koa.sess',
maxAge: 86400000,
httpOnly: true,
signed: true
}, app));
```
配置 Redis 之后,Cookie 里只剩一个 Session ID,真正的数据全在 Redis 里。应用部署多个实例也没问题,只要连的是同一个 Redis 集群,Session 就能跨实例共享。
几个生产环境的注意点:
- Redis 连接建议用连接池或集群模式,单点 Redis 挂了 Session 全丢
- key 的前缀(上面的 `session:`)按业务区分,避免和其他 Redis 数据冲突
- `maxAge / 1000` 是把毫秒转成秒,Redis 的 `EX` 参数单位是秒,这里容易写错
### 其他存储方案
除了 Redis,常见的还有:
- **MongoDB** -- 用 `connect-mongo` 之类的适配器,适合已经有 MongoDB 的项目,但性能不如 Redis
- **MySQL** -- 不推荐,关系型数据库做高频 Session 读写是大材小用,性能也跟不上
- **Memcached** -- 和 Redis 类似的内存缓存,但不如 Redis 生态完善,现在用的人少了
## 怎么实现登录认证中间件?
认证中间件的核心就是一件事:检查 Session 里有没有用户信息,没有就拦截。
```javascript
async function authRequired(ctx, next) {
if (!ctx.session.user) {
ctx.throw(401, '未登录');
}
await next();
}
// 只对需要认证的路由生效
router.get('/api/profile', authRequired, async (ctx) => {
ctx.body = ctx.session.user;
});
router.get('/api/settings', authRequired, async (ctx) => {
ctx.body = await getUserSettings(ctx.session.user.id);
});
```
中间件放在路由处理函数前面,没登录的请求在中间件层就打回去了,不会进业务逻辑。
更完善的做法是加上角色校验:
```javascript
function roleRequired(...roles) {
return async (ctx, next) => {
if (!ctx.session.user) {
ctx.throw(401, '未登录');
}
if (!roles.includes(ctx.session.user.role)) {
ctx.throw(403, '权限不足');
}
await next();
};
}
router.delete('/api/users/:id', roleRequired('admin'), async (ctx) => {
// 只有 admin 角色能访问
});
```
## Session 和 JWT 该怎么选?
两者不是非此即彼,但在不同场景下各有优势:
| 对比维度 | Session | JWT |
|---------|---------|-----|
| 存储位置 | 服务端(内存/Redis) | 客户端(Cookie/Header) |
| 状态 | 有状态,服务端维护会话 | 无状态,服务端不存数据 |
| 水平扩展 | 需要共享存储(Redis) | 天然支持,哪里都能验 |
| 主动失效 | 删掉服务端 Session 就行 | 做不到,只能等过期 |
| 数据安全 | 数据在服务端,客户端看不到 | Payload 只是 Base64,谁都能解码 |
| 实现复杂度 | 需要维护存储和清理 | 签发即忘,但吊销很麻烦 |
选型建议:
- **传统 Web 应用(SSR)** -- 用 Session。浏览器自动管理 Cookie,登出即失效,权限变更即时生效,开发体验最简单
- **前后端分离 / API 服务** -- 用 JWT。无状态减少服务端压力,适合微服务架构,客户端自己存 token
- **高安全要求** -- 两者结合:JWT 做接口认证(短期有效),关键操作再校验 Session(服务端可控)。银行、支付这类场景经常这么干
一个常见误区:觉得 JWT 无状态就一定比 Session 好。实际上 JWT 做不到主动失效,一旦签发就无法撤回。如果你需要"踢人下线"或"立即撤销权限"的能力,Session 反而更合适。
## Cookie 和 Session 的安全防护有哪些要点?
### Cookie 安全清单
- `httpOnly: true` -- 必设项。没有这个,XSS 攻击能直接偷 Cookie
- `secure: true` -- 生产环境必须开。确保 Cookie 只在 HTTPS 下传输,防止中间人窃听
- `sameSite: 'strict'` 或 `'lax'` -- 阻止跨站请求携带 Cookie,从源头防 CSRF。`strict` 最安全但可能影响从外链跳转的体验,`lax` 是较好的折中
- `signed: true` -- 签名防篡改,客户端改了 Cookie 值服务端能发现
- **Cookie 前缀** -- `__Host-` 前缀强制 `secure`、不设 `domain`、`path` 为 `/`;`__Secure-` 前缀强制 `secure`。浏览器会自动执行这些约束,推荐用在敏感 Cookie 上
### Session 安全清单
- `app.keys` 用强随机字符串,至少 32 位,从环境变量读取,不要硬编码在代码里
- 设置合理的 `maxAge`,不要设成永不过期。通常 1-7 天,根据业务调整
- 登出必须 `ctx.session = null` 彻底销毁,别只删 `ctx.session.user`
- 生产环境必须用 Redis 等外部存储,内存存储重启就丢,也没法跨进程共享
### Session Fixation 防护
Session Fixation 攻击的原理是:攻击者获取一个有效的 Session ID,诱骗受害者使用这个 ID 登录,攻击者就能用同一个 Session ID 访问受害者的会话。
防护方法:**登录成功后重新生成 Session ID**。
```javascript
app.use(async (ctx) => {
if (ctx.path === '/login' && ctx.method === 'POST') {
const user = await authenticate(ctx.request.body);
if (user) {
// 登录成功,先销毁旧 Session 再创建新的
ctx.session = null;
ctx.session.user = { id: user.id, name: user.name };
// koa-session 会在响应时生成新的 Session ID
ctx.body = { message: '登录成功' };
}
}
});
```
### 其他防护措施
- 限制同一账号的并发 Session 数量,防止 Session 被盗用后长期使用
- 记录认证日志(登录 IP、时间、设备),异常行为可以及时发现
- 实现登录失败次数限制和延迟,5 次失败后锁定 15 分钟,防暴力破解
- Session ID 要足够长且随机,用 `crypto.randomBytes(32)` 生成,避免被猜测或碰撞
- 敏感操作(修改密码、绑定手机)要求重新验证身份,不要仅依赖已有 Session服务端5月27日 19:41
Koa 洋葱模型的执行机制是怎样的?有哪些实际应用场景## Koa 洋葱模型到底是什么
先看一个现象:在 Koa 里写三个中间件,控制台打印的顺序是 1-前置 → 2-前置 → 3-核心处理 → 2-后置 → 1-后置。请求像穿透洋葱一样从外层进到最里层,再从里层一层层返回——这就是"洋葱模型"的名字由来。
这个机制不是 Koa 凭空发明的,它借鉴了 koa-compose 的函数组合思想,核心就一句话:每个中间件拿到 next 函数,调用它就进入下一层,await 它返回后就执行后置逻辑。
## 执行流程拆解
用一个最小可运行的例子说明:
```javascript
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('1-前置');
await next();
console.log('1-后置');
});
app.use(async (ctx, next) => {
console.log('2-前置');
await next();
console.log('2-后置');
});
app.use(async (ctx) => {
console.log('3-核心处理');
ctx.body = 'Hello Koa';
});
app.listen(3000);
```
请求进来后,执行路径是这样的:
1. 进入第一个中间件,执行 `console.log('1-前置')`
2. 遇到 `await next()`,暂停当前中间件,进入第二个中间件
3. 执行 `console.log('2-前置')`,再遇到 `await next()`,进入第三个中间件
4. 第三个中间件没有调用 next,设置 ctx.body 后返回
5. 回到第二个中间件,执行 `await next()` 之后的 `console.log('2-后置')`
6. 回到第一个中间件,执行 `console.log('1-后置')`
关键点在于 `await next()` 这一行。它不是简单的函数调用,而是一个 Promise——下一个中间件(以及它后续的所有中间件)全部执行完毕后,这个 Promise 才 resolve。所以 await 之后的代码天然就在所有下游中间件之后执行。
## compose 函数怎么实现的
洋葱模型的本质是 koa-compose,核心代码不到 30 行:
```javascript
function compose(middleware) {
return function (context, next) {
let index = -1;
function dispatch(i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
let fn = i === middleware.length ? next : middleware[i];
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0);
};
}
```
dispatch(i) 取出第 i 个中间件,把 `dispatch(i+1)` 作为 next 传进去。每个中间件内部 await next() 就是 await dispatch(i+1),递归调用下一层。当 i 等于 middleware.length 时,fn 为 next(外层传入的,通常为 undefined),递归终止。
有一个容易忽略的细节:`index` 变量用来检测 next() 是否被调用了多次。同一个中间件里调用两次 next() 会抛错,因为第二次调用时 i <= index 成立。这是有意为之——多次调用 next() 会导致下游中间件重复执行,产生不可预期的行为。
## 和 Express 中间件有什么区别
Express 的中间件是线性的:调用 next() 之后,控制权交给下一个中间件,不会再回来。Koa 的洋葱模型让控制权"去了又回",这是最根本的区别。
```javascript
// Express 风格
app.use((req, res, next) => {
console.log('前置');
next(); // 交出控制权,不再回来
console.log('这行也会执行,但响应可能已经发出');
});
// Koa 风格
app.use(async (ctx, next) => {
console.log('前置');
await next(); // 等下游全部完成,控制权回来
console.log('后置,此时可以修改响应');
});
```
这意味着在 Koa 里,后置逻辑可以可靠地操作响应——比如统一格式化返回值、记录响应日志、计算耗时。Express 里 next() 后面的代码虽然也能执行,但响应可能已经被下游发出了,再改就晚了。
另一个区别是错误处理。Express 需要在中间件链末尾放一个四个参数的错误处理中间件 `(err, req, res, next) => {}`。Koa 只需要在最外层 try-catch:
```javascript
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = { error: err.message };
}
});
```
因为洋葱模型保证了外层中间件的后置逻辑一定会执行,所以 try-catch 能捕获到任何内层抛出的异常。
## 实际项目中怎么用洋葱模型
### 请求耗时统计
```javascript
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
ctx.set('X-Response-Time', `${ms}ms`);
});
```
前置逻辑记录开始时间,后置逻辑计算差值并写入响应头。这是洋葱模型最直观的用法——前置做初始化,后置做收尾。
### 统一错误处理
```javascript
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.statusCode || 500;
ctx.body = {
code: ctx.status,
message: err.message
};
// 生产环境不暴露堆栈
if (process.env.NODE_ENV !== 'production') {
ctx.body.stack = err.stack;
}
}
});
```
放在最外层,任何内层抛出的异常都会被捕获。不需要在每个路由里单独 try-catch。
### 认证与权限控制
```javascript
app.use(async (ctx, next) => {
const token = ctx.headers.authorization;
if (!token) {
ctx.throw(401, '未登录');
}
try {
ctx.state.user = jwt.verify(token.replace('Bearer ', ''), SECRET);
} catch {
ctx.throw(401, 'token 无效');
}
await next();
});
```
如果认证失败,直接抛错不调用 next(),下游中间件不会执行。这是洋葱模型的另一个特性:中间件可以选择"截断"请求,不往下传。
### 响应格式统一
```javascript
app.use(async (ctx, next) => {
await next();
if (ctx.body && !ctx.body.code) {
ctx.body = {
code: 0,
data: ctx.body,
message: 'success'
};
}
});
```
后置逻辑里检查 ctx.body,如果路由返回的是裸数据,就包装成统一格式。业务代码不需要关心响应结构。
## 使用洋葱模型容易踩的坑
### 忘记 await next()
```javascript
app.use(async (ctx, next) => {
console.log('前置');
next(); // 忘记 await
console.log('后置'); // 会立即执行,不等下游完成
});
```
next() 返回 Promise,不加 await 后置逻辑会立即执行,洋葱模型失效。更严重的是,如果下游中间件是异步操作(查数据库、调接口),后置逻辑执行时响应可能还没准备好。
### 中间件顺序搞反
洋葱模型里,先注册的中间件包裹在后注册的外面。所以日志和错误处理要放最前面,路由放最后面。顺序写反了,错误处理就捕获不到路由层的异常。
### 在后置逻辑里修改请求
有些开发者习惯在后置逻辑里继续操作 ctx.request,但此时请求已经处理完了,修改请求对象没有意义。后置逻辑应该只操作 ctx.response 或 ctx.body。
## 洋葱模型适用于哪些场景
不是所有场景都需要洋葱模型。如果你的应用只有简单的请求-响应,Express 的线性中间件更直观。洋葱模型的优势在于需要在请求前后都执行逻辑的场景:日志、计时、错误兜底、认证拦截、响应包装。中间件越多、前后置逻辑越复杂,洋葱模型的价值越大。
理解洋葱模型的关键不是记住执行顺序,而是理解 await next() 是一个分界线——之前的代码在请求进入时执行,之后的代码在响应返回时执行。把握住这一点,写中间件就不会出错。服务端5月27日 18:30
Koa 错误处理怎么写?从洋葱模型到完整方案## Koa 的错误处理和其他框架有什么不同?
Koa 的错误处理设计跟 Express 有本质区别。Express 用中间件参数签名来区分普通中间件和错误处理中间件——四个参数 `(err, req, res, next)` 才是错误处理中间件。Koa 走了另一条路:它借助 async/await,让 try-catch 自然地包裹整个下游中间件链,配合洋葱模型实现错误冒泡。这意味着你只需要在洋葱模型的最外层放一个 try-catch,就能捕获所有内层抛出的错误。
理解这一点,是写好 Koa 错误处理的前提。
## Koa 错误传播的原理是什么?
Koa 的洋葱模型中,每个中间件都有机会在 `await next()` 之后执行逻辑。如果某个内层中间件抛出错误,这个错误会沿着调用栈向上冒泡,直到被某一层的 try-catch 捕获,或者到达框架顶层。
关键细节:Koa 框架顶层有兜底逻辑。如果一个错误始终没被任何中间件捕获,Koa 会尝试返回 500,并触发 `app.on('error')` 事件。但如果响应头已经发送(`ctx.headerSent` 为 true),Koa 无法再修改状态码和响应体,只能把错误抛给 Node.js 的 `unhandledRejection`。这是实际开发中容易踩的坑——在流式响应场景中尤其要注意。
## 如何用 ctx.throw 抛出标准 HTTP 错误?
`ctx.throw` 是 Koa 提供的快捷方法,用于抛出带 HTTP 状态码的错误:
```javascript
app.use(async (ctx) => {
if (!ctx.query.token) {
ctx.throw(401, 'Token is required');
}
ctx.body = 'Success';
});
```
`ctx.throw` 的第一个参数是状态码,第二个参数是错误消息。它内部会创建一个 `HttpError` 对象并抛出,这个对象携带 `status`、`message` 等属性,方便外层中间件统一处理。
需要注意的是,`ctx.throw` 只支持 HTTP 标准状态码对应的错误。如果你需要携带自定义的业务错误码(比如 `INVALID_PARAM`),应该用自定义错误类代替 `ctx.throw`。
## 怎么写错误处理中间件?
错误处理中间件必须放在所有业务中间件之前,也就是洋葱模型的最外层。只有这样,内层所有中间件的错误才能被捕获:
```javascript
async function errorHandler(ctx, next) {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
if (ctx.app.env === 'development') {
ctx.body = {
error: err.message,
stack: err.stack,
code: err.code
};
} else {
ctx.body = {
error: 'Internal Server Error',
code: 'INTERNAL_ERROR'
};
}
ctx.app.emit('error', err, ctx);
}
}
app.use(errorHandler);
```
这段代码做了三件事:设置状态码、构建响应体、触发错误事件。开发环境返回堆栈信息方便调试,生产环境隐藏细节防止信息泄露。`ctx.app.emit('error', err, ctx)` 把错误转发给全局监听器,用于日志记录和监控上报。
常见误区:有人把错误处理中间件放在路由中间件之后,这样它就无法捕获路由中抛出的错误——因为洋葱模型中,后注册的中间件在内层,内层的 try-catch 捕获不到外层已经抛出的错误。
## 如何设计自定义错误类?
`ctx.throw` 只能抛出 HTTP 标准错误,实际项目中往往需要更丰富的错误信息。自定义错误类可以携带业务错误码、错误详情等字段:
```javascript
class AppError extends Error {
constructor(status, message, code) {
super(message);
this.status = status;
this.code = code;
this.name = 'AppError';
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(404, message, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(400, message, 'VALIDATION_ERROR');
this.name = 'ValidationError';
}
}
class AuthError extends AppError {
constructor(message = 'Authentication required') {
super(401, message, 'AUTH_ERROR');
this.name = 'AuthError';
}
}
```
使用时直接抛出,错误处理中间件会自动识别 `status` 和 `code`:
```javascript
app.use(async (ctx) => {
const user = await findUser(ctx.params.id);
if (!user) {
throw new NotFoundError('User not found');
}
if (!user.isActive) {
throw new AuthError('User account is deactivated');
}
ctx.body = user;
});
```
设计自定义错误类时,建议让所有业务错误继承同一个基类 `AppError`,这样错误处理中间件可以通过 `instanceof` 判断错误类型,做差异化处理。
## 全局错误事件怎么用?
`app.on('error')` 是 Koa 的全局错误事件监听器。所有未被中间件完全处理的错误,以及中间件中手动 `ctx.app.emit('error', err, ctx)` 触发的错误,都会到达这里:
```javascript
app.on('error', (err, ctx) => {
console.error(`[${new Date().toISOString()}] ${ctx.method} ${ctx.url}`);
console.error(`Status: ${err.status || 500}, Code: ${err.code || 'UNKNOWN'}`);
console.error(`Message: ${err.message}`);
// 上报监控系统
monitoringService.report(err, ctx);
// 严重错误发送告警
if (err.status >= 500) {
alertService.send(err, ctx);
}
});
```
全局错误事件的职责是日志记录、监控上报、告警通知。不要在这里修改 `ctx` 的响应——因为到了这一步,响应可能已经发出去了。响应格式化是错误处理中间件的事,全局监听只管记录。
还有一个容易忽略的点:如果错误处理中间件捕获了错误并正常响应了客户端,但没有调用 `ctx.app.emit('error')`,这个错误就不会到达全局监听器。这意味着你需要做一个选择——哪些错误需要全局记录。通常建议:所有 500 及以上的错误都应该 emit 到全局,4xx 的客户端错误可以视情况决定。
## 404 怎么处理?
Koa 不会自动返回 404。如果一个请求没有匹配到任何路由,也没有任何中间件设置响应体,Koa 默认返回 404 状态码和 `Not Found` 纯文本。但在实际项目中,你通常需要返回统一格式的 JSON 响应:
```javascript
// 放在所有路由之后
app.use(async (ctx) => {
ctx.status = 404;
ctx.body = {
error: 'Not Found',
code: 'NOT_FOUND',
path: ctx.url
};
});
```
这个中间件的原理是:如果前面的路由中间件已经处理了请求(设置了 `ctx.body`),Koa 不会再执行后续中间件。只有请求穿透了所有路由,才会落到这个兜底中间件。
更优雅的做法是判断 `ctx.status === 404 && !ctx.body`,避免覆盖其他中间件故意设置的 404 响应。
## 异步错误在 Koa 中怎么处理?
Koa 基于 async/await,能自动捕获 async 函数中抛出的同步错误。但有些场景需要额外注意:
```javascript
// 直接 await — 错误会正常冒泡
app.use(async (ctx) => {
const data = await fetchData();
ctx.body = data;
});
// 未 await 的 Promise — 错误不会被捕获
app.use(async (ctx) => {
fetchData().then(data => { // 危险!如果 fetchData reject,错误不会冒泡
ctx.body = data;
});
});
```
第二条规则:永远不要在 Koa 中间件里写 `.then()` 而不 await。未 await 的 Promise 如果 reject,错误会被吞掉,不会冒泡到错误处理中间件,也不会触发全局错误事件。这是 Node.js 中 `unhandledRejection` 的常见来源。
对于第三方回调风格的异步操作,用 `Promise` 包装后再 await:
```javascript
const { promisify } = require('util');
const readFile = promisify(fs.readFile);
app.use(async (ctx) => {
try {
const content = await readFile(ctx.query.path, 'utf8');
ctx.body = content;
} catch (err) {
if (err.code === 'ENOENT') {
throw new NotFoundError('File not found');
}
throw err;
}
});
```
## 数据库和第三方服务的错误怎么统一处理?
数据库驱动抛出的错误通常有特定的错误码,需要转换成 HTTP 友好的格式。在错误处理中间件中针对不同错误类型做转换:
```javascript
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
// PostgreSQL 唯一约束冲突
if (err.code === '23505') {
ctx.throw(409, 'Resource already exists');
}
// PostgreSQL 外键约束冲突
if (err.code === '23503') {
ctx.throw(400, 'Invalid reference');
}
// MongoDB 重复键
if (err.code === 11000) {
ctx.throw(409, 'Duplicate key error');
}
// JWT 过期
if (err.name === 'TokenExpiredError') {
ctx.throw(401, 'Token expired');
}
// 请求超时
if (err.code === 'ECONNABORTED' || err.code === 'ETIMEDOUT') {
ctx.throw(504, 'Request timeout');
}
throw err;
}
});
```
这种做法把底层错误码翻译成 HTTP 语义,对客户端更友好。但要注意,这些转换逻辑不应该无限膨胀——如果某个数据库的错误码特别多,应该封装成专门的错误转换函数。
## 一个完整的错误处理方案长什么样?
把上面的各个部分组合起来,得到一个可用的完整方案:
```javascript
const Koa = require('koa');
const app = new Koa();
// 自定义错误类
class AppError extends Error {
constructor(status, message, code) {
super(message);
this.status = status;
this.code = code;
this.name = 'AppError';
}
}
class NotFoundError extends AppError {
constructor(message = 'Resource not found') {
super(404, message, 'NOT_FOUND');
}
}
class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(400, message, 'VALIDATION_ERROR');
}
}
// 错误处理中间件 — 放在最前面
app.use(async (ctx, next) => {
try {
await next();
// 兜底 404
if (ctx.status === 404 && !ctx.body) {
ctx.body = { error: 'Not Found', code: 'NOT_FOUND', path: ctx.url };
}
} catch (err) {
ctx.status = err.status || 500;
const response = {
error: err.message,
code: err.code || 'INTERNAL_ERROR',
timestamp: new Date().toISOString()
};
if (app.env === 'development') {
response.stack = err.stack;
}
ctx.body = response;
ctx.app.emit('error', err, ctx);
}
});
// 全局错误事件
app.on('error', (err, ctx) => {
console.error(`[${new Date().toISOString()}] ${ctx.method} ${ctx.url} - ${err.status || 500}`);
if (err.status >= 500) {
monitoringService.report(err, ctx);
}
});
// 业务路由
app.use(async (ctx) => {
if (ctx.path === '/users/:id') {
const user = await findUser(ctx.params.id);
if (!user) throw new NotFoundError('User not found');
ctx.body = user;
}
ctx.body = { message: 'OK' };
});
app.listen(3000);
```
这套方案覆盖了自定义错误类、错误处理中间件、全局事件监听、404 兜底、开发/生产环境差异化响应。把它作为项目模板,根据实际需求增减即可。
写 Koa 错误处理,核心就是三件事:把错误处理中间件放在最前面,用自定义错误类统一错误格式,在全局事件中做好日志和监控。搞清洋葱模型中错误的传播方向,其他问题都好解决。服务端5月27日 18:29
Koa 中 Context 对象 ctx 有哪些核心属性和用法?## Koa 的 Context 对象是什么?
Koa 的 Context 对象(即 ctx)是 Koa 框架中最核心的概念之一。它将 Node.js 原生的 request 和 response 对象封装到一个统一的对象中,并通过代理机制让开发者可以直接在 ctx 上访问请求和响应的属性,不必反复切换 req/res。
理解 ctx,本质上就是理解 Koa 的设计哲学——用更少的代码完成更多的事情。
## ctx 的代理机制是怎么工作的?
很多开发者只知道 `ctx.query` 能拿到查询参数,但并不清楚它为什么能直接用。实际上,ctx 上许多属性并不是自己定义的,而是通过 Object.defineProperty 代理到 ctx.request 和 ctx.response 上的。
具体来说,当你访问 `ctx.query` 时,实际执行的是 `ctx.request.query`;当你设置 `ctx.body` 时,实际设置的是 `ctx.response.body`。这种代理机制的好处是减少了代码嵌套层级,让中间件的写法更加扁平。
需要注意的一点是,并非所有 request/response 上的属性都被代理了。对于没有被代理的属性,你仍然需要通过 `ctx.request.xxx` 或 `ctx.response.xxx` 来访问。
## 请求相关属性有哪些?
ctx 提供了两组请求属性的访问方式:便捷访问和完整访问。
**便捷访问(直接通过 ctx):**
- `ctx.url` — 请求路径,包含查询字符串
- `ctx.method` — 请求方法(GET、POST 等)
- `ctx.header` — 请求头对象
- `ctx.query` — 解析后的查询字符串对象,例如 `/api?name=koa` 会得到 `{ name: 'koa' }`
- `ctx.path` — 请求路径,不包含查询字符串
- `ctx.host` — 请求的主机名
**完整访问(通过 ctx.request):**
- `ctx.request.querystring` — 原始查询字符串(未解析),例如 `name=koa`
- `ctx.request.search` — 包含 `?` 的原始查询字符串
- `ctx.request.type` — 请求的 Content-Type
- `ctx.request.accept` — 客户端接受的内容类型
- `ctx.request.ip` — 客户端 IP 地址
实际开发中,`ctx.query` 和 `ctx.method` 是使用频率最高的两个请求属性。获取请求体数据(`ctx.request.body`)则需要额外引入 koa-bodyparser 中间件,Koa 本身不内置 body 解析功能。
```javascript
app.use(async (ctx) => {
// 获取查询参数
const { page, size } = ctx.query;
// 获取请求方法和路径
console.log(ctx.method, ctx.path);
// 获取客户端 IP
const ip = ctx.request.ip;
});
```
## 响应相关属性有哪些?
和请求类似,响应也有便捷访问和完整访问两种方式。
**便捷访问(直接通过 ctx):**
- `ctx.body` — 响应体,支持字符串、Buffer、Stream、Object(自动序列化为 JSON)
- `ctx.status` — HTTP 状态码
- `ctx.type` — 响应的 Content-Type
- `ctx.redirect(url)` — 重定向到指定 URL
**完整访问(通过 ctx.response):**
- `ctx.response.header` — 响应头对象
- `ctx.response.length` — 响应 Content-Length
- `ctx.response.lastModified` — Last-Modified 时间戳
- `ctx.response.etag` — ETag 值
设置 `ctx.body` 时有一些细节值得注意:如果 body 是一个对象,Koa 会自动设置 Content-Type 为 `application/json`;如果 body 是字符串,则默认为 `text/plain`。你也可以通过 `ctx.type` 手动覆盖。
```javascript
app.use(async (ctx) => {
// 返回 JSON
ctx.body = { code: 0, data: { list: [] } };
// 返回 HTML
ctx.type = 'html';
ctx.body = '<h1>Hello</h1>';
// 设置状态码后重定向
ctx.status = 302;
ctx.redirect('/login');
});
```
## ctx.state 有什么用?
`ctx.state` 是 Koa 官方推荐的命名空间,用于在中间件之间传递数据。它的设计初衷是避免在 ctx 上随意挂载属性导致命名冲突。
```javascript
// 认证中间件
app.use(async (ctx, next) => {
const token = ctx.header.authorization;
if (token) {
ctx.state.user = verifyToken(token); // 将用户信息挂到 state 上
}
await next();
});
// 业务中间件
app.use(async (ctx) => {
const user = ctx.state.user; // 从 state 取出用户信息
ctx.body = { name: user.name };
});
```
这个模式在实际项目中非常常见。除了用户信息,你还可以用它存储请求 ID、权限标识、分页参数等中间件间需要共享的数据。
## ctx.cookies 怎么操作?
Koa 内置了 Cookie 操作能力,不需要额外安装中间件。ctx.cookies 提供了 get 和 set 两个方法。
```javascript
app.use(async (ctx) => {
// 读取 Cookie
const sessionId = ctx.cookies.get('sid');
// 设置 Cookie
ctx.cookies.set('sid', 'abc123', {
maxAge: 86400000, // 有效期 1 天,单位毫秒
httpOnly: true, // 禁止 JS 访问,防止 XSS
secure: true, // 仅 HTTPS 传输
sameSite: 'lax', // 防止 CSRF
});
});
```
设置 Cookie 时,`httpOnly` 和 `sameSite` 是两个安全相关的选项,生产环境中建议始终配置。`maxAge` 比 `expires` 更常用,因为它指定的是相对时间,不受时区影响。
## ctx.throw 和 ctx.assert 怎么用?
Koa 提供了两种错误处理方式:`ctx.throw()` 和 `ctx.assert()`。
`ctx.throw()` 用于主动抛出 HTTP 错误:
```javascript
app.use(async (ctx) => {
const user = await findUser(ctx.query.id);
if (!user) {
ctx.throw(404, '用户不存在');
}
});
```
`ctx.assert()` 是 `ctx.throw()` 的断言封装,条件为 false 时抛出错误:
```javascript
app.use(async (ctx) => {
ctx.assert(ctx.query.id, 400, '缺少用户 ID');
ctx.assert(ctx.state.user, 401, '未登录');
});
```
两种方式抛出的错误都会被 Koa 的错误事件捕获,你可以在 app.on('error') 中统一处理日志记录和监控上报。相比之下,`ctx.assert()` 的写法更简洁,适合做参数校验。
## ctx.app 是什么?
`ctx.app` 是当前 Koa 应用实例的引用。通过它可以访问应用级别的配置和回调,比如 `ctx.app.env` 获取运行环境、`ctx.app.proxy` 判断是否信任代理头等。日常开发中用得不多,但在编写通用中间件时偶尔需要。
## ctx.req 和 ctx.res 与 ctx.request 和 ctx.response 有什么区别?
这是初学者容易混淆的一对概念:
- `ctx.req` / `ctx.res` — Node.js 原生的 http 模块请求和响应对象,功能原始,不经过 Koa 封装
- `ctx.request` / `ctx.response` — Koa 封装后的对象,提供了更友好的 API
除非你需要操作一些 Koa 没有封装的底层功能(比如 `ctx.res.writeHead()`),否则应始终优先使用 `ctx.request` 和 `ctx.response`。直接操作 `ctx.res` 可能会绕过 Koa 的中间件机制,导致响应处理逻辑失效。
## 实际项目中的 ctx 使用模式
了解了各个属性之后,更重要的是知道在实际项目中如何组织 ctx 的使用。以下是一个典型的中间件链中 ctx 的流转过程:
```javascript
const Koa = require('koa');
const app = new Koa();
// 请求日志中间件
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${duration}ms`);
});
// 认证中间件
app.use(async (ctx, next) => {
const token = ctx.header.authorization;
ctx.assert(token, 401, '缺少认证信息');
ctx.state.user = verifyToken(token);
await next();
});
// 业务路由
app.use(async (ctx) => {
const { page = 1, size = 10 } = ctx.query;
const list = await getList(ctx.state.user.id, page, size);
ctx.status = 200;
ctx.body = { code: 0, data: { list, total: list.length } };
});
app.listen(3000);
```
这个例子展示了 ctx 在整个请求生命周期中的角色:从日志中间件读取 method 和 url,到认证中间件校验 header 和写入 state,再到业务层读取 query 和设置 body,ctx 始终是贯穿所有中间件的数据枢纽。服务端2月21日 15:54
Koa 与 Express 框架的详细对比和选择建议Koa 与 Express 是两个流行的 Node.js Web 框架,它们各有特点和适用场景。理解它们的差异有助于在实际项目中做出正确的选择。
**1. 核心设计理念:**
**Express:**
- 内置大量功能(路由、中间件、模板引擎等)
- 提供开箱即用的解决方案
- 采用传统的回调函数模式
- 中间件链式调用
**Koa:**
- 极简核心,只提供最基础的功能
- 通过中间件扩展功能
- 采用现代 async/await 模式
- 洋葱模型中间件机制
**2. 中间件机制对比:**
**Express 中间件:**
```javascript
const express = require('express');
const app = express();
app.use((req, res, next) => {
console.log('Middleware 1');
next();
console.log('Middleware 1 after');
});
app.use((req, res, next) => {
console.log('Middleware 2');
res.send('Hello Express');
});
// 执行顺序:Middleware 1 -> Middleware 2 -> Middleware 1 after
```
**Koa 中间件:**
```javascript
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
console.log('Middleware 1 before');
await next();
console.log('Middleware 1 after');
});
app.use(async (ctx, next) => {
console.log('Middleware 2 before');
await next();
console.log('Middleware 2 after');
ctx.body = 'Hello Koa';
});
// 执行顺序:Middleware 1 before -> Middleware 2 before ->
// Middleware 2 after -> Middleware 1 after
```
**3. 代码风格对比:**
**Express 回调风格:**
```javascript
app.get('/users/:id', (req, res, next) => {
User.findById(req.params.id, (err, user) => {
if (err) return next(err);
Post.findByUserId(user.id, (err, posts) => {
if (err) return next(err);
res.json({ user, posts });
});
});
});
```
**Koa async/await 风格:**
```javascript
app.get('/users/:id', async (ctx) => {
const user = await User.findById(ctx.params.id);
const posts = await Post.findByUserId(user.id);
ctx.body = { user, posts };
});
```
**4. 请求/响应处理对比:**
**Express:**
```javascript
app.get('/', (req, res) => {
// 请求信息
const url = req.url;
const method = req.method;
const query = req.query;
const body = req.body;
// 响应设置
res.status(200);
res.json({ message: 'Hello' });
// 或
res.send('Hello');
// 或
res.render('index', { title: 'Hello' });
});
```
**Koa:**
```javascript
app.get('/', async (ctx) => {
// 请求信息
const url = ctx.url;
const method = ctx.method;
const query = ctx.query;
const body = ctx.request.body;
// 响应设置
ctx.status = 200;
ctx.body = { message: 'Hello' };
// 或
ctx.type = 'text/html';
ctx.body = '<h1>Hello</h1>';
});
```
**5. 错误处理对比:**
**Express 错误处理:**
```javascript
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
error: err.message
});
});
// 抛出错误
app.get('/error', (req, res, next) => {
const err = new Error('Something went wrong');
err.status = 500;
next(err);
});
```
**Koa 错误处理:**
```javascript
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = {
error: err.message
};
ctx.app.emit('error', err, ctx);
}
});
// 抛出错误
app.get('/error', async (ctx) => {
ctx.throw(500, 'Something went wrong');
});
```
**6. 路由功能对比:**
**Express 内置路由:**
```javascript
const express = require('express');
const router = express.Router();
router.get('/users', getUsers);
router.post('/users', createUser);
router.get('/users/:id', getUser);
router.put('/users/:id', updateUser);
router.delete('/users/:id', deleteUser);
app.use('/api', router);
```
**Koa 需要路由中间件:**
```javascript
const Router = require('@koa/router');
const router = new Router();
router.get('/users', getUsers);
router.post('/users', createUser);
router.get('/users/:id', getUser);
router.put('/users/:id', updateUser);
router.delete('/users/:id', deleteUser);
app.use(router.routes());
app.use(router.allowedMethods());
```
**7. 性能对比:**
**Express:**
- 成熟稳定,经过大量生产环境验证
- 中间件链式调用,性能相对较低
- 回调函数,可能存在回调地狱
- 内存占用相对较高
**Koa:**
- 更轻量级,核心只有约 2KB
- async/await,代码更简洁
- 洋葱模型,中间件控制更灵活
- 内存占用相对较低
**8. 学习曲线对比:**
**Express:**
- 文档丰富,社区活跃
- 学习曲线平缓
- 大量教程和示例
- 适合初学者
**Koa:**
- 需要理解 async/await
- 需要理解洋葱模型
- 需要选择合适的中间件
- 适合有一定经验的开发者
**9. 适用场景对比:**
**Express 适合:**
- 快速开发原型
- 传统 Web 应用
- 需要大量内置功能的项目
- 团队成员对 async/await 不熟悉
- 需要稳定成熟的框架
**Koa 适合:**
- 现代 Web 应用
- 需要精细控制中间件的项目
- 追求代码简洁和可维护性
- 团队熟悉现代 JavaScript
- 需要更好的错误处理
**10. 迁移建议:**
从 Express 迁移到 Koa:
```javascript
// Express
app.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
});
// Koa
app.get('/users/:id', async (ctx) => {
const user = await User.findById(ctx.params.id);
ctx.body = user;
});
```
**总结:**
| 特性 | Express | Koa |
|------|---------|-----|
| 核心大小 | 较大 | 极小(2KB) |
| 中间件模式 | 链式调用 | 洋葱模型 |
| 异步处理 | 回调函数 | async/await |
| 路由 | 内置 | 需要中间件 |
| 学习曲线 | 平缓 | 较陡 |
| 社区生态 | 成熟 | 快速发展 |
| 性能 | 良好 | 优秀 |
| 适用场景 | 传统应用 | 现代应用 |
选择建议:
- 如果追求快速开发和稳定性,选择 Express
- 如果追求代码质量和现代化,选择 Koa
- 如果团队熟悉 async/await,优先选择 Koa
- 如果需要大量内置功能,选择 Express