代码在腐烂之前,往往先从接口开始。我接手那个项目的第三周,终于被一个诡异的线上事故逼到了墙角——前端调用了GET /user/info,后端返回的却是{data: {userInfo: ...}},而另一个服务同样的语义用的是POST /api/getUser。没人说得清哪个是对的,因为两套代码分别出自两个已经离职的同事,而他们的命名习惯恰好代表了两个时代。我花了一下午翻完仓库里全部三十七个controller,发现接口风格至少有五种:有REST的、有RPC的、有动词式URL的、还有把SQL直接拼在参数里的。那一刻我意识到,团队缺的不是代码规范,而是对“API到底代表什么”的共同认知。
周一早上,我在技术群里扔了一张截图,是某个内部服务返回的报错信息:{"code": "500", "msg": "服务器异常"}。讽刺的是,这个报错来自一个明明应该返回404的接口。群里安静了三十秒,然后有人开始甩锅:“这是老接口,没人动过。”我没接话,而是发了一句话:“如果我们继续容忍这种接口,下个月就会有人写第五种风格出来。”当天下午,我拉了一个五人小组,宣布要用一周时间重构整个团队的API设计规范。反对声最先来自维护老系统的同事:“改规范可以,但存量接口怎么办?”我回答得很干脆——规范的价值不在于约束存量,而在于让增量不再制造新的混乱。
其实“重构规范”这件事,真正的难点不是写文档,而是逼所有人回答一个哲学问题:你的接口到底在暴露资源,还是在暴露动作?团队里默认的说法是“我们做的是RESTful API”,但真拆开看,大部分人只是把HTTP动词当成了摆设。有人用GET去修改订单状态,因为“这样前端用起来方便”;有人用POST去查列表,因为“参数太长GET怕缓存”。我们把七个核心业务模块的接口全部列在白板上,逐个标记它们的“语义——动词——URL——返回结构”,结果发现有一半的接口从设计第一天就错了。混乱不是技术债,是认知债,债主不是代码,而是设计者脑子里对“这个接口应该承担什么职责”的模糊。
第一天的讨论格外痛苦。后端同事坚持要在URL里写动作片段,比如/order/cancel,理由是“一眼就能看懂”;前端同事则抱怨“你们后端连个统一的返回包装都没有,我怎么拦截错误?”我提议先放下所有技术偏好,只问一个问题:如果这个接口被第三方调用,对方最想拿到什么?没人回答。因为团队从来没想过API会有“外部视角”。这几年大家一直活在内部系统里,接口随便写,参数随便加,反正调用方是同一个公司的人,出了问题拉个群就能解决。正是这种“内部系统”的傲慢,毁掉了所有规范的可能性。
第二天,我拿了一份从GitHub上扒下来的Stripe风格API文档,放在投影仪上。没有多余的话,先让大家看它如何定义错误:一个status字段,一个error对象,里面是type和message,所有错误统一结构。然后看它的URL命名:全部是复数名词,动作全部收敛到HTTP动词。有同事说:“这不就是教科书吗?”我说:“对,但我们的问题是从没人愿意翻教科书。”教科书不是给你背诵的,是给你在无人可问时当作参照系的。我们决定不照搬任何一家公司的规范,而是基于自己的业务模型,提炼出三个核心原则:第一,API的第一公民是资源,不是功能;第二,错误信息必须包含“人可读”和“机器可读”两部分;第三,任何接口都必须能通过URL反推出它的属性和能力。
原则定下来之后,真正的挑战才开始:怎么把原则翻译成可操作的规则。我让每个人随机挑选当前系统里的一个旧接口,按新原则重新设计,然后全体投票。有个老哥们选了订单查询,原接口是POST /order/query,参数直接传JSON,返回一个极度复杂的嵌套结构。他新设计成GET /orders?status=paid&page=2,返回扁平化的列表,每项只含必要字段。大家投票说好,他又补了一句:“可这样改动,前端得全量适配。”我说:“那也值得,因为你今天不还这笔债,明天就得还复利。” 大家笑了,但气氛松动了。重构规范不是删掉旧代码,而是定义一个新的“默认选项”,让以后写接口的人不需要思考就走在正确的路上。
到了第三天,我们开始制定具体条款。第一件事是统一响应结构,我提出用{ok: true, data: ...}或者{ok: false, error: {code, message, detail}}。有个资深后端皱眉:“这样所有接口都要包一层,性能有损耗。”我说:“性能损耗的优先级永远低于认知一致性。如果你的接口响应连个统一的信封都没有,那每个调用方都得写一套解析逻辑,这才是最大的浪费。” 我们最终定了下来:所有正常的业务成功码一律200,所有业务失败用4xx/5xx,但响应体里的error结构必须保持一致。
第四天,处理了一个最敏感的问题:接口版本管理。旧系统很多接口没有版本号,导致不断有人偷偷改字段,改完也不通知。我提出必须强制在URL中带上/v1/,并且任何破坏性变更必须升到v2。立刻有人说:“那v1永远留在那里,会很乱。”我反问他:“如果你不给旧版本一个合法的存续位置,开发者就会在同一个版本里偷偷做破坏性修改,那才叫真正的乱。” 版本号不是讨价还价的东西,它是对下游的承诺。我们随后在文档里写了一条铁律:“任何对输入/输出结构的修改,只要导致旧调用方报错,就必须视为破坏性变更,必须升版本号。”那一天最后,我们还定义了一个“扩展字段”的规则:新增可选项时,如果放到响应体末尾,并且加上_ext后缀,允许不升版本,但必须写入变更日志。
第五天的讨论几乎变成了辩论。焦点是“参数校验到底在API层做还是业务层做”。以前团队的习惯是业务层自己校验,结果每个接口的报错信息千奇百怪——有的返回参数错误,有的返回{code: 40001, msg: 'xxxx is invalid'},还有的直接抛出异常让Spring默认处理,返回一坨HTML。我们决定把校验收归到API网关层,所有接口入参必须显式声明schema,校验失败统一返回422,并且error.message要写明具体字段名和约束条件。一个不会告诉你“哪里错了”的API,就是在逼调用方用猜的。那一晚我加班到凌晨,不是为了写代码,而是为了把团队里长期存在的“只要结果对,过程无所谓”的思维扳过来。
到了第六天,我们把草稿整理成了一份完整的规范文档,共四章:一、资源和URL设计;二、HTTP动词与状态码语义;三、响应与错误结构;四、版本生命周期。但这还不是最终的胜利。因为一份没人遵守的规范,还不如一张废纸——所以我做了一个大胆的决定:下午找所有相关团队的负责人开了个会,要求每个人现场用自己的业务场景,尝试违反这条规范,看能不能找出现实中不得不违抗的情形。有一个团队说:“我们的导出功能要生成Excel,URL里怎么表达?”我们讨论后给出了方案:POST /exports,返回一个ExportJob对象,前端轮询GET/exports/{id},下载时再GET/exports/{id}/content。另一个团队说:“我们有个内部任务调度器,这算资源吗?”我们回答:“算,任务就是资源,你可以用PUT去更新它的配置,用POST去触发它执行。”世界上没有不能建模成资源的业务,只有懒得建模的人。散会时,我看到有人眼神里依然有质疑,但没有人再反对。
第七天,我们没有继续讨论技术,而是做了一件小事:把旧的API文档全部下线,在新文档站上挂上了这份规范,并附了一个一键检测脚本——它能扫描项目代码,自动标记不符合新规则的接口,并给出改版建议。当天下午就有同事跑来说:“我用脚本跑了一下,发现我的接口有17处不符合规范。”他语气里带着沮丧。我说:“这恰恰是好消息,因为从今天起,你有了一份明确的地图,而不是在黑暗中蒙着眼睛走路。” 我看着那个数字从17慢慢变成0的过程,明白了一周时间到底改变了什么。我们换掉的不是命名规则,不是响应格式,而是每个工程师在写接口前那一刻的思考方式——从‘我该怎么把数据传过去’变成了‘这个资源应该对外呈现什么状态’。
那一周结束后的周例会上,技术总监问我:这份规范要多久更新一次?我说:“规范不是纪念碑,而是活的操作系统,每迭代一次功能,就要回头审视它一次。” 他若有所思地点点头。我知道很多人觉得“花一周时间只为了写一份文档”是浪费时间,但我的看法完全不同——如果这一周能避免未来无数个深夜的故障排查、避免几十个因为狗屁接口风格引发的吵架,那它就是我们做过最值钱的投资。
后来,新加入团队的实习生问我:“为什么要用一周时间来重构API规范,而不是直接写代码?”我指了指电脑屏幕上那套他刚提交的代码,里面有一个接口路径叫/delete_user_by_id。我没有直接批评他,只是让他翻开规范手册,翻到“资源命名”那一章,然后问他:“你说,DELETE /users/{id}和/delete_user_by_id,哪一个更像是一台机器对世界发出的指令?”他愣了几秒,笑着说:“我知道了。”那一刻我确信,一周重构的不只是团队的API设计规范,更是团队对“专业”这两个字的最低尊重。规范最终会过时,但那个因为规范而被纠正的思维习惯,会一直留在每个人写下的每一行代码里。