2.1 接口设计:RESTful 与小程序约定
场景:前后端对着空气联调
迁移第一周,前端和我就接口格式吵了一架。我按 RESTful 的直觉给了 GET /api/getPostList,前端说他要 /api/feed;我对错误情况返回 HTTP 500 加一段文字,前端说他解析不了。联调会上谁也说服不了谁,原因不是谁水平不行,而是双方对约定没有共识。当天下午我拉着前端把所有接口在表格里列了一遍,连分页参数叫 page 还是 offset 都写死,之后两个月没再为接口格式红过脸。
小程序场景还有它自己的特殊性:请求都要走 wx.request,超时太长在弱网下用户体验差;小程序对并发数也有限制,同时发起的请求超过十个会被系统排队。所以接口设计不能照搬 App 或网页的经验,必须把数据合并粒度和小程序的网络特性一起考虑进去。我们的超时策略是分接口设置:首屏接口八秒、普通列表十秒、上传走独立通道不占并发额度。另外还有一条隐性约束:接口一旦发布就无法强制小程序端即时更新,因为用户可能用着旧版本,所以接口必须保持向后兼容,能加字段不能改字段,能加枚举值不能删。
联调会后我们还补了一个动作:接口契约表格挂在仓库里,每次改接口先改表格,表格评审通过再写代码。这条流程后来救过我们好几次,最典型的是列表接口要加版本字段,前端旧版本解析不了,靠表格里标注的兼容性说明拦住了这次事故。
还有一个细节值得单说:接口命名我们统一用小写加下划线,不用驼峰。原因是小程序端 JS 用驼峰,后端用下划线,总有一边要转换,与其约定谁迁就谁,不如统一在接口层用下划线、各端自行转换。这种小决定看起来不起眼,但每个接口都省一次沟通,一个项目下来省下的时间是按天算的。
方案:动词加资源的轻量约定
我们没有严格套 RESTful 的五个动词,而是取其中最直观的部分:读取用 GET,写入用 POST,路径表达资源。复杂动作比如点赞、收藏,直接写成 POST /api/like、POST /api/favorite,比硬凑 RESTful 语义让前端更好理解。分页统一用 page 和 page_size,排序用 sort 参数,时间戳统一毫秒。错误码独立于 HTTP 状态,HTTP 只表达管道层,业务错误在 body 里给 code。这套约定我们故意做得比教科书简单,四个人的团队套完整 RESTful 的学习成本和口舌成本都不值当,用得顺手比用得标准更要紧:
| 约定项 | 规则 | 示例 | 理由 |
|---|---|---|---|
| 资源路径 | 名词复数 | /api/posts | 直观 |
| 读取 | GET | GET /api/feed/:sid | 可缓存 |
| 写入 | POST | POST /api/post | 简单 |
| 分页 | page 加 page_size | page=2&page_size=20 | 前端统一 |
| 错误 | HTTP 200 加业务 code | code=1001 | 前端解析简单 |
| 鉴权 | header 带 token | x-auth-token | 不污染 URL |
统一返回体只有一个形状,前端封装一个 request 函数就能覆盖所有接口。后端再也不会直接抛字符串错误,排查问题看 code 就能定位到模块。这套约定还降低了测试成本:自动化脚本只认 code 和 data 两个键,新增接口时回归测试自动套用。
错误码也做了分段规划,避免不同模块各定各的、数字撞车。这套错误段规划后来还顺手解决了告警分级难题,值班的同学看 code 段就知道是报警还是只是提示。这张表至今贴在契约文档开头,值班同学看告警时第一眼就能判断严重程度:
| 错误段 | 含义 | 示例 | 前端处理 |
|---|---|---|---|
| 0 | 成功 | code=0 | 直接用数据 |
| 1xxx | 参数与校验 | 1001 未登录 | 跳登录页 |
| 2xxx | 频率与风控 | 2003 频繁操作 | 显示提示 |
| 3xxx | 业务冲突 | 3001 已点过赞 | 静默忽略 |
| 5xxx | 服务异常 | 5001 系统忙 | 显示兜底 |
配置与代码
统一响应体的中间件,所有业务逻辑只管往 ctx.state.data 里塞数据,包装由中间件完成:
// middleware/response.js
const SUCCESS = 0;
module.exports = async (ctx, next) => {
try {
await next();
ctx.body = { code: SUCCESS, msg: 'ok', data: ctx.state.data || {} };
} catch (e) {
ctx.body = { code: e.code || 500, msg: e.message, data: {} };
}
};
帖子列表接口的分页和排序约定,排序键做白名单校验防止注入 SQL:
const SORT_MAP = { new: 'created_at', hot: 'score' };
router.get('/api/feed/:sid', async ctx => {
const { page = 1, page_size = 20, sort = 'new' } = ctx.query;
const order = SORT_MAP[sort] || 'created_at';
const rows = await postModel.list({
section_id: ctx.params.sid,
offset: (page - 1) * page_size,
limit: +page_size,
order
});
ctx.state.data = { list: rows, page: +page, has_more: rows.length === +page_size };
});
业务里抛错误也统一走一个 BizError 类,code 必须来自错误段规划表,不允许随手写魔法数字。这样错误码天然集中可查,新人加接口时翻一遍表就知道往哪个段里放:
class BizError extends Error {
constructor(code, msg) { super(msg); this.code = code; }
}
// 使用示例:未登录
throw new BizError(1001, '请先登录');
// 使用示例:频繁操作
throw new BizError(2003, '操作太频繁,请稍后再试');
踩坑
第一个坑是分页深翻。信息流允许一直往下拉,page 一旦超过五十,MySQL 的 LIMIT 1000,20 扫描行数暴涨,接口耗时从五十毫秒涨到一秒多,后来改成游标分页,用上一页最后一条的 created_at 加 post_id 做定位,深翻耗时稳定在三十毫秒。第二个坑是 HTTP 状态码混用,有一阵我心血来潮对业务错误返回 4xx,结果前端在某些微信版本下拿不到 body 只看到错误提示,紧急回滚成全部 200 加业务 code。第三个坑是接口默认值,page_size 默认二十,但前端忘记传参时我后端给了五十,结果首屏流量翻了一倍,弱网用户加载时间从一秒变成三秒,统一默认值后恢复。第四个坑是 GET 的 URL 长度,管理后台批量拉帖子 ID 列表拼在 URL 里,超过两千字符被 Nginx 拒绝,改成 POST 加 body 解决,虽然不 RESTful 但稳。第五个坑是兼容性事故,列表字段把 view_cnt 改名成 read_cnt,旧版本小程序展示为零,又是灰度又是热修复才平息,从此字段只增不改。第六个坑是并发请求超限,小程序里同时发起的请求超过十个会被系统排队,有一次首屏同时拉了八个接口,弱网下一半超时,后来把首屏合并成一个聚合接口,请求数从八个降到两个。第七个坑是缓存键带分页参数忘了转字符串,page=2 和 page=02 生成了两个缓存,排查半天才发现是键名拼接的陷阱,之后键名一律先标准化再拼。第八个坑是接口没做幂等,弱网下用户连点两次发帖,后台真的出现两条重复帖子,前端加防抖、后端加幂等键双向兜底后才根治。
小结
接口约定的价值不在优雅,在于消灭沟通成本。一张表写死动词、分页、错误码、鉴权方式,联调时连吵架的素材都没有。这套约定后来也复用到了管理后台和开放接口上,只加了签名参数。永远记住小程序接口的特殊性:双端版本不可控,兼容性要写进流程而不是靠自觉。最后送一句经验之谈:接口文档别图省事写成代码注释,表格化的契约文档才是前后端都能读的东西,我们团队 wiki 里那张契约表至今还是访问量最高的页面。对接口防护感兴趣的同学,可以对比看 PHP 接口实战 的做法,思路高度一致;契约表的灵感多少来自 PHP SaaS 实战 的多端协作章节。
延伸阅读
- PHP 接口实战:设计与防护(错误码与限流对照)
- Node 建站实战(Koa 路由组织)
- 小程序商城实战:从选型到上线(wx.request 的封装实践)
