👋 大家好,我是十三! 今天看到了一篇关于API设计的讨论摘要,不禁回想起被背包接口折腾的痛苦经历,所以想和大家聊聊接口设计这件"小事"。
🎯 故事开端:从背包那一系列让我抓狂的接口说起
2年前我接手过一个项目,里面有一系列处理背包业务的接口,它的请求体大概是这样:map<string, map[string]interface>。对,就是 map 套 map,是不是想吐槽。当初设计这些接口的同学,可能是想用这种"通用"结构来适配未来可能出现的一切背包信息:证件、标签、地址...所有可以用key:value表示的跟用户物品有关的信息
理想很丰满,现实很骨感,我在维护这个接口的时候,想死的心都有了:
- 不看代码鬼知道第二个
map里的 key 到底该传idcode还是address?值又该是map还是string? - 每次去迭代这个接口,我都得花很长时间去梳理这个参数的转化,特别是隔的时间久了,我还得对着单测再看一遍转化
- 我下游服务的同学也经常跟我吐槽啥时候重构下,这个接口也太复杂了,对接起来太麻烦了,有没有简单一些的接口
- 这个接口又同时包含了好几个领域的知识,我迭代地址改这里,我迭代证件信息还是改这里,每次改完都当心影响到了其他业务
你以为这就完了?不,还有一个"缝合怪"接口,同时支持批量新增和批量删除。一个 POST 请求过来,里面混着要创建的数据和要删除的 ID。底层的逻辑得写多少 if/else 去判断?事务怎么保证?部分成功部分失败了又该怎么返回?
这痛苦感受让我反复的思考一个问题:
设计 API 时,我们到底应该追求通用性,让接口'大而全';还是应该追求专一性,让接口职责单一?
我想了很久,得出的结论是:都对,但得看场景
🚨 巨型接口的"原罪":当一个接口想"包办一切"
举一个我们最近在做项目的接口例子:会员信息接口 GET /users/{userId}/membership。
这个接口一次性返回了关于会员的大部分信息,包括:用户的当前等级和积分、完整的会员等级体系说明、每个等级对应的所有权益详情...是的,它差不多覆盖到了对应页面上所需要的所有信息
但在我看来,这是不合理的。这种设计至少犯下了三宗"原罪":
-
性能和带宽的浪费 会员信息是展示在个人中心的卡片中,表现为一个会员标识和等级。在这个场景下,接口下发了如此多的信息,绝大部分是不被使用到的。大而全的会员信息增加接口的延迟和产生额外的带宽,这些都是负面的影响,甚至是浪费
-
前后端的深度耦合 这种接口,就是一颗"定时炸弹"。后期不敢轻易动它,因为不知道这个大而全的接口被多少个地方使用着,改动一个字段会影响到哪些页面哪些域,这都是不好评估的。过往也有类似的接口仅仅是因为刚刚好可以取到需要某个需要字段就在某个场景下被拿去使用了
-
缓存策略的噩梦 这个返回包里,混合了不同生命周期的东西:会员等级体系(变化频率极低)、用户的当前权益(变化频率高,升级时改变)、用户的积分(可能随时都在变)。请问,这个接口的缓存时间你该怎么设?设长了,积分不准;设短了,等于没缓存。最后只能放弃治疗,导致那些本可以长期缓存的数据也被一次又一次地重复拉取。
我认为这种大而全的接口,是为了偷懒或者当前场景下的某种妥协,而为后续的维护埋雷。有得必有失,为了当前的开发效率或者通用性,牺牲了可读性和易维护性。
⚡ 我的答案:分层!像"外交部"和"后厨"一样设计 API
既然在单个接口层面讨论"大"还是"小"会陷入两难,那我们不妨退一步,从架构的视角来看。
我的答案是:分层。把我们的 API 体系,清晰地划分成多层,让它们扮演截然不同的角色。
第一层:BFF 层 API —— 体面的"外交官"
BFF (Backend for Frontend) 层,就是我们系统的"外交部"。它的客户,是Web、iOS、Android 这些挑剔的"外国友人"。
- 它的职责:专门为前端服务,负责聚合、裁剪、适配数据。前端要什么,它就给什么,一个或者几个接口覆盖端上的一个场景
- 它的形态:追求"场景完备"。一个接口搞定一个页面或一个完整的用户操作。这里的"大而全"是褒义词,是为了最大化前端的开发效率,减少客户端的网络请求。
- 举个例子:端上个人中心的会员信息,BFF 就提供一个
GET /my-membership-profile接口。这个接口内部,可能会去调用好几个更底层的服务,拿到用户状态、权益列表、积分历史..组装裁剪,一次性满足端上所需要的完整信息
第二层:原子服务层 API —— 专注的"大厨"
如果说 BFF 是外交官,那原子服务就是我们恪尽职守的"后厨"。它的客户,是聚合服务或其他内部内部服务。它不关心页面如何编排如何布局如何交互
- 它的职责:坚守自己的领域,维护数据的一致性。用户服务就只管人,订单服务就只管订单。
- 它的形态:严格遵守"单一职责"。接口必须"小而美",返回纯粹、干净、未经加工的领域数据,绝不为任何特定的 UI 场景妥协。
- 举个例子:
GET /users/{id}:就只返回用户的核心信息。GET /membership/levels:就只返回全局的等级定义。
通过划分服务层级来降低复杂度,在大而全和小而美之间来找到平衡点
💡 再进一步:如何定义原子服务的"单一职责"?
分层之后,新的问题来了:原子服务的"单一职责",这个"单一"的边界到底在哪,我们如何去定义这个边界是合理的
这又是一个经典的难题,以我熟悉的用户域举个例子:用户的"认证信息"(如手机、邮箱、三方ID)和他的"公开资料"(如昵称、头像),应该放在一个用户服务里吗?
这直接决定了 GET /users/{id} 这个API的返回内容,是典型的API设计权衡。
不妙的是我们目前的现状:这些信息都在一个接口中返回了,仔细思考它们其实是可以拆分的。
设想一下: 一个社交功能的页面,仅仅需要展示用户的头像和昵称。如果这个接口返回了包含了用户手机号、邮箱甚至更敏感信息的一个大而全的对象,会发生什么?
- 安全风险:不必要的敏感数据在网络中多了一次传输,就多了一分泄露的风险,整个包含了用户敏感信息的对象就这么暴露给端上了
- API职责混乱:这个
/users/{id}接口到底是一个对内提供身份验证支持的接口,还是一个对外提供公开信息展示的接口?它的安全等级完全不同。
它们是两种不同的业务能力,应该由两个不同的原子服务来承载:
- 认证服务 (Auth Service):负责用户的注册、登录、密码管理。它的API(如
POST /login)高度安全,只在内部被需要身份认证的场景调用。 - 资料服务 (Profile Service):负责用户的昵称、头像、简介等公开信息。它的API(如
GET /profiles?user_ids=1,2,3)则可以有更宽松的权限,专门为需要展示用户信息的前端场景服务。
所以我总结了一个简单粗暴的思考方法:
问自己一句话:"使用这个数据的场景和安全等级,是一回事吗?"
- "登录"和"展示头像"显然不是一回事。
- "修改密码"和"修改昵称"的风险等级也天差地别。
当场景和安全等级出现显著差异时,就强烈建议将它们拆分成不同的接口。这是我对单一职责的理解。
🏆 总结一下:我的 API 设计"三原则"
-
原则一:分清你的客户 设计接口前,先问这接口给谁用?是给端上用,还是给另一个服务用?这是所有设计的前提。给前端用的,就去BFF层、聚合层、场景层做聚合;给内部用的,就在原子服务层保持纯粹。
-
原则二:默认用"单一职责" 拿不准的时候,就让接口先只干一件事。接口多几个没关系,逻辑清晰、职责单一比什么都重要。未来真的需要合并,也比把一个臃肿的接口拆开要容易得多。
-
原则三:杜绝一切"魔法" 严禁在契约里使用
map、object这类模糊结构。所有请求体和响应体,都必须用 OpenAPI (Swagger) 之类的工具把 Schema 定义得清清楚楚。每个字段的名字、类型、是否必传,都得明明白白,这既是给调用方看的,也是给你自己看的。
说到底,好的 API 设计,本质上是一种同理心。对调用你接口的兄弟的同理心,更是对未来那个要回来维护这坨代码的你自己的同理心。
希望今天的分享,能让大家在下次设计接口时,少一些纠结,多一些底气。
🗣️ 一起聊聊
- 你遇到过哪些让你拍案而起(或者想直接跑路)的奇葩API设计?
- BFF 听起来很美,但在你的团队里落地了吗?
- 在敏捷开发的压力下,我们有时候不得不做一些"丑陋"但快速的设计。
期待在评论区看到你的真知灼见!
👨💻 关于十三Tech
资深服务端研发工程师,AI编程实践者。
专注分享真实的技术实践经验,相信AI是程序员的最佳搭档。
希望能和大家一起写出更优雅的代码!
📧 联系方式:569893882@qq.com
🌟 GitHub:@TriTechAI
💬 微信:TriTechAI(备注:十三Tech)
扫码关注不迷路