库契约
应用程序由用户评判; 库由程序员评判, 并且是在特定的时刻——当他们想要作者未曾料到的东西时。 一个好的库不是按预期使用时很方便的那个, 而是在其他用途下也不必被抛弃的那个。
以下所有规则都源于一个不对称性。 在库内部,你可以永远改变任何东西。 而在外部,一旦有人依赖,调用者可观察的一切都会冻结—— 并且当调用者足够多时, 所有的一切都会被依赖。
什么冻结,以及何时冻结
海勒姆定律是运作条件,而非奇闻: 当用户足够多时, 每一个可观察行为都会成为承重行为——异常措辞、 枚举顺序、日志输出、计时, 甚至是你文档化为未定义的行为。 “合同中从未包含”并不能挽回已经基于此发货的调用者。
因此,冻结线位于可观察性,而非 public 关键字,
并由此产生三个后果:
- 表面从一开始就很小。 一个符号,一旦公开, 就是一个你可以拓宽但永远不能悄无声息地收回的承诺。 “暂时公开”是为了毫无好处而欠下的债务;按需暴露。
- 格式比代码更持久。 文件格式、网络协议、 持久化模式在围绕它的代码每次重写后都存活。 将内部结构直接序列化会将冻结层与可编辑层焊接在一起。
- 应用内模块在其第二个消费者处越过这条线, 而非在打包时。 一旦存在两个调用者, 其 API 的冻结经济学就与已发布的包相同—— 只是爆炸半径较小。
语义化版本是同一冻结线的公开拼写, 并且它从拒绝没有版本号开始: “使用语义化版本控制的软件必须声明公共 API”(SemVer 2.0.0,第 1 条)。 一旦声明,增量是决定的而非感受的—— 对该 API 的向后不兼容更改必须提高 MAJOR(第 8 条), 新的向后兼容功能必须提高 MINOR(第 7 条), 向后兼容的修复必须提高 PATCH(第 6 条)。 海勒姆定律正是使声明变得困难的原因: 规范冻结了你声明的, 而调用者冻结了他们观察到的。
该规范也为尚未越过线的模块命名: “主版本零(0.y.z)用于初始开发。任何内容都可能随时更改”(第 4 条)。 这是只有一个消费者模块的诚实标签, 当它获得第二个消费者的那天,就是 1.0.0 应被发布的日子, 而非打包的那天。
不夺取控制权
将机制与策略分离。 执行 HTTP 请求是机制; 使用指数退避重试三次是策略, 而策略被内化到核心中,会缩小库的范围到只有共享它的调用者。 为日志、线程、重试、超时、 缓存、序列化提供方便的默认值—— 并使每一个都可替换。
应用程序的角色是单一的,并且不属于你。 退出进程、写入标准输出、捕获 SIGINT、 安装顶层异常处理程序、调整线程池大小: 每个进程只能有一个组件执行这些操作, 而执行这些操作的库会与有权执行这些操作的那个组件发生冲突。 读取进程的输入也属于同一角色: 环境变量、 配置文件和命令行参数由应用程序根读取, 然后将每个值作为参数或选项对象传递给库。 调用者选择值的来源, 测试则可以直接传入值,而无需搭建运行环境。
魔术——自动检测、自动转换、自动重连、 自动缓存——仅在调用者能够理解其行为原因时才可接受。 用户无法调试的便利是夺取的控制权,而非节省的工作。
不增加隐藏成本
事情发生的时间属于调用者。 启动线程的导入、打开连接的构造函数、 读取环境变量的静态初始化器——都将实际工作移动到了调用者从未选择且无法回退的时刻。
依赖项是你 API 的一部分。 它的运行时间、更新节奏、ABI 稳定性、许可证、 以及它自身的初始化副作用都会传递给每个调用者; 你是在替他们做选择。
性能特征是契约。 将 O(1) 查找变为 O(n)——或者加倍分配和拷贝—— 是穿着兼容签名的破坏性变更, 而调用者会在生产中发现它。
当输入到达速度快于离开时——流、事件、日志、 队列——无界内部缓冲区是延迟的内存泄漏。 反压是设计的一部分,而非操作细节。
抽象本身的基准是零成本, 同时满足 Stroustrup 的两个条款。 调用者未使用的,他们不付费: 便利层不得以其分配、 其分发或其设置来征税原始层。 而且他们使用的,他们无法用手写代码合理写出更好的: 被手写代码超越的抽象会被绕过, 而被绕过的库是戴着版本号的废弃品。
不持有隐藏状态
单例、静态缓存、全局事件循环、 进程级配置: 当只有你的库在进程中时,它们都能工作, 但当第二个库、插件宿主、 或并行测试套件共享它时就会发生冲突。 状态存在于调用者创建、拥有、 并且可以创建两次的实例中。
不隐藏失败
API 形状声明了可能发生什么以及谁负责。 错误、取消、超时、部分成功、 不存在——通过类型建模, 而不是通过“这通常不会发生”和异常来绕行。 相同的形状承载了注释无法强制执行的所有权答案: 谁关闭流,缓冲区是否可重用, 回调到达的线程。
保留原因。
将失败折叠为 false 或 null 恰好剥去了上层所需的决策信息;
自己记录并继续丢失了相同的信息,并劫持了调用者的日志策略。
质量在异常路径上区分。 在快乐路径上,库是可以互换的;超时、重连、 流中断开、部分读取、 损坏数据和重试是差异所在, 因此首先为它们的组合设计。
取消机制难以事后添加——取消令牌会穿过它所触及的每个签名。 I/O 和长时间运行的操作从第一个版本起就接受它。
不阻塞逃生路径
构建两层。
一个高级 API,用一行代码服务常见情况,
建立在公共原语之上——ReadAsync() 基于 ReadAsync(Memory<byte>)——
这样,当调用者的需求超出顶层时,他们可以向下爬而不是离开。
只有高层的库在遇到未预期的第一个需求时就会被抛弃。
可观测性是被注入的,而非拥有的:日志器、指标、 追踪插入调用者的堆栈。 只有你的库能读取的诊断格式是劫持的遥测记录。
一项税务,两种货币
此技能与整洁架构是在两种货币中的同一拒绝。 零成本抽象拒绝运行时性能税; 依赖规则拒绝可变更性税。 在两者中,抽象均由其迫使依赖方放弃的东西来评判。
| 这里的职责 | 其架构名称 |
|---|---|
| 机制与策略分离 | 用例与框架和驱动程序分离 |
| 默认值可替换 | 依赖倒置——稳定端选择实现 |
| 便利层之下的原语 | 外层细节可替换 |
| 无全局状态 | 依赖项显式,因此可划定边界 |
| 依赖项是你 API 的一部分 | 依赖规则 |
调用者的健康形态将你的库置于底部: 是可替换的细节,位于他们的策略和领域之下。 相反的形态——应用程序生活在框架内部, 拖着它的生命周期、它的类型、它的依赖注入、它的错误模型、 它的异步模型——是库扮演应用程序, 以架构规模书写。
一般形式:好的架构最小化的不是依赖项的数量, 而是每个依赖项所夺走的自由。
总结
库中的抽象存在是为了拓宽调用者的选择。 一旦它以性能、控制或信息作为交换, 它就停止了抽象并开始做决定。
何时不适用
只有一个消费者、一个仓库、 一个团队的模块尚未跨越冻结线——它在语义化版本术语中是 0.y.z, 无论它是否携带数字—— 为它构建每个可替换的策略接缝是带着维护账单的投机。 保留从第一天起就零成本的纪律——无全局状态、 保持原因的失败、 不在调用者要求之前进行任何工作—— 并在第二个消费者到达时添加接缝, 此时它们就不再是投机了。
来源
- 语义化版本 2.0.0,semver.org: 第 1 条(公共 API 必须声明), 第 6-8 条(PATCH、MINOR、MAJOR 增量),第 4 条(0.y.z), 以及关于弃用的 FAQ 条目(记录,然后发布包含弃用的次要版本)。 措辞已对照页面检查。
- 海勒姆定律是一个观察结果,而非标准,此处作为一条观察引用。