跳到主要内容

依赖:借来的代码

这一章讲三件事: 加一个依赖到底签下了什么;版本号的每一位数字各承诺什么; 以及「相依性地狱」长什么样、怎么不进去。 读完你会拿一份「加依赖之前该问的问题清单」和一套防冲突的配置纪律。

1. 这一章讲什么

写代码免不了用别人写好的代码:数据库驱动、应用框架、机器学习库——原书说得直白,这些东西你不该从头自己写1。但「不重复造轮子」的另一半很少被教:每引入一个依赖,你也引入了它的所有风险——不兼容的变化、循环依赖、版本冲突和缺乏控制2

本章在全书链条里的位置:第 03 章讲你的代码怎么在生产环境活下来;这一章讲你借来的代码——它们同样要活,而且坏起来会连累你。本章的版本号纪律在第 10 章(API 演进)和第 07 章(软件包发布)都会再次出现。

2. 顶层全景

「把这个库加进来吧」

├─ 它的版本号承诺什么? SemVer:主.次.补丁
├─ 它还拖着谁? 传递依赖(1 个 → 101 个)
│ └─ 两条 地狱之路: 钻石依赖(两个版本择一) / 循环依赖(先有鸡还是先有蛋)
└─ 怎么防? 九问 → 隔离(复制/供应商/遮蔽)
→ 显式声明 + 版本指定 + 依赖清单 + 范围最小化

图说:左半边解释风险从哪来,右半边给防御动作;主走查在 3.3。

3. 核心原理

3.1 开场故事:一个小包怎么拆掉半个生态

2016 年 3 月,一个叫 left-pad 的 JavaScript 包从包管理器(下载和管理依赖的工具,NPM 就是 JavaScript 世界的那一个)里被移除了。这个包只有一个方法——把字符串左侧填充到指定宽度3。但几个基础的底层库依赖它,众多项目又依赖那些底层库;由于依赖关系会像病毒一样顺着依赖链传播4,成千上万的开源和商业项目一夜之间无法编译。

这个故事的教训不是「别用开源」,而是原书那句定场诗:「在现有的代码上增加一个依赖似乎是一个简单的决定」——它从来不是5。每个依赖都是一份租约:功能是租来的,维护风险也是租来的。

3.2 版本号:三段数字各自的承诺

依赖怎么标版本、怎么挑版本,靠的是版本管理方案。原书先给了好版本号的三个性质:唯一(同一版本号永不复用)、可比(工具能排出先后)、信息(能看出稳定性和兼容性预期)6。Git 用哈希(由内容算出的一串指纹)标识每次提交,有唯一性,但没有可比性和信息量;Android 用甜点名、Ubuntu 用动物名,好记但工具没法比较7

行业里最常用的是 SemVer(Semantic Versioning,一种让版本号自己说明兼容含义的方案):版本号写成「主版本号.次版本号.补丁版本号」。原书拿本章反复使用的例子说:httpclient 的 4.3.6,意味着主、次、补丁号分别是 4、3、68。三段号的承诺从主版本号 1 起算9:

段位什么时候递增它向使用者承诺
补丁版本号修 bug向下兼容,升级不会坏
次版本号新增向下兼容的特性向下兼容
主版本号有不兼容的变化不承诺兼容,升级要看说明

两个补丁要记:主版本号 0 开头的包(如 0.9.1)处于预发布期,作者可以随意破坏兼容性,别拿它当稳定件用10;正式版之前常有候选发布版(RC,给早期采用者试错的预览版,如 3.0.0-rc.1),发布后 RC 后缀会去掉11。SemVer 还允许通配符(如 2.13.*),让构建系统自动拉取同主版本内的新补丁——这正是下一节麻烦的入口。

3.3 主走查:一棵依赖树是怎么长出冲突的

「加一个依赖」在构建文件里是一行,在磁盘上是一棵树。这一节拿原书自带的 Gradle(Java 世界的构建工具之一)例子,把这棵树从干净走到冲突12

第 1 步:干净的状态。 项目声明了两个直接依赖13:

dependencies {
compile 'org.apache.httpcomponents:httpclient:4.3.6'
compile 'org.slf4j:slf4j-api:1.7.2'
}

第 2 步:展开传递依赖。 让构建系统打印依赖树,httpclient 自己又拖着三个库——httpcore、commons-logging、commons-codec。你的代码一行没多写,依赖从 2 个变成 5 个。原书给的放大倍数是:加 1 个依赖,如果它拖着 100 个,你就依赖 101 个类库14日志库 slf4j 就是这里的关键伏笔:httpclient 的树里没有它,但你即将引入的两个库各自拖着一个不同版本的 slf4j。

第 3 步:引入冲突。 项目又加了两个依赖:zookeeper(它拖 slf4j-api 1.6.1)和公司内部的 util(它拖 slf4j-api 1.7.21)。依赖树长成这样15:

+--- com.google.code.findbugs:annotations:3.0.1
+--- org.apache.zookeeper:zookeeper:3.4.10
| +--- org.slf4j:slf4j-api:1.6.1 -> 1.7.21 ← 冲突在这里
| +--- org.slf4j:slf4j-log4j12:1.6.1
| \--- ...
\--- com.mycompany.util:util:1.4.2
\--- org.slf4j:slf4j-api:1.7.21

第 4 步:看构建系统怎么裁决。 一个项目不能同时用同一个库的两个版本,构建系统必须择一,用箭头注明它选了哪个:1.6.1 -> 1.7.21 表示 zookeeper 要的 1.6.1 被整体升级到了 1.7.2116。这就是钻石依赖(两个路径把你拖向同一个库的不同版本)的解法:静默择一。

第 5 步:为什么这一步是赌博。 择一的依据是 SemVer 的承诺——主版本号没变,应当兼容17。但原书接着就把这个承诺戳破了:「在现实中,兼容性是一个美丽的愿望」——项目经常不检查兼容性就发版,不兼容的变化被不经意地发成次版本甚至补丁号,直接砸坏你的构建18。而且这里还有个隐形雷:被升级的只有 slf4j-api 本体,zookeeper 拖的配套件 slf4j-log4j12 还停在 1.6.1——半新半旧的组合,谁也没验证过。

3.4 地狱的另一种形状:循环依赖

钻石之外,更糟的是循环依赖:A 依赖 B,B 依赖 C,C 又依赖 A——一个库间接依赖它自己19。它制造的是「先有鸡还是先有蛋」的问题:升级任何一个库都会弄坏另一个;构建系统会出现「昨天好好的、今天突然失败」的诡异行为,应用会冒出难以捉摸的零星 bug20。原书提醒,工具类、辅助类项目是循环依赖的高发区——一个自然语言处理(NLP——让计算机处理人话文本的分支)库依赖字符串工具类,另一位开发者顺手在工具类里加了个用到了 NLP 库的方法,环就成了21

版本冲突的真实代价,原书给了 LinkedIn 的故事:工作流(按预先定好的步骤分批跑任务的系统)引擎 Azkaban 的任务突然报「方法不存在」错误,可缺失的方法明明就在上传的代码包里。排查结果是:Azkaban 用着一个老库 google-collections,而某个上传的包同时带着 google-collections 和它的后继者 Guava——Java 在运行时按类路径顺序找类,先找到老库里那几个类,再去调用只有新版本才有的方法,当场爆炸22。团队最终要靠隔离类路径加重构才收场。原书的收尾问句值得记住:仅仅为了几个集合类的帮助方法,这一切值得吗?

3.5 防御四件套

第一件:先过九问。 每次加依赖前问:真的需要这些特性吗?它维护得怎么样?出了问题好修吗?成熟度如何?破坏兼容的频率?团队对它的理解程度?自己写有多难? 许可协议是什么?你用的部分占整个包的比例23。这九问里最容易被跳过的是「自己写有多难」——DRY(不要重复自己,教人别写重复代码的原则)不是教人「任何重复的代码都必须抽成依赖」;原书明说,要务实,如果复制一段小代码能帮你避开一个庞大或不稳定的依赖,不要害怕复制代码(前提是许可协议允许)24

第二件:隔离。 真要和某个依赖划清界限,有三档力度:直接复制代码(适合短小稳定的片段);供应商化(vendor,把整个库的副本放进仓库,用 git-subtree 之类的工具管历史和更新);遮蔽(shading,构建时自动把依赖的包名改写进独立的命名空间(换个前缀、各住各的地址),防止它强加给别人)。遮蔽是高级技术,要少用,且永远不要在公开接口里暴露遮蔽后的对象25

第三件:显式声明 + 版本指定。 只用传递依赖里的类而不声明它,等于签了一份别人随时可以撕的约——类库哪怕在补丁升级里也有权换掉自己的依赖26。所以要版本指定(pinning,把每个依赖的版本写死):「把你的命运交给构建系统是个坏主意」,版本一变,代码就不稳定27。原书拿 Apache Airflow 的一段真实依赖声明展示了三种策略混用28:

'Flask-OAuthlib>=0.9.1' # 只设下限
'oauthlib!=2.0.3,!=2.0.4,!=2.0.5,<3.0.0,>=1.1.2' # 有界,还手动排雷
'requests-oauthlib==1.1.0' # 钉死

那几个被排除的版本(2.0.3-2.0.5)是已知有 bug 的——这不是洁癖,是踩过坑之后的防御工事。原书还附了这行配置的来历:上游库的修复迟迟不发,Airflow 只好把上游的依赖声明复制过来顶着,注释写着「等新版发布就撤掉」——18 个月后,那些复制的依赖声明仍然没有被恢复29

第四件:依赖清单 + 范围最小化。 你的直接依赖钉死了,传递依赖仍可能有通配符。所以要用依赖清单(pip freeze、Cargo.lock 这类文件)把整棵树的最终版本全部钉住,和代码一起提交——每次构建结果因此完全可复现30。最后,给每个依赖挑最小的依赖范围(编译期需要/仅运行时需要/仅测试需要):全声明成编译期依赖虽然省事,但会放大冲突面、撑大产物31。对循环依赖,用构建工具的内置检测,别靠肉眼32

4. 作者的判断与证据

  • 「依赖是租约」:这是本章的立场句,论证靠 left-pad(真实公开事件)+ 风险清单,不是统计。
  • 「兼容性是美丽的愿望」:作者的强判断。证据是行业观察(项目不验证兼容性就发版)+ 钻石依赖的机制推演;Guava/google-collections 故事是一次亲历,样本量 1。
  • 「自己写有多难」进九问:作者对 DRY 的修正,原书明确说「要务实」;这与第 02 章「十倍好」一脉相承——默认保守,例外要论证。
  • Airflow 依赖声明的例子:真实开源项目的真实文件,带作者注解,是本章最「可核对」的证据。
  • SemVer 规范本身:原书让读者去官方规范页看全文,本书按「定义+承诺表」转述。

5. 边界与局限

  • 语言生态偏差:例子全来自 Java/Gradle、Python、Go、Rust 的只言片语;JavaScript 的 lockfile 机制、各家包管理器的差异没有展开。
  • 「钉死一切」有反面:依赖清单防住了意外升级,也挡住了安全补丁的自动到达——原书没讨论「什么时候该主动升」(如今业界普遍有自动化依赖升级工具与安全扫描,书出版时未成主流)。
  • 遮蔽依赖讲得浅:只说「少用」,没给替代方案(如发布独立组件、接口隔离)。
  • 书出版于 2021 年;此后软件供应链安全(left-pad 的现代版是「投毒」攻击)成为独立议题,本书未覆盖。

6. 可带走的

  1. 加依赖前默念 left-pad:一行声明,一整棵树的租约;
  2. SemVer 三段号 = 三种承诺;0.x 不作数,RC 是预览,通配符是下一场事故的预约;
  3. 学会打印依赖树——冲突处那个箭头(->)是构建系统在替你做未经同意的决定;
  4. 「兼容性是美丽的愿望」:主版本号不变不等于没坑,升级后跑一遍集成测试再说;
  5. 别让工具类变成循环依赖的繁殖地:工具类不回头依赖业务库;
  6. 加依赖过九问,「自己写有多难」永远排在清单里;
  7. 短小稳定的功能,复制不可耻(许可允许的话);庞大或动荡的,才值得签租约;
  8. 依赖版本要 pin,传递依赖要锁(pip freeze / Cargo.lock),并把锁文件提交进仓库;
  9. 依赖范围给最小档:只在测试用的库,别让它进编译和运行时。

7. 原文地图

主题原书章原文位置
left-pad 开场第5章 依赖管理text/14-ch05.txt:3(搜「left-pad」) · text/14-ch05.txt:10(搜「包管理器」)
依赖的风险清单第5章 依赖管理text/14-ch05.txt:18(搜「循环依赖」)
依赖的定义与声明第5章 依赖管理text/14-ch05.txt:28(搜「相依性是指」) · text/14-ch05.txt:38(搜「4.3.6」)
版本三性质第5章 依赖管理text/14-ch05.txt:49(搜「唯一性」) · text/14-ch05.txt:59(搜「甜点」)
SemVer 与承诺第5章 依赖管理text/14-ch05.txt:66(搜「语义版本管理」) · text/14-ch05.txt:72(搜「意味着主版本号」)
0.x 与 RC第5章 依赖管理text/14-ch05.txt:91(搜「预发布」) · text/14-ch05.txt:101(搜「候选发布版」)
构建流水号第5章 依赖管理text/14-ch05.txt:107(搜「构建流水号」)
传递依赖与 101第5章 依赖管理text/14-ch05.txt:119(搜「依赖传递」) · text/14-ch05.txt:128(搜「httpcore」) · text/14-ch05.txt:141(搜「101 个类库」)
主走查:依赖树第5章 依赖管理text/14-ch05.txt:126(搜「Compile classpath」) · text/14-ch05.txt:162(搜「1.6.1 -> 1.7.21」)
钻石依赖第5章 依赖管理text/14-ch05.txt:174(搜「钻石依赖」) · text/14-ch05.txt:186(搜「1.6.1 -> 1.7.21 意味着」)
美丽的愿望第5章 依赖管理text/14-ch05.txt:192(搜「美丽的愿望」)
循环依赖第5章 依赖管理text/14-ch05.txt:198(搜「更糟糕的循环依赖」) · text/14-ch05.txt:205(搜「先有鸡还是先有蛋」)
Guava 事故第5章 依赖管理text/14-ch05.txt:219(搜「Azkaban」) · text/14-ch05.txt:224(搜「NoSuchMethodErrors」) · text/14-ch05.txt:232(搜「google-collections」)
加依赖九问第5章 依赖管理text/14-ch05.txt:252(搜「你真的需要这些特性吗」)
DRY 要务实第5章 依赖管理text/14-ch05.txt:269(搜「DRY」) · text/14-ch05.txt:272(搜「不要害怕复制代码」)
遮蔽依赖第5章 依赖管理text/14-ch05.txt:280(搜「遮蔽依赖」) · text/14-ch05.txt:282(搜「shaded.some.package.space」)
显式声明第5章 依赖管理text/14-ch05.txt:298(搜「显式声明为依赖项」) · text/14-ch05.txt:303(搜「commonscodec」)
版本指定第5章 依赖管理text/14-ch05.txt:312(搜「版本指定」) · text/14-ch05.txt:314(搜「把你的命运」)
Airflow 三策略第5章 依赖管理text/14-ch05.txt:339(搜「flask_oauth」) · text/14-ch05.txt:345(搜「明确地指定为 1.1.0」)
18 个月未撤第5章 依赖管理text/14-ch05.txt:377(搜「18 个月后」)
依赖清单第5章 依赖管理text/14-ch05.txt:357(搜「依赖清单」) · text/14-ch05.txt:358(搜「pip freeze」)
范围最小化第5章 依赖管理text/14-ch05.txt:385(搜「依赖范围」)
循环检测第5章 依赖管理text/14-ch05.txt:399(搜「内置的循环依赖」)

Footnotes

  1. 出处:「第5章 依赖管理」第 16 段(text/14-ch05.txt:16,搜「机器学习包」)。原文:数据库驱动程序、应用程序框架、机器学习包,有许多例子表明你不应该从头开始写某个类库。

  2. 出处:「第5章 依赖管理」第 18 段(text/14-ch05.txt:18,搜「缺乏控制」)。原文列出依赖带来的四类风险:不兼容的变化、循环依赖、版本冲突和缺乏控制。

  3. 出处:「第5章 依赖管理」第 3 段(text/14-ch05.txt:3,搜「left-pad」)与第 4 段(text/14-ch05.txt:4,搜「具有单一」)。

  4. 出处:「第5章 依赖管理」第 7 段(text/14-ch05.txt:7,搜「病毒传播的」)。

  5. 出处:「第5章 依赖管理」第 12 段(text/14-ch05.txt:12,搜「似乎是一个简单的决定」)。

  6. 出处:「第5章 依赖管理」第 49 段(text/14-ch05.txt:49,搜「唯一性」)与第 55 段(text/14-ch05.txt:55,搜「信息性」)。

  7. 出处:「第5章 依赖管理」第 59 段(text/14-ch05.txt:59,搜「甜点系列」)与第 59 段(text/14-ch05.txt:59,搜「Ubuntu」)。

  8. 出处:「第5章 依赖管理」第 72 段(text/14-ch05.txt:72,搜「意味着主版本号」)与第 73 段(text/14-ch05.txt:73,搜「4、3、6」)。

  9. 出处:「第5章 依赖管理」第 95 段(text/14-ch05.txt:95,搜「补丁版本号是递增」)与第 97 段(text/14-ch05.txt:97,搜「主版本号会被递增」)。

  10. 出处:「第5章 依赖管理」第 91 段(text/14-ch05.txt:91,搜「预发布」)与第 92 段(text/14-ch05.txt:92,搜「破坏旧代码的方式修改」)。

  11. 出处:「第5章 依赖管理」第 101 段(text/14-ch05.txt:101,搜「候选发布版」)与第 104 段(text/14-ch05.txt:104,搜「没有 RC 的后缀」)。

  12. 出处:「第5章 依赖管理」第 121 段(text/14-ch05.txt:121,搜「依赖关系报告」)。原书用 Gradle 的依赖报告做主例。

  13. 出处:「第5章 依赖管理」第 37 段(text/14-ch05.txt:37,搜「dependencies」)与第 38 段(text/14-ch05.txt:38,搜「4.3.6」)。

  14. 出处:「第5章 依赖管理」第 128 段(text/14-ch05.txt:128,搜「httpcore」)与第 141 段(text/14-ch05.txt:141,搜「101 个类库」)。

  15. 出处:「第5章 依赖管理」第 152 段(text/14-ch05.txt:152,搜「代码清单 5-3」)与第 162 段(text/14-ch05.txt:162,搜「1.6.1 -> 1.7.21」)。

  16. 出处:「第5章 依赖管理」第 179 段(text/14-ch05.txt:179,搜「选择其一」)与第 186 段(text/14-ch05.txt:186,搜「1.6.1 -> 1.7.21 意味着」)。

  17. 出处:「第5章 依赖管理」第 190 段(text/14-ch05.txt:190,搜「主版本号没有变化」)。

  18. 出处:「第5章 依赖管理」第 192 段(text/14-ch05.txt:192,搜「美丽的愿望」)与第 195 段(text/14-ch05.txt:195,搜「不经意地发版」)。

  19. 出处:「第5章 依赖管理」第 198 段(text/14-ch05.txt:198,搜「更糟糕的循环依赖」)。

  20. 出处:「第5章 依赖管理」第 397 段(text/14-ch05.txt:397,搜「构建先正常进行」)与第 205 段(text/14-ch05.txt:205,搜「先有鸡还是先有蛋」)。

  21. 出处:「第5章 依赖管理」第 206 段(text/14-ch05.txt:206,搜「工具类或辅助类」)与第 209 段(text/14-ch05.txt:209,搜「词干提取」)。

  22. 出处:「第5章 依赖管理」第 219 段(text/14-ch05.txt:219,搜「Azkaban」)、第 224 段(text/14-ch05.txt:224,搜「NoSuchMethodErrors」)与第 232 段(text/14-ch05.txt:232,搜「google-collections」)。

  23. 出处:「第5章 依赖管理」第 252 段(text/14-ch05.txt:252,搜「你真的需要这些特性吗」)与第 259 段(text/14-ch05.txt:259,搜「许可协议」)。

  24. 出处:「第5章 依赖管理」第 269 段(text/14-ch05.txt:269,搜「DRY」)与第 272 段(text/14-ch05.txt:272,搜「不要害怕复制代码」)。

  25. 出处:「第5章 依赖管理」第 280 段(text/14-ch05.txt:280,搜「遮蔽依赖」)、第 282 段(text/14-ch05.txt:282,搜「shaded.some.package.space」)与第 286 段(text/14-ch05.txt:286,搜「高级技术」)。

  26. 出处:「第5章 依赖管理」第 298 段(text/14-ch05.txt:298,搜「显式声明为依赖项」)与第 298 段(text/14-ch05.txt:298,搜「不要使用来自横向」)。

  27. 出处:「第5章 依赖管理」第 312 段(text/14-ch05.txt:312,搜「版本指定」)与第 314 段(text/14-ch05.txt:314,搜「把你的命运」)。

  28. 出处:「第5章 依赖管理」第 339 段(text/14-ch05.txt:339,搜「flask_oauth」)与第 345 段(text/14-ch05.txt:345,搜「明确地指定为 1.1.0」)。

  29. 出处:「第5章 依赖管理」第 374 段(text/14-ch05.txt:374,搜「临时的修复方案」)与第 377 段(text/14-ch05.txt:377,搜「18 个月后」)。

  30. 出处:「第5章 依赖管理」第 356 段(text/14-ch05.txt:356,搜「所有已解决的依赖项」)与第 361 段(text/14-ch05.txt:361,搜「相同的结果」)。

  31. 出处:「第5章 依赖管理」第 385 段(text/14-ch05.txt:385,搜「依赖范围」)与第 390 段(text/14-ch05.txt:390,搜「精确的依赖范围」)。

  32. 出处:「第5章 依赖管理」第 399 段(text/14-ch05.txt:399,搜「内置的循环依赖」)。