标签

Koa

在前端技术的跃进浪潮中,一个框架脱颖而出 —— Koa.js,它是由Express原班人马打造的新一代Node.js框架。为什么它能成为时下热议的焦点?因为Koa.js以其简洁的设计,强大的功能和对现代JavaScript特性(如async/await)的天然支持,重新定义了后端的开发模式。 简洁:Koa.js提供了一个轻量的函数库,让你能够快速搭建服务器。 现代化:它采用最新的JS特性,使得代码更加直观且易于管理。 灵活:通过中间件机制,你可以轻松扩展功能,实现定制化的解决方案。 性能:Koa.js注重性能优化,可以建立更快、更稳定的网络应用。 不仅如此,Koa.js的优雅编程体验和提升的开发效率,让前端工程师的技能得到了全方位的提升。它不是简单的技术更迭,而是前端领域的一次革新旅程。

Koa
服务端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` 加了时间戳后缀,避免并发测试时邮箱唯一约束冲突。这个小技巧省了无数调试时间。 写测试这件事,起步觉得麻烦,写顺手了你会发现:改代码的胆子大了很多,部署前不再心虚,凌晨三点的报警也少了。框架选型五分钟搞定,踩坑排查才花时间——把坑提前在测试里踩掉,比在线上踩便宜太多了。
服务端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