2 分钟

芭芭拉·利斯科夫的数据抽象:构建可靠的 API

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

芭芭拉·利斯科夫的数据抽象:构建可靠的 API

为什么芭芭拉·利斯科夫对于 API 设计仍然重要

芭芭拉·利斯科夫是一位计算机科学家,她的工作悄然塑造了现代软件团队构建不会崩溃系统的方式。她关于数据抽象信息隐藏以及后来提出的**里氏替换原则(LSP)**的研究影响了从编程语言到我们日常思考 API 的方式:定义清晰行为、保护内部实现,并让他人安全地依赖你的接口。

“可靠接口”在产品层面的含义

可靠的 API 并非只是理论上的“正确”。它应该能让产品更快推进:

  • 新功能上线不会破坏现有客户。
  • 集成在不同版本间保持工作。
  • 值班事故减少,因为失败是可预测的。
  • 团队可以更改内部实现而无需大规模协调。

这种可靠性是一种体验:对于调用你 API 的开发者、维护它的团队,以及间接依赖它的用户都是如此。

数据抽象如何减少 bug(和会议)

数据抽象的思想是:调用方应通过一个概念(账户、队列、订阅)与之交互,通过一组小而明确的操作——而不是通过它如何存储或计算的混乱细节。

当你隐藏表示细节时,你就消除了整类错误:没人能“意外”依赖一个不该公开的数据库字段,或以系统无法处理的方式修改共享状态。同样重要的是,抽象降低了协调成本:只要公共行为保持一致,团队无需许可即可重构内部实现。

读完你能应用的内容

本文末尾你将获得可操作的方法来:

  • 将 API 行为写成清晰的承诺(包括边缘情况)。
  • 在系统演进时保持接口小而稳定。
  • 设计可预期的失败模式,供调用方处理。

如果你想快速回顾,跳到 /blog/a-practical-checklist-for-designing-reliable-apis。

用非术语解释数据抽象

数据抽象是一个简单的想法:你通过它做什么来交互,而不是它怎么构建

想象售货机。你不需要知道电机如何转动或硬币如何计数。你只需要控制(“选择商品”、“支付”、“领取商品”)和规则(“如果付款足够,你得到商品;如果售罄,你收到退款”)。这就是抽象。

“做什么” vs “怎么实现”

在软件中,接口是“做什么”:操作名称、接受的输入、产生的输出以及可能的错误。实现是“怎么做”:数据库表、缓存策略、内部类和性能技巧。

将两者分离,你就能在系统演进时保持 API 稳定。你可以重写内部、替换库或优化存储——而接口对用户保持不变。

一分钟理解抽象数据类型(ADT)

抽象数据类型是“容器 + 允许的操作 + 规则”,在不承诺具体内部结构的情况下描述其行为。

示例:(后进先出)。

  • push(item):添加一个元素
  • pop():移除并返回最近添加的元素
  • peek():查看栈顶元素但不移除它

关键是承诺:pop() 返回最近的 push()。栈是用数组、链表还是其他结构实现的都是私有的。

这如何映射到真实的 API

同样的分离适用于各处:

  • REST 端点POST /payments 是接口;欺诈检查、重试和数据库写入是实现。
  • SDK 方法client.upload(file) 是接口;分片、压缩和并行请求是实现。
  • UI 组件:一个“DatePicker”暴露 props/事件;DOM 结构和无障碍处理是实现。

当你以抽象为中心设计时,你关注的是用户依赖的契约——并为自己争取在幕后自由更改的一切权利。

不变式:保持系统正确的隐形规则

不变式是在抽象内部必须始终为真的规则。如果你在设计 API,不变式是防止数据漂移到不可能状态的护栏——比如一个同时有两种货币的银行账户,或一个没有商品却标记为“已完成”的订单。

不变式长什么样(无数学符号)

把不变式想成类型的“现实形状”:

  • 一个 Cart 不能包含负数数量。
  • 一个 UserEmail 永远是有效的电子邮件(不是“稍后验证”)。
  • 一个 Reservationstart < end,且两个时间在相同时区。

如果这些语句不再成立,系统会变得不可预测,因为每个功能都需要猜测“损坏”数据意味着什么。

不变式如何引导验证与错误处理

好的 API 在边界处强制不变式:

  • 创建时: 及早拒绝无效输入(返回清晰错误)。
  • 更新时: 只允许保持不变式为真的变更。
  • 解析/IO: 将外部数据视为不可信;在存储前验证。

这自然而然地改善了错误处理:API 能解释哪个规则被违反,而不是模糊的“出错了”。

不要让不变式通过接口泄露

调用方不应记住诸如“该方法只有在调用 normalize() 之后才有效”之类的内部规则。如果不变式依赖于特殊流程,它就不是不变式——而是一个陷阱。

将接口设计为:

  • 无效状态不可表示(或很难表示);
  • 方法自动维护不变式。

一个实用的文档核对表

在记录 API 类型时,写下:

  1. 不变式陈述(用简单英语,可测试)
  2. 在哪儿强制它们(构造函数、设置器、端点)
  3. 违反时发生什么(错误类型/消息、状态码)
  4. 哪些方法会保持它们(以及任何例外)
  5. 有效 vs 无效输入示例(简短具体)

契约:为调用方和维护者明确行为

一个好的 API 不仅是一组函数——它是一个承诺。契约使该承诺明确,这样调用方就能依赖行为,维护者也能在不意外用户的情况下改变实现。

在契约中要写清的内容

至少记录:

  • 前置条件: 调用前必须为真(有效范围、所需权限、线程安全期望)。
  • 后置条件: 成功调用后将为真(返回值含义、状态变化)。
  • 副作用: 还会改变什么(写磁盘、发送网络请求、更新缓存、修改传入对象)。

这种清晰性使行为可预测:调用方知道哪些输入是安全的,以及哪些结果需要处理,测试可以验证承诺而不是猜测意图。

契约减少“部落知识”

没有契约时,团队依赖记忆和非正式规范:"别传 null"、"那个调用有时会重试"、"出错时返回空"。这些规则在入职、重构或事故中会丢失。

书面契约把隐形规则变成共享知识,也为代码审查提供稳定目标:讨论会变成“这次改动仍然满足契约吗?”而不是“我这边能跑”。

好 vs 含糊措辞(示例)

含糊: “Creates a user.”

更好: “Creates a user with a unique email.

  • Preconditions: email must be a valid address; caller must have users:create permission.
  • Postconditions: returns the new userId; the user is persisted and immediately retrievable.
  • Failure modes: returns 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.

  • Side effects: none.
  • Consistency: may be up to 60 seconds stale.
  • Pagination: use nextCursor for the next page; cursors expire after 15 minutes.”

信息隐藏:保持内部私有,保持 API 稳定

信息隐藏是数据抽象的实用面:调用方应依赖API 做什么,而不是API 如何做。如果用户看不到你的内部实现,你就可以在不把每次发布变成破坏性变更的情况下更改它们。

暴露操作,不暴露表示

好的接口发布一小套操作(create、fetch、update、list、validate),把表示——表、缓存、队列、文件布局、服务边界——保持为私有。例如,“将商品加入购物车”是一个操作。来自数据库的“CartRowId”是实现细节。暴露该细节会诱使用户基于它构建逻辑,从而冻结你更改的能力。

隐藏内部使重构安全的原因

当客户端仅依赖稳定行为时,你可以:

  • 切换数据库或存储格式;
  • 将单体拆分为服务;
  • 添加缓存或改变索引策略;
  • 重新组织内部模型;

而 API 保持兼容,因为契约没有移动。这才是真正的回报:为用户提供稳定性,为维护者提供自由。

常见的泄露模式需警惕

内部实现意外泄露的几种方式:

  • 返回内部 ID,这些 ID 仅在你的存储层有意义(自增整数、分片键)。
  • 暴露可变结构(例如返回原始对象,客户端可以修改后再发回),这会将客户端与精确字段耦合。
  • 让客户端构造内部状态,比如接受 status=3 而不是明确的名称或专用操作。

设计可保持稳定的响应形状

优先返回描述语义的响应,而不是机制:

  • 使用稳定且不透明的公共标识(例如 "userId": "usr_…")而非数据库行号。
  • 返回集合的副本或只读视图,而不是客户端可能“意外”依赖的有序或内部字段结构。
  • 以向后兼容的方式添加字段;避免改变已存在字段的含义。

如果某个细节可能会改变,就别发布它。如果用户需要它,就把它提升为接口承诺中有意的、带文档的部分。

里氏替换原则作为接口承诺

同时构建客户端应用
添加一个依赖明确契约和一致错误处理的 Flutter 客户端。

里氏替换原则(LSP)一句话:如果一段代码能与某个接口协同工作,当你用该接口的任意合法实现替换时,代码也应继续工作——无需特殊处理。

LSP 更少关注继承,更多关注信任。当你发布一个接口时,你就是在做出行为承诺。LSP 表明所有实现都必须保持该承诺,即使它们在内部采用非常不同的方法。

LSP 就是“别让调用方惊讶”

调用方依赖于接口所声明的内容——而不是接口今天的实现细节。如果接口声明“你可以用任意合法记录调用 save()”,那么每个实现都必须接受那些合法记录。如果接口声明“get() 返回一个值或明确的‘未找到’结果”,那么实现就不能随意抛出新错误或返回部分数据。

安全的扩展意味着你可以添加新实现(或切换提供者)而不强迫用户重写代码。这就是 LSP 的实用回报:保持接口可替换。

API 中常见的 LSP 违规

两种常见的破坏承诺方式:

  • 更窄的输入(更严格的前置条件): 新实现拒绝接口定义允许的输入。例如:基接口接受任意 UTF‑8 字符串作为 ID,但某个实现只接受数字 ID 或拒绝空但合法的字段。

  • 更弱的输出(后置条件放宽): 新实现返回比承诺更少。例如:接口说结果为已排序、唯一或完整——而某实现返回无序数据、重复项或悄悄丢弃条目。

第三种微妙的违规是改变失败行为:一个实现返回“未找到”,另一个为同样情况抛异常,调用方就无法安全替换实现。

在不令人惊讶的情况下设计插件行为

为了支持“插件式”实现,将接口写成契约:

  • 指定哪些输入是有效的,并在实现间保持一致;
  • 指定输出的含义(包括排序、默认值和边缘情况);
  • 标准化失败模式:哪些错误会出现以及它们表示什么。

如果某实现确实需要更严格的规则,不要把它隐藏在相同接口后面。要么(1)定义一个单独接口,要么(2)把约束显式化为能力(例如 supportsNumericIds() 或文档化的配置要求)。这样调用方便能有意识地选择,而不是被“替换”所惊讶。

好的接口是小而内聚且易读的

良好设计的接口使用起来“显而易见”,因为它只暴露调用方需要的内容——并且不多。里斯科夫关于数据抽象的观点会推动你设计窄而稳定、可读的接口,让用户在不学习内部细节的情况下就能依赖它们。

偏好内聚而非“全能”接口

大的 API 往往混合不相关的职责:配置、状态变更、报告与故障排查混在一处。这使得理解何时安全调用变得困难。

内聚的接口将属于同一抽象的操作分组。如果你的 API 表示一个队列,专注队列行为(enqueue/dequeue/peek/size),而不是通用工具。更少的概念意味着更少的误用路径。

避免过度灵活的参数导致模糊

“灵活”通常意味着“不清晰”。像 options: anymode: string 或多个布尔值(例如 forceskipCachesilent)会创建大量未定义的组合。

更好的做法:

  • 为不同行为提供具体方法(例如 publish()publishDraft()),或
  • 使用小而类型化的 options 对象并记录默认值及非法组合。

如果某个参数需要调用方读源码才能知道发生了什么,那它就不是好的抽象的一部分。

命名也是接口的一部分

名称传达契约。选择描述可观测行为的动词:reservereleasevalidatelistget。避免花哨的比喻和重载术语。如果两个方法听起来相似,调用方会假定它们行为相似——所以让它们真的相似。

何时拆分成多个模块/资源

当你注意到以下情况时就应拆分:

  • 不同用户角色(例如“admin”与“consumer”)需要不同能力;或
  • 不同部分的变更频率不同(一个部分经常演进,另一个必须保持稳定)。

拆分模块可以让你在保持核心承诺稳定的同时演进内部。如果你计划扩展,考虑一个精简的“核心”包加上附加组件;参见 /blog/evolving-apis-without-breaking-users。

在不破坏用户的情况下演进 API

从 Go 和 Postgres 开始
创建一个 Go + PostgreSQL 后端,专注于边界处的不变式。

API 很少保持不变。新功能到来、发现边缘用例、“小改进”可能悄然破坏真实应用。目标不是冻结接口,而是在不违反用户已依赖承诺的前提下演进它们。

语义化版本控制(实用,但有限)

语义化版本控制是沟通工具:

  • MAJOR:你做了破坏性变更。
  • MINOR:以向后兼容方式增加功能。
  • PATCH:修复 bug,不改变预期行为。

其限制:仍需判断力。如果一个“修复”改变了调用者依赖的行为,那么它在实践中就是破坏性的——即使旧行为是偶然的。

破坏性变更关乎契约,而不仅是类型

许多破坏性变更不会在编译器中显现:

  • 收紧输入规则(拒绝之前接受的值)。
  • 改变含义(相同字段含义改变)。
  • 改变时序(一次调用从快变慢或阻塞)。
  • 改变错误行为(新的错误码、不同行为的重试、部分结果变化)。

前置条件后置条件来思考:调用者必须提供什么,以及他们可以期待得到什么回报。

用户能实际跟进的弃用路径

弃用要明确且有时间表才有效:

  • 在文档和响应中标注老行为为弃用(警告、头信息、日志)。
  • 提供双支持窗口(老的与新的并行)。
  • 发布明确时间线(例如“60 天内变为默认,180 天后移除”)。

抽象如何让演进更容易

里斯科夫式的数据抽象有助于缩小用户可依赖的范围。如果调用者只依赖接口契约——而不是内部结构——你就可以自由地更改存储格式、算法和优化。

在实践中,这也是强大工具发挥作用的地方。例如,当你在内部 API 上快速迭代以构建 React 前端或 Go + PostgreSQL 后端时,像 Koder.ai 这样的工作流可以加速实现,而不改变核心纪律:仍需要清晰的契约、稳定标识符和向后兼容的演进。速度是乘数——所以值得把正确的接口习惯乘起来。

错误处理与失败模式:为可预测性而设计

可靠的 API 不是永不失败,而是以调用方能够理解、处理并测试的方式失败。错误处理是抽象的一部分:它定义了“正确使用”的含义,以及当网络、磁盘、权限或时间与预期不同时会发生什么。

程序员错误 vs 运行时失败

先将两类区分开:

  • 程序员错误:调用方违反了契约(例如传入无效 ID 格式、方法调用顺序错误、遗漏必需字段)。这些应被及早且明确地捕获——通常以指出滥用位置的验证错误。
  • 运行时失败:调用方遵守契约,但外部发生故障(超时、依赖不可用、配额限制、并发冲突)。这些应可表达并可恢复。

此区分让接口更诚实:调用方能学会哪些是可在代码中修复的,哪些必须在运行时处理。

用契约选择合适的失败形态

你的契约应该暗示机制:

  • 错误(验证响应) 用于契约违反。
  • 异常 用于库中真正的异常情况——或者当你无法合理地让每个调用点都分支处理时。
  • 结果类型(例如 Ok | Error)当失败是预期且你希望调用方显式处理时使用。

无论选择何种方式,API 内要保持一致,以免用户猜测。

让失败模式变得明确且可测试

含义而不是实现细节列出每个操作可能的失败:“因为版本过旧导致冲突”、“未找到”、“权限被拒绝”、“被限流”。提供稳定的错误码和结构化字段,让测试可以在不匹配字符串的情况下断言行为。

重试、幂等性与部分成功

文档化某个操作是否可安全重试、在何种条件下可重试,以及如何实现幂等性(幂等键、自然请求 ID)。如果可能发生部分成功(批量操作),定义成功与失败如何报告,并说明在超时后调用方应假定的状态。

测试抽象:证明接口符合承诺

抽象是一个承诺:“只要你用合法输入调用这些操作,你会得到这些结果,这些规则始终成立。”测试就是在代码变化时保持该承诺的方式。

把契约转成单元和集成测试

先把契约翻译成可自动运行的检查。

单元测试应验证每个操作的后置条件和边缘情况:返回值、状态变化和错误行为。如果接口说明“移除不存在的项返回 false 并且不改变任何状态”,就写一个精确测试来验证它。

集成测试应跨真实边界验证契约:数据库、网络、序列化与鉴权。许多“契约违反”仅在类型被编码/解码或重试/超时时出现。

使用属性测试检查不变式

不变式是无论执行何种有效操作序列都必须保持的规则(例如“余额永远不为负”、“ID 唯一”、“通过 list() 返回的项可以被 get(id) 获取”)。

属性测试通过生成大量随机但合法的输入和操作序列来检验这些规则,寻找反例。概念上,你在问:“无论用户以何种顺序调用这些方法,不变式都成立吗?”这对于发现人类未想到的奇怪边角情况尤其有效。

面向消费者的契约测试用于公共 API

对于公共或共享 API,让消费者发布他们发出的请求示例和所依赖的响应。提供者在 CI 中运行这些契约以确认更改不会破坏真实用法——即便提供者团队没预见到这种用法。

监控生产环境以发现契约漂移

测试无法覆盖一切,因此监控可能表明契约正在改变的信号:响应形状变化、4xx/5xx 率上升、新的错误码、延迟骤增、未知字段或反序列化失败。按端点与版本追踪这些信号,以便早期检测漂移并安全回滚。

如果你的交付流水线支持快照或回滚,它们与此心态天然契合:早期发现漂移,然后回滚而不强迫客户端在事故中适配。(例如 Koder.ai 包含快照与回滚工作流,契合“先契约、后变更”的做法。)

常见反模式及避免方法

让失败可预测
先拟定可预测的错误响应与边界情况,再通过对话实现。

即便重视抽象的团队也会滑入看似“实用”但逐步把 API 变成特例集合的陷阱。下面是一些反复出现的问题及替代做法。

永久性功能开关作为 API 参数

功能开关对逐步发布很有用,但问题出在开关变成公开且长期存在的参数:?useNewPricing=truemode=legacyv2=true。随着时间推移,调用方将它们组合出意想不到的方式,你会发现自己不得不永远支持多种行为。

更安全的做法:

  • 尽量把发布开关保持为内部使用;
  • 如果行为必须不同,用一个清晰命名且有生命周期的新能力表示(并计划移除旧行为);
  • 记录哪些组合是有效的;对无效组合明确拒绝。

把数据库概念泄露到接口中

暴露表 ID、连接键或“SQL 形”过滤(例如 where=...)会迫使客户端学习你的存储模型,这会让重构变得痛苦:模式变化变成了 API 破坏性变更。

应将接口围绕领域概念建模,让客户端以意义提问而不是以存储方式提问(例如“在日期范围内某客户的订单”)。

“就加个字段”反射式操作

添加字段看似无害,但重复的“再加一个字段”会模糊职责并削弱不变式。客户端开始依赖偶然细节,类型就变成了杂物箱。

避免长期成本的做法:

  • 为新概念引入新的、聚焦的类型;
  • 把相关字段分组到具有明确含义的嵌套对象中;
  • 将每次添加都视为契约更改:它暗示了什么,必须始终为真什么?

过度严格的抽象会阻碍真实需求

过度抽象会阻塞真实需求——比如不能表达“从该游标后开始”的分页,或搜索端点不能指定“精确匹配”。客户端会绕过这些限制(多次调用、本地过滤),导致更差的性能和更多错误。

修复方法是受控的灵活性:提供一小组定义良好的扩展点(例如支持的过滤操作),而不是一个开放式的逃生舱。

简化不等于移除能力

简化并不一定要剥夺能力。弃用令人困惑的选项,但通过更清晰的形状保留底层能力:用一个结构化请求对象替换多个重叠参数,或把一个“万金油”端点拆成两个内聚端点。然后用版本化文档和明确弃用时间表引导迁移(参见 /blog/evolving-apis-without-breaking-users)。

构建可靠 API 的实用核对表

你可以用一个简单、可重复的核对表来应用里斯科夫的数据抽象思想。目标不是完美——而是把 API 的承诺显式化、可测试,并且安全演进。

简短核对表

  • 不变式: 关于数据或资源必须始终成立的内容是什么?(例如 “余额不会为负”、“ID 唯一”、“返回项目有稳定顺序”)。
  • 契约: 对每个操作,写明 前置条件后置条件副作用(包括不会改变的内容)。
  • 隐藏表示: 列出哪些细节是有意私有的(存储格式、缓存、内部 ID),确保调用方无法依赖它们。
  • 演进计划: 决定如何新增能力:版本策略、弃用政策以及旧行为支持多久。

快速 API 审查工作流(可重复)

  1. 只看接口(不看实现)。新同学能否预测其行为?
  2. 走过 5 个“故事测试”:正常情况、空情况、边界情况、无效输入和失败情况。
  3. 检查替换安全性:如果有多实现,替换实现会不会让调用方惊讶?
  4. 扫描隐藏耦合:客户端是否被迫了解内部状态、时序或存储细节?
  5. 写下你将引入的破坏性变更,然后重设计直到这张清单为空(或被有意识接受)。

文档模板(可复制粘贴)

使用简短、一致的区块:

  • Operation: transfer(from, to, amount)
  • Requires: amount > 0 and accounts exist
  • Ensures: balances updated atomically; total sum preserved
  • Errors: InsufficientFunds, AccountNotFound, Timeout
  • Notes: idempotency, ordering, performance expectations

可选的下一步阅读

如果想深入了解,请查阅:抽象数据类型(ADTs)契约式设计(Design by Contract) 以及 里氏替换原则(LSP)

如果你的团队保留内部笔记,把它们从类似 /docs/api-guidelines 的页面链接出来以便重复使用审查工作流——如果你以人手或聊天驱动构建器(例如 Koder.ai)快速构建新服务,把这些指南作为“快速交付”的不可妥协部分。可靠的接口是速度复利而非反噬的方式。

常见问题

为什么芭芭拉·利斯科夫的工作对当今的 API 设计仍然重要?

她推广了数据抽象信息隐藏,这直接映射到现代 API 设计:发布一个小而稳定的契约,同时保持实现的灵活性。实际回报是可观的:更少的破坏性变更、更安全的重构,以及更可预测的集成。

在产品和工程层面上,“可靠的接口”是什么意思?

一个可靠的 API 是调用方可以随时间依赖的接口:

  • 新版本不会破坏现有消费者。
  • 失败模式是一致且有文档说明的。
  • 内部实现可以改变而不改变公共行为。

可靠性不在于“从不失败”,而在于以可预测的方式失败并遵守契约。

如何把一个 API 端点或方法变成明确的行为承诺?

把行为写成一个契约

  • 前置条件: 调用前必须为真(有效范围、权限)。
  • 后置条件: 成功后将为真(返回值、状态变化)。
  • 副作用: 还会改变什么(写入、网络调用、缓存更新)。

包含边缘情况(空结果、重复、排序),让调用方能够据此实现并测试该承诺。

什么是不变式,API 应该在哪里强制它们?

不变式是一个必须始终在抽象内部成立的规则(例如“数量永远不会为负”)。应在边界处强制不变式:

  • 在创建/更新时验证。
  • 及早拒绝无效输入并返回具体错误。
  • 避免要求“先调用 normalize()”之类的特殊流程。

这会减少下游 bug,因为系统其他部分不再需要处理不可能的状态。

什么是信息隐藏,如何将其应用到响应结构和 ID?

信息隐藏意味着公开操作和语义,而非内部表示。不要让消费方耦合到你未来可能会改变的内容(表、缓存、分片键、内部状态)。

实用策略:

  • 使用稳定且不透明的公共 ID(例如 usr_...)而非数据库行 ID。
  • 不要要求客户端构造内部状态(避免 status=3)。
  • 向后兼容地添加字段,且不要改变现有字段的含义。
为什么将数据库概念泄露到 API 中会成为长期常见问题?

因为它们会冻结你的实现。如果客户端依赖表格形态的过滤、连接键或内部 ID,那么一次模式重构就会变成 API 的破坏性变更。

更好的方式是基于领域概念建模,例如“在某日期范围内某客户的订单”,把存储模型隐藏在契约后面。

在实用的 API 层面上,什么是里氏替换原则(LSP)?

LSP 的意思是:如果代码能与某个接口配合工作,那么换成该接口的任何合法实现也应继续工作——不需要特殊处理。对 API 而言,就是“别让调用方惊讶”。

为了支持可替换的实现,应标准化:

  • 有效输入(不能让某实现增加更严格的前置条件)。
  • 输出保证(排序、完整性、唯一性)。
  • 失败行为(错误和“未找到”的含义一致)。
当存在多个实现或提供者时,常见的 LSP 违规有哪些?

要注意:

  • 更窄的输入: 新实现拒绝接口曾允许的输入。
  • 更弱的输出: 它丢弃项目、改变排序或返回不完整数据却不说明。
  • 不同的失败语义: 一个返回“未找到”,另一个抛异常或返回不同的错误结构。

如果某实现确实需要额外约束,应发布单独接口或显式能力标记,让调用方有意识地选择。

如何设计一个保持精简、内聚且易读的 API?

保持接口小而内聚

  • 偏好与单一抽象直接对应的专注操作。
  • 避免 options: any 或大量布尔参数导致含糊组合。
  • 使用描述可观测行为的命名(reservereleaselistvalidate)。

如果存在不同角色或不同变更频率,拆分模块/资源以便更安全地演进(参见 /blog/evolving-apis-without-breaking-users)。

我应该如何设计错误处理,使失败可预测且可测试?

把错误处理作为契约的一部分:

  • 区分 程序员错误(契约被违反)和 运行时失败(超时、依赖不可用、配额)。
  • 文档化稳定的错误码/字段,测试不要依赖消息字符串。
  • 指明重试安全性与幂等性(幂等键、请求 ID),并定义批量操作的部分成功语义。

一致性比具体机制更重要(异常 vs 结果类型),只要调用方能够预测并处理结果即可。

Related posts