乐于分享
好东西不私藏

12-Factor App 解读:依赖为什么必须显式声明

12-Factor App 解读:依赖为什么必须显式声明

很多工程实践看起来像“经验之谈”,但往前追一层,往往能找到更早、更稳定的方法论来源。

我计划写一个系列文章,围绕 12-Factor App 的各项原则做一次重新解读。目的不是复述概念,而是把这些原则放回今天的软件工程、DevOps、容器化、供应链安全和 AI 辅助开发语境中,看看它们为什么仍然有效。

第一篇,先从 Dependencies,依赖关系 开始。

1. 什么是 12-Factor App

12-Factor App 可以理解为一组面向现代应用程序的设计原则。它最初主要服务于 SaaS 应用,但其思想早已超出某一种部署形态。

它关注的是一个应用如何被构建、配置、发布、运行和扩展。换句话说,它不只是代码风格问题,而是应用从开发机到生产环境全过程的工程约束。

在今天看,12-Factor 仍然有价值,原因很直接:我们面对的问题没有消失,只是换了外壳。

过去的问题可能是“为什么在我的机器上可以运行”;今天的问题可能变成了:

  1. 为什么同一份代码在 CI 里构建失败?
  2. 为什么容器镜像换了基础镜像后行为变了?
  3. 为什么本地、测试、生产环境的依赖版本不一致?
  4. 为什么安全扫描发现漏洞后,很难定位到底是谁引入的?
  5. 为什么 AI 生成的修复建议看似正确,但落到项目里无法复现?

这些问题背后,都有一个共同关键词:上下文是否明确

Dependencies 原则讨论的,就是应用对外部依赖的上下文应该如何表达。

2. Dependencies 原则到底说什么

Dependencies 原则的核心可以概括为一句话:

应用不应该隐式依赖系统环境中“碰巧存在”的包或工具,而应该显式声明并隔离自己的依赖。

这里有两个关键词:显式声明 和 隔离

所谓显式声明,是指应用需要清楚写出自己依赖了什么。例如:

  • Go 项目使用 go.mod 和 go.sum 声明模块依赖与校验信息。
  • Python 项目使用 pyproject.tomlrequirements.txt 或锁文件声明运行依赖。
  • TypeScript 项目使用 package.json 和 package-lock.jsonpnpm-lock.yamlyarn.lock 等文件声明包和锁定版本。

所谓隔离,是指应用不应该默认使用系统全局环境里的依赖。例如:

  • Python 不应该依赖某台机器全局安装过的包,而应该使用虚拟环境、构建环境或容器隔离。
  • TypeScript 不应该依赖全局安装的某个 CLI,而应该把工具放进项目依赖,通过 npm scriptspnpm 或 npx 调用。
  • Go 虽然编译后通常得到独立二进制,但构建阶段仍然需要明确模块版本、工具链版本和外部系统依赖。

Dependencies 原则反对的不是“使用依赖”,而是反对依赖关系只存在于某个人的机器、某个基础镜像、某个隐含路径、某段口口相传的部署说明里

依赖可以复杂,但不能漂浮。

3. 为什么一定要显式声明依赖

很多团队一开始并不是不懂依赖管理,而是觉得“现在能跑就行”。问题是,隐式依赖的成本不会立刻出现,它通常在环境迁移、人员交接、CI/CD 重建、基础镜像升级、安全审计时集中爆发。

3.1 避免环境不一致

最典型的问题是:本地能跑,测试环境不能跑;测试环境能跑,生产环境不能跑。

原因可能只是某台机器上曾经手动安装过一个系统库,某个容器基础镜像里刚好带了一个命令,或者某个开发者全局安装过一个 CLI。

这些依赖没有写在项目里,就不属于项目契约的一部分。它们存在,但不可见;可用,但不可控。

显式声明依赖,本质上是在告诉所有环境:运行这个应用需要哪些东西,版本边界是什么,安装入口在哪里。

3.2 让构建可复现

可复现构建不是一句口号,而是工程系统的底线能力。

如果今天构建出的产物和明天构建出的产物不一致,而代码没有变化,那么问题通常出在环境、依赖或构建过程本身。

Go 的 go.sum、Python 的锁文件、TypeScript 生态中的 lockfile,都是为了降低这种不确定性。它们不能解决所有问题,但至少把“依赖版本漂移”这个风险从黑盒里拿了出来。

一个成熟的构建系统应该尽量做到:换一台机器、换一个 CI runner、换一个构建节点,只要输入一致,输出就应该尽可能一致。

3.3 优化镜像体积

在容器时代,依赖声明还会直接影响镜像体积。

如果依赖关系不清晰,构建镜像时很容易把不需要的工具、缓存、开发依赖、测试依赖一起打进最终镜像。

例如:

  • Go 项目可以通过多阶段构建,把编译工具链留在 builder 阶段,最终镜像只保留运行所需的二进制和必要文件。
  • Python 项目可以区分运行依赖和开发依赖,避免把测试框架、格式化工具、构建缓存带进生产镜像。
  • TypeScript 项目可以在构建阶段安装完整依赖,在运行阶段只保留构建产物和生产依赖。

依赖越明确,裁剪越有依据。否则所谓“瘦身”只能靠猜。

3.4 支持安全审计

安全审计也依赖显式信息。

如果项目的依赖没有被清楚声明,漏洞扫描工具就很难准确判断当前系统到底使用了哪些组件、哪些版本、哪些传递依赖。

这不仅影响漏洞发现,也影响漏洞修复。

当一个依赖被曝出漏洞时,团队需要快速回答几个问题:

  1. 我们是否使用了它?
  2. 使用的是哪个版本?
  3. 是直接依赖还是传递依赖?
  4. 哪些服务受影响?
  5. 升级后会影响哪些构建与运行环境?

这些问题如果只能靠人工登录机器排查,就说明依赖关系没有成为工程资产。

4. 在 Go、Python、TypeScript 中如何实践

Dependencies 原则不是抽象口号,它最终一定要落到项目文件、构建命令和运行环境里。

4.1 Go:把模块和工具链边界写清楚

Go 项目的依赖管理通常从 go.mod 开始。

基本实践包括:

  1. 使用 go.mod 声明模块路径、Go 版本和直接依赖。
  2. 保留 go.sum,用于记录依赖校验信息。
  3. 在 CI 中检查 go mod tidy 后是否仍有差异,避免依赖声明和实际代码脱节。
  4. 对构建工具、代码生成工具保持明确管理,不依赖开发者机器上的全局状态。
  5. 容器构建使用多阶段构建,区分编译环境和运行环境。

Go 的优势是编译后产物相对独立,但这不代表依赖问题消失。构建阶段的模块版本、工具链版本、CGO 开关、系统库依赖,都会影响最终产物。

如果一个 Go 服务依赖了系统动态库、证书文件、时区数据或外部命令,也应该在构建和镜像定义中明确表达。

4.2 Python:不要把全局环境当项目环境

Python 的依赖管理复杂度更高,因为解释器版本、包版本、系统库、虚拟环境之间关系密切。

基本实践包括:

  1. 使用 pyproject.toml 作为现代项目元数据入口。
  2. 对应用运行依赖、开发依赖、测试依赖做清晰分组。
  3. 使用锁文件或固定版本策略提高构建稳定性。
  4. 在 CI 和容器中从空环境安装依赖,避免复用本机状态。
  5. 对包含原生扩展的依赖,明确系统库和构建工具要求。

Python 项目尤其要警惕“我本地已经装过”的问题。全局 Python 环境里多出来的一个包,可能会掩盖项目声明缺失;到了 CI 或容器里,这个问题才会暴露。

真正可靠的做法,是让项目在一个干净环境里也能完成安装、测试和启动。

4.3 TypeScript:把工具也视为依赖

TypeScript 项目中,依赖不只是运行时包,还包括构建工具、类型工具、测试框架、代码生成器和打包器。

基本实践包括:

  1. 使用 package.json 声明 dependencies、devDependencies 和 scripts。
  2. 提交 lockfile,确保团队和 CI 使用一致的依赖解析结果。
  3. 避免依赖全局安装的 tscviteeslintprettier 等工具。
  4. 使用 npm cipnpm install --frozen-lockfile 或类似命令保证安装过程不随意改写依赖图。
  5. 生产镜像中只保留运行所需依赖和构建产物。

前端和 Node.js 服务都容易出现“工具链隐式依赖”。例如某个人全局安装了新版本 CLI,本地构建通过了;CI 使用项目内旧版本工具,构建结果却不同。

工具链也是依赖。只要它影响构建结果,就应该进入项目契约。

5. 显式是一种原则

Dependencies 原则真正值得延伸的地方,不只是“依赖要写进文件”,而是背后的工程哲学:显式是一种契约原则

“默认”听起来很方便,但默认值从来不是抽象存在。它一定由某个系统、某个版本、某个配置、某个维护者在某个时刻定义。

而这些东西,都可能变化。

生活里也一样。

你去咖啡馆说“默认浓度”,不同店可能完全不同;去面馆说“正常硬度”,师傅的理解可能跟你不一样;餐厅默认给不给纸巾,外卖默认给不给餐具,也都不是天然确定的事情。

如果你真的在意结果,就不能只说“默认就行”。你需要把关键条件说清楚。

工程系统更是如此。

例如 Kubernetes manifest 里,如果没有明确写资源 requests 和 limits,调度依据、资源争抢和高负载下的运行表现都可能变得不可预期。某个环境下看起来没问题,不代表另一个集群、另一个版本、另一个负载条件下仍然稳定。

显式不是为了啰嗦,而是为了减少猜测。

契约的双方都需要明确知道契约内容,才能更好地履行契约。

对应用来说,依赖声明是应用和运行环境之间的契约。

对团队来说,依赖声明是开发者、CI/CD、运维系统和安全工具之间的契约。

对 AI 来说,显式上下文同样重要。

AI 辅助开发越来越普遍后,一个新的问题出现了:AI 并不会天然知道你的项目约束。它依赖你给出的上下文,也依赖仓库中已经存在的显式信息。

如果依赖关系、构建命令、运行方式、环境变量、资源限制都写得清楚,AI 更容易给出贴合项目的建议。反过来,如果一切都靠隐式知识和口头约定,AI 只能在不完整上下文里猜测,结果自然不稳定。

这不是 AI 的特殊问题,而是工程系统长期存在的问题。AI 只是把“上下文不显式”的成本放大了。

小结

12-Factor App 的 Dependencies 原则,看似只是在讲依赖管理,实际讲的是现代应用的可移植性、可复现性和可治理性。

显式声明依赖,至少带来五个直接收益:

  1. 减少不同环境之间的行为差异。
  2. 提高构建过程的可复现性。
  3. 为容器镜像瘦身提供依据。
  4. 让安全审计和漏洞修复有据可查。
  5. 为团队协作和 AI 辅助开发提供明确上下文。

软件工程里,很多问题不是因为系统太复杂,而是因为重要信息没有进入契约。

Dependencies 原则提醒我们:不要把应用能否运行,寄托在某个环境“刚好有”的东西上。

把依赖写下来,把边界说清楚,把默认值变成明确选择。

这不是繁琐流程,而是工程系统走向可靠的起点。

如果你也在维护服务、构建流水线或容器镜像,可以回头检查一下:你的项目依赖,是写在契约里,还是藏在某台机器里?

欢迎点赞、在看,也欢迎在评论区聊聊:你遇到过哪些因为隐式依赖导致的线上或构建问题?