做一个实验:同一个接口 /report/42,浏览器打开看到一张 HTML 报表页,命令行 curl 拿到 JSON,脚本带上特定头还能拿到 CSV。代码里只有一个处理器。这就是内容协商——客户端用 Accept 头报口味,服务端按口味出货。本节讲清多格式响应的写法、协商的机制,以及错误响应的统一形态——后者是所有接口消费方最关心的"格式"。
最直接的分支写法,按查询参数或路径决定格式:
router.get('/report/:id', auth(), async (ctx) => { const data = await reportService.load(ctx.params.id); if (ctx.query.format === 'csv') { ctx.type = 'text/csv; charset=utf-8'; ctx.set('Content-Disposition', 'attachment; filename="report.csv"'); ctx.body = toCsv(data); return; } ctx.body = data; // 默认 JSON });
这能跑,但格式选择写死在服务端。REST 风格的正统做法是把选择权交给请求头——客户端 Accept 里早就说清了自己要什么,ctx.accepts 负责解析:
router.get('/report/:id', auth(), async (ctx) => { const data = await reportService.load(ctx.params.id); switch (ctx.accepts(['json', 'html', 'csv'])) { case 'json': ctx.body = data; // 内核自动 JSON 序列化 break; case 'html': ctx.type = 'html'; ctx.body = renderReportPage(data); // 模板渲染结果 break; case 'csv': ctx.type = 'text/csv; charset=utf-8'; ctx.body = toCsv(data); break; default: ctx.status = 406; // 客户端口味全都给不了 ctx.body = { code: 'NOT_ACCEPTABLE', supported: ['json', 'html', 'csv'] }; } });
ctx.accepts(['json', 'html', 'csv']) 按 Accept 头的权重挑一个双方都能接受的格式,全都不可接受时返回 false,对应 HTTP 语义里的 406 Not Acceptable。curl 验证三种口味:
curl http://localhost:3000/report/42 -H 'accept: application/json' # {"id":"42","rows":[...]} curl http://localhost:3000/report/42 -H 'accept: text/html' # <html>…报表页… curl http://localhost:3000/report/42 -H 'accept: text/csv' # id,sku,qty # 42,A1,2
注意浏览器与 curl 的默认行为差异:浏览器的 Accept 头里 text/html 权重最高,所以直接访问拿到页面;curl 不带头时 Koa 默认给 JSON——这就是"同一个接口、不同客户端不同格式"的全部机制,没有魔法,只有头。
我的实战口径很明确:管理后台与内容型站点用协商——同一份数据,运营要看页面、脚本要拉数据,一份处理器两种出货,维护成本省一半。纯 API 服务不做协商——客户端只有前端一种,Accept 协商是白付的复杂度,直接 JSON 出货加统一错误结构更干脆。判断依据就一条:你真的有不止一种消费方吗?没有就别把 406 这类边角状态码引进系统。
还有一条实用边界:二进制资源不参与协商。文件下载(4.4 节)的格式由资源本身决定,协商只对"同一数据的多种表达"生效,别把下载接口也塞进 switch。
对客户端来说,最重要的"格式约定"不是成功响应,而是错误响应。2.4 节的兜底层已经给出了骨架,这里把它补成完整的形态规范:
// 成功:数据直接在顶层,分页类附加 meta { "id": "42", "status": "paid", "items": [ ... ] } // 错误:code 供程序判断,message 供人看,details 给表单定位 { "code": "INVALID_ARGUMENT", "message": "请求参数不合法", "requestId": "req-8f3a12", "details": [ { "field": "address", "message": "address 长度不得超过 200" } ] }
四条设计判断支撑这个形态。code 用稳定字符串不用数字——数字错误码必然与 HTTP 状态码重复且容易失控,字符串常量(INVALID_ARGUMENT、NOT_FOUND)可读、可 grep、可枚举。message 是给人读的,技术细节(堆栈、SQL)绝不进这个字段。requestId 是排障的桥——用户报障时报这串号,后端按号检索日志(第 6.2 节的日志体系围绕它建立)。details 是字段级的,表单场景前端直接映射到输入框。
让所有错误长一个样子的关键,是只有兜底层一个出口(2.4 节的纪律):业务代码只负责抛类型化异常,任何接口的错误响应都经过同一段代码生成——形态不统一的问题,根源几乎都是错误出口不统一。
💡 关键直觉:接口的"格式稳定"比"格式丰富"重要得多。消费方最怕的不是少一种格式,而是同样的错误有时拿到 JSON 有时拿到 HTML 错误页——那意味着每个消费方都要写两套解析。宁可砍掉花式格式,也要保证结构永远可预测。
最后补两个 body 序列化的边角,都在真实联调里坑过人。其一,循环引用直接炸:ctx.body = someOrmEntity 时,ORM 实体带着双向引用,JSON.stringify 抛 Converting circular structure to JSON。解法是出层前做白名单映射(pick 需要的字段),既避免炸裂又防止把内部字段(password、内部状态)漏给客户端。其二,大对象的序列化开销:上万条记录的整体序列化会阻塞事件循环几十毫秒——分页不是产品需求,有时是性能刚需(8.1 节量化过这类开销)。
// 白名单映射:既防循环引用,又防字段泄漏 function toOrderVO(order) { return { id: order.id, status: order.status, items: order.items.map((it) => ({ sku: it.sku, qty: it.qty })), createdAt: order.createdAt, }; // 没有 passwordHash,没有内部审批链 }
JSON、HTML、CSV 都能出了,还剩一大类出层流量没有走业务处理器——静态文件。下一节讲它们怎么直出、怎么带缓存策略、怎么用 304 省带宽。