学习芭芭拉·利斯科夫的数据抽象原则,设计稳定接口、减少破坏,并以清晰、可靠的 API 构建可维护系统。

芭芭拉·利斯科夫是一位计算机科学家,她的工作悄然塑造了现代软件团队构建不会崩溃系统的方式。她关于数据抽象、信息隐藏以及后来提出的**里氏替换原则(LSP)**的研究影响了从编程语言到我们日常思考 API 的方式:定义清晰行为、保护内部实现,并让他人安全地依赖你的接口。
可靠的 API 并非只是理论上的“正确”。它应该能让产品更快推进:
这种可靠性是一种体验:对于调用你 API 的开发者、维护它的团队,以及间接依赖它的用户都是如此。
数据抽象的思想是:调用方应通过一个概念(账户、队列、订阅)与之交互,通过一组小而明确的操作——而不是通过它如何存储或计算的混乱细节。
当你隐藏表示细节时,你就消除了整类错误:没人能“意外”依赖一个不该公开的数据库字段,或以系统无法处理的方式修改共享状态。同样重要的是,抽象降低了协调成本:只要公共行为保持一致,团队无需许可即可重构内部实现。
本文末尾你将获得可操作的方法来:
如果你想快速回顾,跳到 /blog/a-practical-checklist-for-designing-reliable-apis。
数据抽象是一个简单的想法:你通过它做什么来交互,而不是它怎么构建。
想象售货机。你不需要知道电机如何转动或硬币如何计数。你只需要控制(“选择商品”、“支付”、“领取商品”)和规则(“如果付款足够,你得到商品;如果售罄,你收到退款”)。这就是抽象。
在软件中,接口是“做什么”:操作名称、接受的输入、产生的输出以及可能的错误。实现是“怎么做”:数据库表、缓存策略、内部类和性能技巧。
将两者分离,你就能在系统演进时保持 API 稳定。你可以重写内部、替换库或优化存储——而接口对用户保持不变。
抽象数据类型是“容器 + 允许的操作 + 规则”,在不承诺具体内部结构的情况下描述其行为。
示例:栈(后进先出)。
关键是承诺:pop() 返回最近的 push()。栈是用数组、链表还是其他结构实现的都是私有的。
同样的分离适用于各处:
POST /payments 是接口;欺诈检查、重试和数据库写入是实现。client.upload(file) 是接口;分片、压缩和并行请求是实现。当你以抽象为中心设计时,你关注的是用户依赖的契约——并为自己争取在幕后自由更改的一切权利。
不变式是在抽象内部必须始终为真的规则。如果你在设计 API,不变式是防止数据漂移到不可能状态的护栏——比如一个同时有两种货币的银行账户,或一个没有商品却标记为“已完成”的订单。
把不变式想成类型的“现实形状”:
Cart 不能包含负数数量。UserEmail 永远是有效的电子邮件(不是“稍后验证”)。Reservation 的 start < end,且两个时间在相同时区。如果这些语句不再成立,系统会变得不可预测,因为每个功能都需要猜测“损坏”数据意味着什么。
好的 API 在边界处强制不变式:
这自然而然地改善了错误处理:API 能解释哪个规则被违反,而不是模糊的“出错了”。
调用方不应记住诸如“该方法只有在调用 normalize() 之后才有效”之类的内部规则。如果不变式依赖于特殊流程,它就不是不变式——而是一个陷阱。
将接口设计为:
在记录 API 类型时,写下:
一个好的 API 不仅是一组函数——它是一个承诺。契约使该承诺明确,这样调用方就能依赖行为,维护者也能在不意外用户的情况下改变实现。
至少记录:
这种清晰性使行为可预测:调用方知道哪些输入是安全的,以及哪些结果需要处理,测试可以验证承诺而不是猜测意图。
没有契约时,团队依赖记忆和非正式规范:"别传 null"、"那个调用有时会重试"、"出错时返回空"。这些规则在入职、重构或事故中会丢失。
书面契约把隐形规则变成共享知识,也为代码审查提供稳定目标:讨论会变成“这次改动仍然满足契约吗?”而不是“我这边能跑”。
含糊: “Creates a user.”
更好: “Creates a user with a unique email.
email must be a valid address; caller must have users:create permission.userId; the user is persisted and immediately retrievable.409 if email already exists; returns 400 for invalid fields; no partial user is created.”含糊: “Gets items quickly.”
更好: “Returns up to limit items sorted by createdAt descending.
nextCursor for the next page; cursors expire after 15 minutes.”信息隐藏是数据抽象的实用面:调用方应依赖API 做什么,而不是API 如何做。如果用户看不到你的内部实现,你就可以在不把每次发布变成破坏性变更的情况下更改它们。
好的接口发布一小套操作(create、fetch、update、list、validate),把表示——表、缓存、队列、文件布局、服务边界——保持为私有。例如,“将商品加入购物车”是一个操作。来自数据库的“CartRowId”是实现细节。暴露该细节会诱使用户基于它构建逻辑,从而冻结你更改的能力。
当客户端仅依赖稳定行为时,你可以:
而 API 保持兼容,因为契约没有移动。这才是真正的回报:为用户提供稳定性,为维护者提供自由。
内部实现意外泄露的几种方式:
status=3 而不是明确的名称或专用操作。优先返回描述语义的响应,而不是机制:
"userId": "usr_…")而非数据库行号。如果某个细节可能会改变,就别发布它。如果用户需要它,就把它提升为接口承诺中有意的、带文档的部分。
里氏替换原则(LSP)一句话:如果一段代码能与某个接口协同工作,当你用该接口的任意合法实现替换时,代码也应继续工作——无需特殊处理。
LSP 更少关注继承,更多关注信任。当你发布一个接口时,你就是在做出行为承诺。LSP 表明所有实现都必须保持该承诺,即使它们在内部采用非常不同的方法。
调用方依赖于接口所声明的内容——而不是接口今天的实现细节。如果接口声明“你可以用任意合法记录调用 save()”,那么每个实现都必须接受那些合法记录。如果接口声明“get() 返回一个值或明确的‘未找到’结果”,那么实现就不能随意抛出新错误或返回部分数据。
安全的扩展意味着你可以添加新实现(或切换提供者)而不强迫用户重写代码。这就是 LSP 的实用回报:保持接口可替换。
两种常见的破坏承诺方式:
更窄的输入(更严格的前置条件): 新实现拒绝接口定义允许的输入。例如:基接口接受任意 UTF‑8 字符串作为 ID,但某个实现只接受数字 ID 或拒绝空但合法的字段。
更弱的输出(后置条件放宽): 新实现返回比承诺更少。例如:接口说结果为已排序、唯一或完整——而某实现返回无序数据、重复项或悄悄丢弃条目。
第三种微妙的违规是改变失败行为:一个实现返回“未找到”,另一个为同样情况抛异常,调用方就无法安全替换实现。
为了支持“插件式”实现,将接口写成契约:
如果某实现确实需要更严格的规则,不要把它隐藏在相同接口后面。要么(1)定义一个单独接口,要么(2)把约束显式化为能力(例如 supportsNumericIds() 或文档化的配置要求)。这样调用方便能有意识地选择,而不是被“替换”所惊讶。
良好设计的接口使用起来“显而易见”,因为它只暴露调用方需要的内容——并且不多。里斯科夫关于数据抽象的观点会推动你设计窄而稳定、可读的接口,让用户在不学习内部细节的情况下就能依赖它们。
大的 API 往往混合不相关的职责:配置、状态变更、报告与故障排查混在一处。这使得理解何时安全调用变得困难。
内聚的接口将属于同一抽象的操作分组。如果你的 API 表示一个队列,专注队列行为(enqueue/dequeue/peek/size),而不是通用工具。更少的概念意味着更少的误用路径。
“灵活”通常意味着“不清晰”。像 options: any、mode: string 或多个布尔值(例如 force、skipCache、silent)会创建大量未定义的组合。
更好的做法:
publish() 与 publishDraft()),或如果某个参数需要调用方读源码才能知道发生了什么,那它就不是好的抽象的一部分。
名称传达契约。选择描述可观测行为的动词:reserve、release、validate、list、get。避免花哨的比喻和重载术语。如果两个方法听起来相似,调用方会假定它们行为相似——所以让它们真的相似。
当你注意到以下情况时就应拆分:
拆分模块可以让你在保持核心承诺稳定的同时演进内部。如果你计划扩展,考虑一个精简的“核心”包加上附加组件;参见 /blog/evolving-apis-without-breaking-users。
API 很少保持不变。新功能到来、发现边缘用例、“小改进”可能悄然破坏真实应用。目标不是冻结接口,而是在不违反用户已依赖承诺的前提下演进它们。
语义化版本控制是沟通工具:
其限制:仍需判断力。如果一个“修复”改变了调用者依赖的行为,那么它在实践中就是破坏性的——即使旧行为是偶然的。
许多破坏性变更不会在编译器中显现:
按前置条件和后置条件来思考:调用者必须提供什么,以及他们可以期待得到什么回报。
弃用要明确且有时间表才有效:
里斯科夫式的数据抽象有助于缩小用户可依赖的范围。如果调用者只依赖接口契约——而不是内部结构——你就可以自由地更改存储格式、算法和优化。
在实践中,这也是强大工具发挥作用的地方。例如,当你在内部 API 上快速迭代以构建 React 前端或 Go + PostgreSQL 后端时,像 Koder.ai 这样的工作流可以加速实现,而不改变核心纪律:仍需要清晰的契约、稳定标识符和向后兼容的演进。速度是乘数——所以值得把正确的接口习惯乘起来。
可靠的 API 不是永不失败,而是以调用方能够理解、处理并测试的方式失败。错误处理是抽象的一部分:它定义了“正确使用”的含义,以及当网络、磁盘、权限或时间与预期不同时会发生什么。
先将两类区分开:
此区分让接口更诚实:调用方能学会哪些是可在代码中修复的,哪些必须在运行时处理。
你的契约应该暗示机制:
Ok | Error)当失败是预期且你希望调用方显式处理时使用。无论选择何种方式,API 内要保持一致,以免用户猜测。
按含义而不是实现细节列出每个操作可能的失败:“因为版本过旧导致冲突”、“未找到”、“权限被拒绝”、“被限流”。提供稳定的错误码和结构化字段,让测试可以在不匹配字符串的情况下断言行为。
文档化某个操作是否可安全重试、在何种条件下可重试,以及如何实现幂等性(幂等键、自然请求 ID)。如果可能发生部分成功(批量操作),定义成功与失败如何报告,并说明在超时后调用方应假定的状态。
抽象是一个承诺:“只要你用合法输入调用这些操作,你会得到这些结果,这些规则始终成立。”测试就是在代码变化时保持该承诺的方式。
先把契约翻译成可自动运行的检查。
单元测试应验证每个操作的后置条件和边缘情况:返回值、状态变化和错误行为。如果接口说明“移除不存在的项返回 false 并且不改变任何状态”,就写一个精确测试来验证它。
集成测试应跨真实边界验证契约:数据库、网络、序列化与鉴权。许多“契约违反”仅在类型被编码/解码或重试/超时时出现。
不变式是无论执行何种有效操作序列都必须保持的规则(例如“余额永远不为负”、“ID 唯一”、“通过 list() 返回的项可以被 get(id) 获取”)。
属性测试通过生成大量随机但合法的输入和操作序列来检验这些规则,寻找反例。概念上,你在问:“无论用户以何种顺序调用这些方法,不变式都成立吗?”这对于发现人类未想到的奇怪边角情况尤其有效。
对于公共或共享 API,让消费者发布他们发出的请求示例和所依赖的响应。提供者在 CI 中运行这些契约以确认更改不会破坏真实用法——即便提供者团队没预见到这种用法。
测试无法覆盖一切,因此监控可能表明契约正在改变的信号:响应形状变化、4xx/5xx 率上升、新的错误码、延迟骤增、未知字段或反序列化失败。按端点与版本追踪这些信号,以便早期检测漂移并安全回滚。
如果你的交付流水线支持快照或回滚,它们与此心态天然契合:早期发现漂移,然后回滚而不强迫客户端在事故中适配。(例如 Koder.ai 包含快照与回滚工作流,契合“先契约、后变更”的做法。)
即便重视抽象的团队也会滑入看似“实用”但逐步把 API 变成特例集合的陷阱。下面是一些反复出现的问题及替代做法。
功能开关对逐步发布很有用,但问题出在开关变成公开且长期存在的参数:?useNewPricing=true、mode=legacy、v2=true。随着时间推移,调用方将它们组合出意想不到的方式,你会发现自己不得不永远支持多种行为。
更安全的做法:
暴露表 ID、连接键或“SQL 形”过滤(例如 where=...)会迫使客户端学习你的存储模型,这会让重构变得痛苦:模式变化变成了 API 破坏性变更。
应将接口围绕领域概念建模,让客户端以意义提问而不是以存储方式提问(例如“在日期范围内某客户的订单”)。
添加字段看似无害,但重复的“再加一个字段”会模糊职责并削弱不变式。客户端开始依赖偶然细节,类型就变成了杂物箱。
避免长期成本的做法:
过度抽象会阻塞真实需求——比如不能表达“从该游标后开始”的分页,或搜索端点不能指定“精确匹配”。客户端会绕过这些限制(多次调用、本地过滤),导致更差的性能和更多错误。
修复方法是受控的灵活性:提供一小组定义良好的扩展点(例如支持的过滤操作),而不是一个开放式的逃生舱。
简化并不一定要剥夺能力。弃用令人困惑的选项,但通过更清晰的形状保留底层能力:用一个结构化请求对象替换多个重叠参数,或把一个“万金油”端点拆成两个内聚端点。然后用版本化文档和明确弃用时间表引导迁移(参见 /blog/evolving-apis-without-breaking-users)。
你可以用一个简单、可重复的核对表来应用里斯科夫的数据抽象思想。目标不是完美——而是把 API 的承诺显式化、可测试,并且安全演进。
使用简短、一致的区块:
transfer(from, to, amount)如果想深入了解,请查阅:抽象数据类型(ADTs)、契约式设计(Design by Contract) 以及 里氏替换原则(LSP)。
如果你的团队保留内部笔记,把它们从类似 /docs/api-guidelines 的页面链接出来以便重复使用审查工作流——如果你以人手或聊天驱动构建器(例如 Koder.ai)快速构建新服务,把这些指南作为“快速交付”的不可妥协部分。可靠的接口是速度复利而非反噬的方式。
她推广了数据抽象和信息隐藏,这直接映射到现代 API 设计:发布一个小而稳定的契约,同时保持实现的灵活性。实际回报是可观的:更少的破坏性变更、更安全的重构,以及更可预测的集成。
一个可靠的 API 是调用方可以随时间依赖的接口:
可靠性不在于“从不失败”,而在于以可预测的方式失败并遵守契约。
把行为写成一个契约:
包含边缘情况(空结果、重复、排序),让调用方能够据此实现并测试该承诺。
不变式是一个必须始终在抽象内部成立的规则(例如“数量永远不会为负”)。应在边界处强制不变式:
这会减少下游 bug,因为系统其他部分不再需要处理不可能的状态。
信息隐藏意味着公开操作和语义,而非内部表示。不要让消费方耦合到你未来可能会改变的内容(表、缓存、分片键、内部状态)。
实用策略:
usr_...)而非数据库行 ID。status=3)。因为它们会冻结你的实现。如果客户端依赖表格形态的过滤、连接键或内部 ID,那么一次模式重构就会变成 API 的破坏性变更。
更好的方式是基于领域概念建模,例如“在某日期范围内某客户的订单”,把存储模型隐藏在契约后面。
LSP 的意思是:如果代码能与某个接口配合工作,那么换成该接口的任何合法实现也应继续工作——不需要特殊处理。对 API 而言,就是“别让调用方惊讶”。
为了支持可替换的实现,应标准化:
要注意:
如果某实现确实需要额外约束,应发布单独接口或显式能力标记,让调用方有意识地选择。
保持接口小而内聚:
options: any 或大量布尔参数导致含糊组合。reserve、release、list、)。把错误处理作为契约的一部分:
一致性比具体机制更重要(异常 vs 结果类型),只要调用方能够预测并处理结果即可。
amount > 0InsufficientFunds, AccountNotFound, Timeoutvalidate如果存在不同角色或不同变更频率,拆分模块/资源以便更安全地演进(参见 /blog/evolving-apis-without-breaking-users)。