过度设计的代价:从 Maven 版本幻觉到工程上的简单原则
过度设计的代价:从 Maven 版本幻觉到工程上的简单原则
前段时间,我在梳理内部组件库的版本管理方式。
一开始,我想解决的问题很直接:组件越来越多,每个模块又有自己的版本号,能不能把这些版本统一管理起来,减少重复配置,也让后续维护更规范?
沿着这个思路,我把版本号集中放进 parent POM,通过<dependencyManagement>管理内部组件依赖。子模块不再单独填写版本,所有版本都从同一个地方获取。
从配置上看,这套方式确实更整齐。
但当我继续推演组件的构建、发布和消费过程时,一个问题逐渐暴露出来:
本地构建时使用的依赖版本,未必就是消费者最终解析到的版本。
如果子模块发布后的 POM 仍然需要依靠远程 parent POM 才能获得内部依赖版本,那么只要两者没有同步更新,组件自己的版本虽然升级了,消费者拿到的底层依赖仍然可能停留在旧版本。
版本号写的是 A,实际运行的却可能是 B。
我原本想通过统一管理减少维护成本,结果却引入了一个新的前提:子模块、parent POM 和私服中的发布状态必须始终保持一致。
它在形式上更整齐,却把复杂度藏进了发布流程。
继续分析 A、B、C 三种方案后,我才重新意识到一件事:
工程上的统一不是越多越好。如果统一管理没有消除复杂度,只是把复杂度转移到更隐蔽的地方,它反而可能把人引向错误的方向。
目录
- 我们为什么想统一管理版本
- 看起来整齐的方案如何制造麻烦
- 三种版本管理方案的真实取舍
- 为什么我最终更倾向于方案 C
- 这次问题真正让我反思的事
我们为什么想统一管理版本
我们的内部组件库基于 Java、Maven 和 Spring Boot,包含核心工具库、HTTP 客户端、开放接口 SDK、SSO 组件、监控组件、日志组件以及多个 Starter。
它们之间存在清晰的依赖关系:
开放接口 SDK └── HTTP 客户端 └── 核心工具库 SSO 组件 └── 核心工具库 监控、日志等组件 └── 核心工具库这些组件的更新频率并不相同。
核心模块相对稳定,部分边缘模块仍在快速迭代。如果所有模块使用同一个版本号,一个小模块改动一次,就可能带着一批没有变化的模块一起升级。
所以,我们最初采用了“独立版本 + parent POM 集中管理”的方式:
- 每个组件拥有自己的版本号;
- 版本值集中定义在 parent POM 的
<properties>中; - parent POM 通过
<dependencyManagement>统一管理内部组件版本; - 子模块声明内部依赖时不重复填写
<version>。
例如,子模块只需要这样写:
<dependency><groupId>com.example</groupId><artifactId>core-lib</artifactId></dependency>版本由 parent POM 统一提供:
<dependencyManagement><dependencies><dependency><groupId>com.example</groupId><artifactId>core-lib</artifactId><version>${core-lib.version}</version></dependency></dependencies></dependencyManagement>从开发视角看,这套方案很合理:
- 版本定义只有一份;
- 子模块 POM 更简洁;
- 修改版本时不需要到处搜索替换;
- 仍然保留了各组件独立升级的能力。
如果只看源码仓库,它甚至显得很优雅。
问题出现在“构建完成之后”。
看起来整齐的方案如何制造麻烦
Maven 构建使用的不是某一个孤立的pom.xml,而是经过父子继承、属性解析和依赖管理后形成的有效模型。
在本地多模块工程中,子模块可以顺利从 parent POM 获得依赖版本,所以编译和测试都可能正常通过。
但消费者不会读取我们的整个源码仓库。它只会从私服获取发布后的组件、组件 POM,以及解析该 POM 所需的其他模型。
这意味着,真正需要检查的不是:
我们源码里的版本配置是否正确?
而是:
消费者拿到的发布后 POM,能否独立、准确地描述构建时使用的依赖?
如果发布后的子模块 POM 仍然需要依靠远程 parent POM 才能获得内部依赖版本,那么子模块和 parent 就形成了一个必须同步发布的组合。
假设核心工具库从0.1.0升级到了0.1.1:
1. 本地修改 parent 中的版本属性 2. 本地重新构建 HTTP 客户端 3. HTTP 客户端构建时使用 core-lib 0.1.1 4. 只发布 HTTP 客户端,没有同步发布对应的 parent 5. 消费者读取远程模型时,仍得到 core-lib 0.1.0于是就出现了最让人困惑的情况:
生产出来的组件,和消费者依据发布元数据重新解析出来的依赖关系,不是同一个状态。
这类问题难排查,是因为每个局部看起来都没有错:
- 代码改了;
- 版本升了;
- 本地构建通过了;
- 新组件也发布了;
- 消费者引入的组件版本也是新的。
真正出错的是这些状态之间没有一起前进。
新增模块时尤其容易暴露
新增组件后,如果远程 parent POM 中还没有对应的依赖管理信息,消费者可能无法正确解析依赖,或者继续使用旧的版本关系。
开发者会觉得:“Jar 明明已经上传了,为什么还是不能用?”
因为发布一个 Maven 组件,从来不只是上传一个 Jar。POM 本身也是发布契约的一部分。
依赖升级可能只在本地生效
底层组件升级后,本地 Reactor 构建会使用当前工作区中的新模型。
消费者却只能依据私服中的发布模型解析依赖。如果两边的 parent 状态不同,本地验证通过并不能证明消费者会得到同样的依赖树。
集中管理还可能引入版本污染
为了修复某个组件的依赖版本,我们重新发布 parent POM。这个 parent 中却不只管理一个组件,而是记录了整个组件库的版本矩阵。
原本只想推进 A 组件的状态,结果可能把 B、C、D 的新版本关系也一起暴露给消费者。
我们原本想用一个中心统一控制所有版本,最后却得到了一个需要谨慎维护、频繁覆盖、难以追溯的共享状态。
统一管理没有消失,它只是从“修改 POM”变成了“保证所有发布状态永远同步”。
后者显然更难。
三种版本管理方案的真实取舍
发现问题后,我重新比较了三种方案。
它们都能工作,但解决的是不同问题,也会引入不同成本。
方案 A:继续集中管理,加强发布约束
第一种做法是不改变现有结构,继续通过 parent POM 的<dependencyManagement>管理独立版本,只要求发布时严格同步相关 parent。
它的优势很明确:
- 每个组件仍然可以独立升级;
- 版本定义集中,源码修改方便;
- 子模块 POM 保持简洁;
- 不需要大规模调整现有结构。
但它的代价也没有消失:
- 发布流程仍然存在额外步骤;
- 自动化之外的人工操作容易遗漏;
- 发布结果依赖子模块和 parent 的状态一致;
- 同一个可覆盖的 SNAPSHOT parent 难以准确追溯;
- 新模块接入时必须同步更新并发布模型。
这个方案不是不能用。只要发布流水线足够成熟,能够自动计算影响范围、同步发布 parent,并验证最终私服中的 POM,它可以继续运行。
问题是,我们当前真正需要的是一套更简单的发布方式,而不是再给已有流程增加更多规则和校验。
方案 B:所有模块使用统一版本
第二种方案是所有模块共用一个${revision}。任何模块发生变化,整个组件库统一升版。
它最大的优势就是简单:
- 只有一个版本号;
- parent 和子模块天然处于同一发布批次;
- 不需要维护复杂的内部版本矩阵;
- 消费者很容易判断一组组件是否来自同一次发布。
如果项目中的模块总是一起修改、一起测试、一起发布,这通常是一种很自然的选择。
但我们的组件库并不是这种形态。
在当时的模块中,大部分组件已经相对稳定,只有少数组件仍在高频变化。如果统一升版,一次局部修改会让大量未变更组件产生新版本:
- 版本号不能直接反映单个组件是否变化;
- 消费者可能被迫进行不必要的升级;
- CI 缓存和依赖下载受到额外影响;
- 局部发布变成整组发布。
方案 B 用发布批次的一致性,换掉了组件独立演进的能力。
它比方案 A 更简单,但并不符合我们当前的模块变化节奏。
方案 C:让发布 POM 明确记录内部依赖版本
第三种方案最直接:子模块声明内部依赖时,明确写出所需版本。
<dependency><groupId>com.example</groupId><artifactId>core-lib</artifactId><version>0.1.1-SNAPSHOT</version></dependency>这样做之后,发布的组件 POM 自己就能说明:
我在构建和发布时,声明依赖的是哪个版本。
消费者不再必须依赖某个持续变化的 parent 状态,才能知道这个组件希望使用哪个内部依赖版本。
这里需要说明一个边界:明确写出版本,并不意味着消费者绝对不可能覆盖它。Maven 仍然存在依赖仲裁,消费方也可以通过自己的<dependencyManagement>统一指定版本。
方案 C 真正解决的是另一件事:
发布方不再把最基本的依赖版本信息藏在一个需要额外同步的共享状态里。
它不是禁止消费者治理依赖,而是先让每个发布物把自己的依赖契约说清楚。
为什么我最终更倾向于方案 C
单看代码,方案 C 似乎不够“高级”。
同一个版本号可能出现在多个 POM 中。底层组件升级后,需要逐个修改真正需要新版本的依赖方。相比集中管理,它存在一定重复。
但换一个角度看,这些重复并不是毫无意义的重复。
它记录的是每个组件真实选择的依赖版本。
假设core-lib从0.1.1升级到0.1.2,只有某个 Starter 需要新功能,那么只修改这个 Starter:
升级 core-lib 到 0.1.2 ↓ 修改确实需要新能力的 Starter ↓ 重新构建并发布这两个组件其他依赖core-lib、但不需要新功能的组件,可以继续使用原来的声明,不必被迫跟随升级。
这种方式带来了几个我更看重的结果。
发布物更自包含
看到组件的 POM,就能知道它声明了哪些直接依赖版本,不需要继续追查当时远程 parent 究竟是什么状态。
变更范围更符合业务事实
谁需要新版本,谁就修改自己的 POM 并重新发布。
没有变化的组件不需要为了形式上的一致性制造新版本。
排查路径更短
出现问题时,可以直接对照:
- 组件源码中的依赖版本;
- 私服中发布 POM 的依赖版本;
- 消费者最终解析出的依赖树。
三个层次清晰可见,而不是先猜测 parent 是否被正确发布。
人工步骤更少
它把“记得同步某个隐式状态”,变成了“修改当前组件明确声明的依赖”。
两者都需要维护,但后者离变更发生的位置更近,也更容易在代码评审中被发现。
当然,方案 C 不是 Maven 组件库的通用最佳实践。
如果团队能够正确配置 Flatten Plugin,让发布后的 POM 固化有效依赖信息;或者维护一个有明确版本、不可覆盖、与发布批次严格绑定的 BOM,也可以解决发布模型不自包含的问题。
如果所有模块天然同版本发布,方案 B 甚至可能比方案 C 更合适。
我选择 C,不是因为它在理论上最漂亮,而是因为它最符合我们当时的几个现实条件:
- 模块更新频率不同;
- 希望保留独立版本;
- 发布流程不应该依赖容易遗漏的额外动作;
- 组件规模尚未大到手动维护直接依赖版本不可接受;
- 当前最重要的是让构建状态和发布契约更容易理解。
它没有消灭所有维护成本,只是把成本放到了更显式、更可检查的位置。
对我来说,这已经是很大的改进。
这次问题真正让我反思的事
回头看,我们最初选择集中管理的理由完全成立。
我们想减少重复、统一修改入口、让版本管理更规范。这些目标没有错。
错的是,我们太早把“统一”当成了答案。
当系统中有十几个更新频率不同、发布节奏不同的组件时,强行把它们的版本状态集中到一个 parent 中,表面上减少了 POM 里的重复,实际上增加了更多隐含约束:
- parent 必须同步更新;
- parent 必须同步发布;
- 子模块必须引用正确的 parent;
- Flatten 配置必须符合发布目标;
- 私服中的模型必须和本地构建模型一致;
- 发布者必须理解这些机制并严格执行。
我们省掉了几行<version>,却换来了一套更难发现错误的协作协议。
这不是说抽象、复用和统一管理没有价值。
真正的问题是:每增加一层抽象,都应该证明它减少了系统的总体复杂度,而不只是让局部代码看起来更整齐。
判断一个工程方案,也不能只看它是否“规范”,还要继续追问:
- 它减少的是重复代码,还是实际维护成本?
- 它把状态放到了更明确的位置,还是更隐蔽的位置?
- 出错后,普通开发者能否沿着直觉找到原因?
- 它依赖自动化保证,还是依赖每个人永远不忘记某个步骤?
- 当模块和团队继续增长时,这套约束会更稳定,还是更脆弱?
有时候,重复写几行版本号看起来不够优雅,但每个组件都能把自己的依赖关系说清楚。
有时候,一个统一入口看起来很先进,却要求多个发布物、多个环境和多个人始终保持同步。
工程设计不是寻找形式上最漂亮的方案,而是在当前约束下,把真实复杂度降到最低。
我们原本希望通过集中管理消除重复,最后却引入了一个必须始终同步、又很容易被遗漏的隐式状态。相比形式上的统一,我更愿意让每个 POM 明确写出自己的真实依赖。代码重复了一点,但系统简单了很多。
这就是我最终更倾向于方案 C 的原因。
它可能不是最优雅的答案,但它让依赖关系更明确,让发布结果更可追溯,也让后来的人少记一条“千万不要忘记”的规则。
而在真实的软件工程中,能少依赖一点人的记忆,往往比少写几行配置更重要。
如果你也维护过 Maven 多模块组件库,可以检查一下:消费者最终拿到的 POM,是否真的记录了你构建时使用的依赖关系?
也欢迎在评论区聊聊,你更倾向集中管理、统一版本,还是让每个组件明确声明依赖。
参考资料
- Maven:Introduction to the Dependency Mechanism
- Maven:POM Reference
- Flatten Maven Plugin:flatten:flatten
