Maven 多模块项目最烦人的不是业务代码而是那一堆长得几乎一样的 pom.xml。十来个模块每个都重复同样的编译参数、同样的插件版本、同样的依赖版本清单某天要统一升级一个依赖就得挨个改十几个文件漏掉一个就等着“本地能跑线上报错”的经典事故发生。后来把公共配置抽到父 POM用 Maven 的继承机制统一管理这种事才算根治。这次我打算把继承机制一次讲透。它到底解决什么问题、哪些配置会真正被继承、父 POM 该怎么写、子模块该怎么继承dependencyManagement 和 pluginManagement 是怎么和继承配合的我都会展开讲同时给出一份可以直接抄的父子项目示例和踩坑记录。如果你正在搭建多模块工程或者已经被一堆重复 pom.xml 折磨过一阵这篇就是写给你的。1. 为什么需要继承先把痛点和思路说清楚1.1 没有继承时的多模块“配置债”先还原一下大多数项目的演进过程。单个服务的 Maven 工程只有一个 pom.xml写多少配置都无所谓。项目一变大拆模块就成了必然一个基础公共模块、若干个业务模块可能还有独立的 API 模块。模块一多你会发现在 pom.xml 里重复的内容越来越多。最典型的是这一段properties java.version1.8/java.version maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties在没做抽取的情况下这五六个配置会原封不动地出现在每个模块的 pom 里。再加上统一的 junit 依赖、统一的 compiler 插件配置、统一的仓库配置一个模块的 pom 可能就有一百多行公共部分占了一大半。配置重复带来的第一个问题是改动成本。Java 版本从 1.8 升到 11 时你得逐个文件修改少改一个模块构建时就会出现莫名其妙的编译版本不一致问题。第二个问题是版本漂移。时间一长总有人图省事在某个模块里单独引入一个依赖并随手写了个版本号过几天别人在别的模块里引了同一个依赖的不同版本项目里同一份依赖就出现了两个版本行为可能还不一样。第三个问题是阅读成本真正属于本模块的依赖被埋在几十行公共配置里。这些问题的根源就是缺少一个“公共配置存放处”。解决思路其实很朴素把重复的部分上移让所有模块共享。1.2 继承机制的设计目标Maven 里的继承机制和 Java 里类的继承在理念上非常像。类继承是子类拿到父类的属性和方法Maven 继承就是子 POM 拿到父 POM 里声明的配置。只不过这里继承的目标不是代码而是配置。设计上它要解决三件事第一集中管理公共配置改一处全局生效第二提供依赖版本和插件版本的统一入口避免同一依赖在项目里出现五花八门的版本第三保持子模块 pom.xml 的简洁让每个模块只关注自己真正需要的东西。有人会问那为什么不直接把公共依赖写进每个模块然后让某个中心服务统一打包事实上没有这种中心服务Maven 提供一个“父 POM”的概念公共配置就往里放。子模块通过 parent 元素声明“我的爸爸是谁”构建的时候 Maven 会把父 POM 和子 POM 合并成一个有效的配置再执行。这个合并过程是自动的不需要任何插件。理解了这个目标后面很多细节就会顺理成章。父 POM 不能是一个普通的 jar 型项目它必须是一个“纯配置”项目所以 packaging 必须是 pom。它也没有业务代码通常只是个容器装了所有模块共享的配置。2. 继承机制的基本玩法从父POM到子模块2.1 父POM的定位与创建父 POM 本质上还是一个 pom.xml它也有 groupId、artifactId、version 这一组“坐标”只不过 packaging 被强制设成 pom。packaging 为 pom 意味着这个工程不需要编译成 jar 或 war它存在的意义就是给其他工程当模板、当配置爸爸。创建父工程很简单在项目根目录下建一个目录里面放一个 pom.xmlpackaging 写成 pom 即可。通常父工程不包含 src 目录因为不会有代码。一个最朴素的父 POM 大概是这个样子?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIddemo-parent/artifactId version1.0.0/version packagingpom/packaging properties java.version1.8/java.version project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties /project注意几个关键点parent 三要素不能省子模块要靠 groupId、artifactId、version 找到这个父 POMpackaging 为 pom告诉 Maven 这是一个纯配置工程properties 是公共属性的储存地子模块里可以直接用 ${java.version} 引用。父工程还会经常配合 modules 标签做多模块聚合这部分我会在后面的章节展开说。现在你先记住父 POM 的核心使命是“被继承”。2.2 子模块如何声明继承子模块里要做的也很简单声明自己的 artifactId然后通过 parent 标签指向父 POM。看一个标准写法?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdcom.example/groupId artifactIddemo-parent/artifactId version1.0.0/version relativePath../pom.xml/relativePath /parent artifactIddemo-common/artifactId packagingjar/packaging dependencies !-- 本模块独有依赖 -- /dependencies /project这里有个容易忽略的细节子模块里并没有写 groupId 和 version因为它们可以从 parent 里继承。你只需要写一个 artifactId 就能唯一确定这个子模块的坐标。不过我的建议是即使可以省略在子模块里也把 groupId 写出来尤其当这个项目以后可能被外部分发引用时完整的坐标能让别人一眼看清归属。version 则建议继承父的因为子模块版本跟着父工程统一走省去很多同步成本。relativePath 表示父 POM 相对于当前子 POM 的路径。默认值是 ../pom.xml也就是说如果你把父工程和子工程放在同一个项目根目录下父 pom.xml 就在当前目录的上一级这个默认值刚好能命中其实可以省略。Maven 解析父 POM 时先按 relativePath 找找不到再去本地仓库、远程仓库找。如果父工程还没 install 过relativePath 又写错立刻就会报 Non-resolvable parent POM 错误。3. 继承到底“继承”了哪些内容一张表说透3.1 可继承与不可继承的元素清单父 POM 并非什么都能传给子模块。我整理了一张高频清单你记这个基本就够了。配置项是否能继承说明groupId / version可继承子模块省略时可从父 POM 获得artifactId不可继承每个模块必须自己有唯一标识packaging不继承子模块打包方式各自独立properties可继承公共属性统一使用升级版本改一处dependencies可继承父 POM 里显式声明的依赖所有子模块都会带上dependencyManagement可继承管理版本子模块按需引用才生效pluginManagement可继承管理插件版本和配置子模块声明插件后才生效plugins可继承父 POM 配置的插件会在子模块构建时执行repositories可继承公共仓库配置distributionManagement可继承公共发布目标modules不可继承modules 是给聚合用的不会被下游继承我强烈建议你把这张表贴在手边写父 POM 之前先过一遍明确每个标签放进去之后的传播范围。最常见的坑就是有人把 dependencies 当成 dependencyManagement 用结果所有子模块都被迫引入了一堆根本用不上的依赖。3.2 几个容易误解的继承细节第一dependencies 是“强制继承”dependencyManagement 是“按需继承”。dependencies 里写一个依赖所有子模块都会引入它哪怕这个模块只需要两个工具类而 dependencyManagement 里声明的东西只是定义了一个版本规范子模块只有在自己的 dependencies 里真正引用了对应依赖版本管理才会生效。所以父 POM 里一般不建议放大量 dependencies尤其是业务依赖那会造成严重的“依赖污染”。真正应该放 dependencies 的通常只有全模块都必需的测试库、日志接口这类基础设施。第二build 里面的配置怎么继承比如 maven-compiler-plugin你在父 POM 的 pluginManagement 里配好 source/target子模块里没有显式写 compiler 插件构建时是不是就无效了不是。Maven 的生命周期里已经预置了 compiler 插件的默认 execution子模块虽然没有显式声明但父 POM pluginManagement 中对该插件的版本和配置仍然会被有效合并到最终的执行配置里。而如果是自定义插件比如你想让某个模块额外执行一个特殊的打包步骤那就必须在子模块的 build/plugins 里显式声明该插件否则它不会凭空执行。第三properties 的继承是纯文本替换。父 POM 里定义了junit.version4.13.2/junit.version子模块里写 ${junit.version}构建时会替换成 4.13.2。注意 Maven 不会去理解这个版本号的含义它只做字符串替换。所以版本号千万不要写成带有换行或者多余空格的样子否则替换出来的字符串里带着这些空白很容易出问题。4. 实操从0搭建一个父子多模块项目4.1 目录结构和父POM完整示例光讲概念太抽象我直接演示一个典型的小型多模块项目。假设要搭一个支付系统包含一个公共模块和一个核心服务模块。目录结构是这样demo-root/ ├── pom.xml // 父 POM ├── demo-common/ // 公共模块 │ ├── pom.xml │ └── src/main/java/... └── demo-pay-service/ // 支付服务模块 ├── pom.xml └── src/main/java/...父 POM 要比之前那个朴素的版本丰富一些加上 dependencyManagement 和 pluginManagementproject xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIddemo-root/artifactId version1.0.0/version packagingpom/packaging modules moduledemo-common/module moduledemo-pay-service/module /modules properties java.version1.8/java.version project.build.sourceEncodingUTF-8/project.build.sourceEncoding junit.version4.13.2/junit.version maven-compiler-plugin.version3.8.1/maven-compiler-plugin.version /properties dependencyManagement dependencies dependency groupIdjunit/groupId artifactIdjunit/artifactId version${junit.version}/version scopetest/scope /dependency /dependencies /dependencyManagement build pluginManagement plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-compiler-plugin/artifactId version${maven-compiler-plugin.version}/version configuration source${java.version}/source target${java.version}/target encoding${project.build.sourceEncoding}/encoding /configuration /plugin /plugins /pluginManagement /build /project注意父 POM 里我同时写了 modules 和 pluginManagement这就是既当“聚合者”又当“配置爸爸”的典型写法。modules 的作用是让父工程一次构建就能带动所有子模块pluginManagement 和 dependencyManagement 是给子模块提供规范和默认值。4.2 子模块POM和构建命令demo-common 模块的 pomproject xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion parent groupIdcom.example/groupId artifactIddemo-root/artifactId version1.0.0/version relativePath../pom.xml/relativePath /parent artifactIddemo-common/artifactId packagingjar/packaging dependencies dependency groupIdjunit/groupId artifactIdjunit/artifactId scopetest/scope /dependency /dependencies /project注意 junit 依赖没有写 version因为父 POM 的 dependencyManagement 里已经统一管理了 version 和 scope。这是继承加依赖管理配合的典型场景。demo-pay-service 模块同样继承父 POM可以再引用 demo-commonproject xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion parent groupIdcom.example/groupId artifactIddemo-root/artifactId version1.0.0/version relativePath../pom.xml/relativePath /parent artifactIddemo-pay-service/artifactId packagingjar/packaging dependencies dependency groupIdcom.example/groupId artifactIddemo-common/artifactId version${project.version}/version /dependency dependency groupIdjunit/groupId artifactIdjunit/artifactId scopetest/scope /dependency /dependencies /project构建时在 demo-root 目录下执行mvn clean install因为父 POM 有 modules 聚合功能Maven 会按依赖顺序自动排好构建顺序先 install 父 POM 本身再构建 demo-common最后构建 demo-pay-service。这是一条命令打完整个项目最舒服的多模块交付方式。想验证继承到底生效了什么强烈推荐这个命令mvn help:effective-pom它会输出当前模块经过父子合并、超级 POM 合并之后的完整“有效 POM”所有继承来的配置全都在里面。排查任何继承问题先执行这个命令基本能解决一半。5. dependencyManagement 和 pluginManagement继承的两大支柱5.1 统一依赖版本的正确姿势dependencyManagement 是 Maven 的一个“版本规范机制”。放在父 POM 里它的作用是当子模块在自己的依赖中使用同一组坐标时可以省略 versionMaven 会自动套用父 POM 中管理的版本。为什么要刻意设计成“声明了才生效”而不是直接继承 dependencies因为直接继承会导致所有子模块无条件引入这个依赖不管它用不用。如果一个系统里有二十个模块其中只有两个用到某个中间件你把中间件直接写进父 POM 的 dependencies其余十八个模块全都白扛了这份依赖和它的传递依赖链。dependencyManagement 则不同它是一个“候选清单”谁用谁声明声明时不带版本号版本号统一由父 POM 说了算。实际项目里dependencyManagement 最常见的两个用法一是统一内部模块版本二是引入第三方平台 BOM。比如一个团队维护了多个公共服务父 POM 里可以这样约定dependencyManagement dependencies dependency groupIdcom.example/groupId artifactIdcommon-core/artifactId version1.2.0/version /dependency /dependencies /dependencyManagement子模块再用 common-core 时就不写 version 了。5.2 统一插件配置的正确姿势pluginManagement 解决的是插件配置重复的问题。在没有它的时候每个模块如果想用同一个编译参数、同一个打包插件版本就得在每个 pom 的 build/plugins 里各写一遍。有了 pluginManagement父 POM 里统一声明并配置插件子模块按需声明插件即可甚至很多默认生命周期插件不用声明配置会自动套用。它的生效规则和 dependencyManagement 很像但也有区别。dependencyManagement 是管理“要不要引入依赖”pluginManagement 是管理“插件怎么配置、用哪个版本”。一个插件如果在子模块的 build/plugins 里显式声明并且它在父 POM 的 pluginManagement 里有对应条目Maven 会把两者合并子模块里写的配置和父 POM 里的配置按规则叠加。注意如果子模块里自己写了相同的参数子模块的配置一般优先。还有一种情况是插件没有在子模块里显式声明但它本身是 Maven 默认生命周期的一部分比如 compiler、surefire。这些插件的有效配置同样会受到父 POM pluginManagement 的影响。这也就是为什么我在第 4 章的例子中只把 compiler 插件的配置写在父 POM 里子模块什么都不写也能生效。5.3 两组工具配合继承的注意事项这里有几个实战中容易翻车的地方。第一scope 和 dependencyManagement 的关系。依赖管理里如果写了 scopetest子模块引入该依赖时即使不写 scope 也会继承 test 作用域。所以想统一管理一个依赖的作用域时可以直接写进 dependencyManagement但要想清楚这个依赖在所有模块里都是同一个 scope 吗。比如某个工具类库在 A 模块是运行时依赖在 B 模块只是测试用那 scope 就不适合写死在父 POM 里应该在子模块里覆盖。第二版本覆盖。dependencyManagement 里管理了版本子模块还是可以在自己的 dependencies 里显式写 version 覆盖它。很多人误以为一旦进了 dependencyManagement 就“锁死”了其实不是。这个覆盖机制要小心用它给了特殊场景逃生的通道但也容易破坏全局一致性。我的建议是非必要不在子模块里写固定版本号真有特殊版本需求应该在父 POM 里提高或降低统一版本并注释清楚原因而不是在子模块里偷偷覆盖否则后续维护的人会非常困惑。第三BOM 的引入。想让多个模块共享一组第三方依赖版本又不想逐个声明可以在父 POM 的 dependencyManagement 里引入一个 BOM 依赖比如主流框架平台发布的依赖清单。引入之后子模块引用该平台下的组件时不需要写版本号版本由 BOM 统一指定。这个思路本质上就是“先管理依赖的依赖”也是多模块项目的常规优解。6. 聚合与继承别再把它们混为一谈6.1 聚合modules到底做了什么聚合是 Maven 的另一个机制目的只有一个在一个入口同时构建多个模块。父 POM 里这样写modules moduledemo-common/module moduledemo-pay-service/module /modules运行 mvn install 时Maven 发现当前 POM 是聚合 POM就会按照 modules 里列出的顺序或者根据依赖关系分析出的顺序依次构建每个子模块。注意这里“构建顺序”不一定是 modules 里写的顺序Maven 会先做依赖分析自动调整拓扑顺序。也就是说如果 demo-pay-service 依赖 demo-common即使你把 demo-pay-service 写在前面Maven 也会先构建 demo-common。聚合只解决“一起构建”的问题它不管配置共享的事。子模块可以完全没有 parent 声明只要父 POM 的 modules 里列了它父工程就能构建它。从这个角度看聚合和继承是两个完全独立的维度。6.2 聚合与继承的本质区别用一个表来对照最直观对比项聚合聚合POM继承父POM声明位置父 POM 用modules列出子模块子 POM 用parent指向父 POM方向从父到子父知道有哪些子从子到父子主动认领父核心目的统一构建入口一次构建所有模块共享公共配置避免重复子模块是否必须存在 parent不需要必须要有 parent是否解决配置重复问题不解决主要解决这个所以聚合是“父拉子”继承是“子认父”。两者方向相反解决的问题也不同。单有聚合子模块 pom 依然可以重复一百遍公共配置单有继承你每次还得小心地单独进入每个模块目录去构建。只有两个机制配合起来才能既保证配置不重复又保证一条命令构建全项目。6.3 最佳实践同一个POM既当爸爸又当管理员实际工程的惯例是顶层 POM 同时承担两个角色。它的 modules 里列出所有模块实现聚合它的配置里写清 dependencyManagement 和 pluginManagement 实现继承规范子模块的 parent 指向它。这就是我在第 4 章给出的写法。这个组合非常自然。你有一个根目录根目录下的 pom.xml 是父 POM子模块目录里各自有 pom.xmlparent 指向 ../pom.xml根目录的 pom.xml 又通过 modules 管理了所有子模块目录。目录结构清晰构建方便配置也有唯一出口。要提醒一点聚合 POM 的 modules 不应该被继承而子模块的 parent 也不是为了聚合。如果发现某个子模块的 parent 写了但根目录 pom 的 modules 里没有它那这个子模块只是继承了配置父工程不会主动构建它你需要单独进入子模块目录执行 mvn 命令或者在根目录 modules 里补上它。7. 常见问题与排查技巧实录7.1 高频问题速查表把我在实际维护中遇到的坑整理成一个速查表你遇到问题时可以直接对号入座。现象可能原因解决办法构建报 Non-resolvable parent POM父 POM 未安装到本地仓库或 relativePath 指向错误检查 relativePath确认父 POM 路径或在父工程目录先执行 mvn install子模块编译版本不对父 POM compiler 插件配置未生效执行 mvn help:effective-pom 检查有效配置确认 pluginManagement 拼写无误依赖版本总是旧版本dependencyManagement 的版本没有更新或本地仓库有旧版缓存升级父 POM 版本号后重新 mvn clean install必要时检查仓库配置子模块引入了不想要的依赖父 POM 的 dependencies 里写了公共依赖把公共依赖从 dependencies 挪到 dependencyManagement按需引用插件在子模块中不执行插件没有在子模块的 build/plugins 中显式声明确认插件是否需要绑定生命周期需要执行的插件要显式声明子模块 groupId 意外变化省略 groupId 后从父 POM 继承在子模块显式写全 groupId避免混淆聚合构建时某个模块被跳过模块间依赖解析失败或 modules 未包含该模块检查模块间依赖是否都有正确坐标modules 是否写全7.2 排查继承问题的三个实用思路第一个思路永远是先看有效 POM。继承机制会把父 POM、子 POM、超级 POM 全部合并最终生效的配置在有效 POM 里。你手工分析父子两块配置很容易漏执行 mvn help:effective-pom 把结果存成文件再检索任何继承导致的问题都能定位。第二个思路是检查构建顺序和安装状态。多模块项目最常见的报错就是“找不到父 POM”或者“找不到兄弟模块”十有八九是父工程没有 install 到本地仓库。先 install 父子关系里的每一层再构建上层问题就没了。如果用聚合入口构建Maven 会自动处理顺序但单独进入某个子模块构建时这个顺序就得你自己保证。第三个思路是分清是“继承问题”还是“依赖解析问题”。同样报“找不到依赖”如果报错信息里有 parent 字样就是继承链条的问题如果是某个 jar 找不着那多半是坐标或仓库的问题。不要一上来就怀疑父 POM 写错先读取报错信息里 artifactId:version 和错误码把方向搞对排查速度快很多。我在实际排查过程中发现很多同学问的问题根源其实就是分不清继承和聚合经常在 parent 和 modules 之间反复折腾。判断标准很简单如果只想让子模块共享配置就写 parent如果想让父工程一次构建所有子模块就写 modules大多数正规项目两个都要。维护程序里也一样每次想在一个子模块的 pom 里新加点什么先停下来想一想——这东西是该放到父 POM 的 dependencyManagement/pluginManagement 里还是只属于这个模块自己的 dependencies/plugins。判断依据就一条未来有两个以上模块可能用到就上收只有这个模块自己用就留在原地。坚持这个判断标准你的父子 POM 结构会一直保持清爽不用频繁推翻重构。
阅读完成 · 觉得有帮助?