本篇要回答的问题:软件工程是 “无数个项目” 的反复迭代,如何用版本管理保证质量?什么是 “发布单元”?只读设计为什么是版本管理乃至整个软件工程的灵魂?
上一讲谈了 “如何阅读别人的代码”——那是理解既有系统。本讲转向如何让长生命周期、反复迭代的系统保持质量确定性。软件工程不是一个项目,而是无数个项目,每个项目只是其中的一个里程碑(Milestone),这种反复迭代的工程要保质极难。
为什么需要版本管理
最朴素的思路:出问题了就召回、换老版本。
| 需求层次 | 要管什么 |
|---|---|
| 仅为召回 | 只需对可执行程序做版本管理 |
| 进一步定位问题、稳定再现 | 必须对源代码也做版本管理,且与可执行程序版本一一对应 |
发布单元:拥有独立迭代周期的软件实体
软件分模块开发,不同模块可能由不同团队甚至第三方开发。从细粒度看,一个软件工程包含很多个彼此独立的子软件工程,它们有自己的迭代周期,你只是它们的 “客户”。这种实体称为发布单元——直观特征是它有自己独立的源代码仓库(repo)。
关键判断:发布单元你可能直觉以为就是模块,但两者有很大不同——发布单元的核心是 “独立的迭代周期”。
发布单元的输出不一定是可执行程序:
| 输出形态 | 说明 |
|---|---|
| 可执行程序 / 虚拟机字节码 | 最终成品 |
| 动态库 | so / dylib / dll |
| 虚拟机动态库 | 如 JVM 的 jar 包 |
| 静态库(.a) | 可执行程序的半成品,由链接器组装成成品 |
| 源代码本身 | 部分语言主张源代码发布,如 Go |
发布单元的输入两部分:① 自己独立演进的模块(repo 托管代码);② 自己依赖的发布单元列表(外部、有独立迭代周期)。
源代码版本管理:以 GitHub 为例
源代码仓库系统(svn/git)一般只能管到输入的第一部分。GitHub 提供的质量管理手段:
| 手段 | 作用 |
|---|---|
| 开发活动独立性 | 低成本建 branch,一个分支做一个 feature,未完成对他人不可见,并行开发互不干扰 |
| 代码质量检查机制 | 提 pull request 合并前检查:单元测试、覆盖率(code coverage)、静态检查(lint)、人工互审(code review)……需求易变,故 GitHub 做了开放设计(再次感受开闭原则威力) |
| 完善的回滚(revert) | 已合并的有 Bug 的 PR 可 revert,主干得到去除该功能的新发行版 |
外部依赖管理与"只读"前提
依赖管理的目标:指定本发布单元依赖的各发布单元的建议版本,从而稳定持续地从源代码构建出相同能力的输出(Go 早期社区方案林立,最终 go mod 统一)。
核心前提:一个打好版本号的发布单元是只读的,不能做任何改动。
只读语义包含两层,破坏其一就会连锁破坏所有依赖者:
| 只读约束 | 含义 |
|---|---|
| 不改自身模块代码 | 容易理解 |
| 不改依赖的外部发布单元版本 | 如把 opencv 从 v1.0 升到 v2.0 也是一次变更,必须改自己的版本号 |
但仅源代码 + 依赖只读,仍不足以保证输出确定性,还有两个东西没只读:
| 未只读的东西 | 为何不纳入版本管理 |
|---|---|
| 操作系统内核 | 大部分情况质量有保证,行为不一致被记为软件 Bug 而非改操作系统 |
| 编译器 | 同理,理论上不同版本编译结果可能行为不同 |
软件发布的版本管理:容器化
源代码构建(build)是相对封闭可预期的环境(甚至可规定 OS 种类版本、编译器版本)。但软件发布环境千差万别——apt / rpm / brew 安装常失败,根因是用户系统环境差异太大,让发布者适配所有环境是过高要求。
彻底解决之道:容器化。容器镜像(image)不只含可执行程序,还完整包含运行所需的全部环境(动态库、运行时、甚至依赖的 “操作系统”),实现完全自描述、不依赖任何外部环境。新版本有缺陷?回滚到老镜像即可。
只读设计的确定性(贯穿全篇的哲学)
版本是一个 “基线”,只读让我们对它有确定性预期——这正是软件工程的核心:在大量不确定性中找确定性。只读思想被广泛运用:
| 场景 | 只读如何体现 |
|---|---|
| 版本管理 | 打号的发布单元只读 |
| 开闭原则 | 模块的业务范畴只读,接口稳定,模块才能自由组合 |
| 函数式编程 | 倾向变量只读,提高确定性预期 |
| 大数据 Spark / RDD | RDD 只读,施加 transform 得到新 RDD,让重试、延迟计算、缓存都极其简单 |
版本的兼容问题
依赖特定版本解决了确定性,但总会想升级新版本。最大风险是所依赖模块完成了一次重构——主体功能好兼容,难度全在细节(错误码、低频分支行为)。
| 兼容策略 | 适用 / 代价 |
|---|---|
| 放弃兼容,连函数名都改 | 干脆:客户升级后编译不过,老实用新接口重写重测 |
| 必须兼容(互联网 API) | 发布的 api 难收回(会丢大量客户),故为 api 引入版本号 /v2/foo/bar,不兼容修改时升到 /v3/foo/bar |
关键判断:api 加版本号的额外好处——全局重构且难兼容细节时,可直接升版本号,线上保留两个版本并存(nginx 作为分派网关)。但这是不得已的办法,能兼容还是尽量兼容,否则客户端升级的心智负担越大。
总结
复杂软件总可分割为若干独立迭代的发布单元以分而治之。发布单元的切割不宜过细,以一个小团队负责起来比较舒服为宜。源代码用 git/GitHub 管开发期质量,发布用容器镜像管运行期确定性,只读设计提供确定性基线,而版本兼容是最容易踩的坑。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 发布单元 | 拥有独立迭代周期、独立 repo 的软件实体(≠ 模块) |
| 只读语义 | 打号的发布单元不可改自身代码、也不可改依赖版本 |
| 容器镜像 | 自描述、含全部运行环境的版本单元,比源代码版本管理更进一步 |
| api 版本号 | /v2/.../v3/...,应对不兼容修改,可线上多版本并存 |
一句话速记
把系统拆成独立迭代的 “发布单元”,给每个打上只读的版本基线,再用容器镜像把运行环境一起冻结——用 “只读” 在反复迭代的不确定性里凿出确定性。
几条值得记住的判断
- 只读 = 确定性:从版本、开闭原则到 Spark RDD,本质都是只读思想。
- 容器化才彻底解决软件发布在异构环境下的安装失败问题。
- 兼容的难度全在细节(错误码、低频分支);互联网 API 用版本号 + 多版本并存兜底。
思考题
回到你的项目:你依赖的外部模块都锁定版本号了吗,还是有人偷偷把某个依赖 “原地” 升级却没改自己的版本号,破坏了只读语义?你对外提供的 API,下一次不兼容改动打算靠 “改名硬断” 还是 “升版本号并存”?
