1. 为什么会有第三篇:从“能用Maven”到“用明白Maven”
前两篇把单模块项目的生命周期、依赖scope、常用插件这些地基打完之后,很多朋友会卡在一个尴尬的位置:单个项目跑得挺顺,一到公司里的多模块工程就懵了。明明代码看起来差不多,但mvn clean install报错时连错误日志都看不明白,更别说搞清楚父POM、聚合、继承、BOM这些概念到底谁管谁。
这篇就是冲着这个断崖来的。我会围绕多模块工程、聚合与继承、settings.xml、私有仓库、构建参数和踩坑复盘展开,把这些内容理成一条清晰的进阶路线。读完你应该能独立搭建一个父子多模块项目,能定位常见的依赖和配置问题,也知道什么时候该去改pom、什么时候该去改settings、什么时候其实是仓库那边出了问题。
为什么这一步这么重要?因为单模块项目就像你一个人住的单身公寓,水电燃气接好就行。多模块项目是一栋楼——有公共的管道井(父POM)、有标准化的户型图纸(聚合+继承)、还有物业管理(仓库)。你在公寓里学的那套装修经验,直接搬到大楼里一定会碰壁,但又不能说完全没用。关键在于搞明白哪些规则变了、哪些规则没变。
第三个阶段大多数人的误区是这样的:以为多模块就是把一堆项目塞进一个文件夹,父POM里写满依赖,然后靠IDE的Reimport按钮解决问题。这不是工程管理,这是碰运气。真正的多模块设计核心是三件事:聚合告诉Maven“这群项目是一个整体”;继承告诉Maven“这些项目共用一套配置”;dependencyManagement告诉Maven“版本号我说了算,但具体要不要用由子模块自己决定”。这三者各司其职,搞混了后面全是坑,所以我会在第二章优先讲清楚它们的边界。
2. 聚合、继承、依赖管理:三件套先摆到桌面上
2.1 聚合:让Maven知道“楼里有哪些房间”
聚合(Aggregation)解决的是构建入口问题。你可以在一个聚合POM里通过<modules>标签把所有子模块列出来,之后只要在聚合POM目录下执行mvn install,Maven就会按照模块间依赖关系自动算出构建顺序,逐个构建。
<groupId>com.demo</groupId> <artifactId>parent-project</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <modules> <module>common</module> <module>core</module> <module>web</module> </modules>注意这里<packaging>必须写成pom。凡是只有配置、不产出jar包的项目,打包方式都是pom,聚合POM和父POM都是这个规则。很多新手在这里踩的第一个坑就是把模块路径写错,<module>里填的是子模块的目录名,不是artifactId,更不是包名。
聚合的另一个特点是构建顺序自动推导。假如core依赖common,即使你把<module>顺序写成 core 在前、common 在后,Maven依然会先构建common再构建core。它读的是依赖关系,不是书写顺序。这个机制极大解放了双手,但前提是你的依赖关系图本身必须是干净无环的,我后面会专门讲循环依赖的问题。
2.2 继承:把公共部分搬到“物业层”
继承(Inheritance)解决的是配置复用问题。子模块只需要在自己的POM里声明<parent>,就能继承父POM里的groupId、version、依赖管理、插件管理、属性定义等一堆东西。
<parent> <groupId>com.demo</groupId> <artifactId>parent-project</artifactId> <version>1.0.0</version> <relativePath>../pom.xml</relativePath> </parent>relativePath默认是../pom.xml,也就是去上一级目录找父POM。大多数情况不写也能工作,但如果你需要从仓库里取父POM、或者目录层级比较特殊,显式写出来更稳。relativePath这个不起眼的标签,在多模块目录调整时经常是罪魁祸首——某个模块明明写着parent,构建时却报错找不到,十有八九是路径对不上。
继承的传递性需要特别注意:插件、属性、依赖管理会被子模块继承,但<dependencies>里直接声明的依赖也会被所有子模块继承。很多人把公共依赖一股脑写进父POM的<dependencies>,结果每个子模块被迫引入了根本不需要的jar,包体膨胀,依赖冲突的排查难度翻倍。这里的原则是:能用dependencyManagement管理的,就不要直接声明dependencies。两者的区别在下一节马上说。
2.3 dependencyManagement:版本的“中央定价权”
dependencyManagement是父子工程里最容易被误用、也最重要的一项配置。它不直接给项目添加依赖,而是统一管理依赖的版本号。子模块声明依赖时,可以不写<version>,由父POM统一指派。
拿我们系统里常见的做法来说:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.30</version> </dependency> </dependencies> </dependencyManagement>子模块里这样用就好:
<dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> </dependency> </dependencies>好处很直接:所有模块的同一个依赖版本只在一个地方维护,升级版本改一处就行。乱写版本号的现象(这直接导致排查了很久的依赖冲突)从这个机制开始被根治了。但留神一点:如果子模块声明时自己写了version,那么以子模块为准,父POM管不到它。所以团队里要有约定——在继承父POM的项目里,子模块声明依赖一律不写版本号,除非版本不同确实有硬理由。
3. 搭一个真实的父子多模块工程:从目录到可运行
3.1 目录骨架:按模块拆分,而不是按代码分层拆
很多新手把多模块理解成“一个模块放controller,一个模块放service,一个模块放mapper”,这其实是大忌。模块划分应该按业务边界或复用边界,而不是按技术层次。比如一个典型的后端工程可以拆成这样:
my-project/ ├── pom.xml # 父POM,packaging=pom ├── common/ # 通用工具、异常定义、基础DTO │ ├── pom.xml │ └── src/main/java/... ├── core/ # 核心业务逻辑,依赖common │ ├── pom.xml │ └── src/main/java/... └── web/ # Web入口,依赖core ├── pom.xml └── src/main/java/...每个模块不用很复杂。common只有依赖纯净的第三方库;core维护业务规则;web负责HTTP接口暴露和参数校验。这个结构的核心逻辑就是依赖单向流动:web → core → common。一旦出现 common 依赖 core 这种反向依赖,图就是环形,多模块的优势就变成了灾难。
3.2 子模块POM的常见形态
子模块的POM相对短小。它继承父POM以后,只需要声明自己的artifactId、自身特有的依赖,以及出jar需要的插件配置。
<project> <parent> <groupId>com.demo</groupId> <artifactId>my-project</artifactId> <version>1.0.0</version> </parent> <artifactId>core</artifactId> <dependencies> <dependency> <groupId>com.demo</groupId> <artifactId>common</artifactId> </dependency> </dependencies> </project>注意到了吗:common依赖没有写version,因为它的版本由父POM的dependencyManagement统一管理。而com.demo:common的版本号恰好就等于父POM的version,你可以在父POM里用属性把它显式写出来,也可以在dependencyManagement里直接声明com.demo:common的版本,二选一即可。我实际项目里更倾向于在dependencyManagement里把内部模块也列出来,这样所有模块的版本控制都在一个地方,看起来整齐,后面上CI(持续集成)时改版本也方便。
3.3 构建一下,看聚合怎么工作
在my-project/pom.xml所在目录执行:
mvn clean install你会看到Maven按依赖关系依次构建 common、core、web,中间的很多东西,比如编译、测试、打包,都会在输出里按模块名打印出来。构建成功后,每个模块的target目录下都会有对应的 jar 包或 war 包。
这里提一个很多人忽略的点:构建报错时,日志输出里的每个模块之间是有阶段关系的。如果你在 common 模块的测试阶段就挂了,后面的模块压根不会开始构建。所以多模块项目里的测试代码要特别自律,别在 common 里写那种依赖“整个应用环境”的集成测试,否则每次构建都会在这里卡住,CI也频繁红灯。我倾向于把纯单元测试放在模块里,把依赖外部服务的集成测试放到单独的测试工程去跑。
4. dependencyManagement 和 BOM:统一版本最实用的两种姿势
4.1 为什么不要直接在每个子模块里写死版本号
假设没有父POM,四个模块里各写了一份spring-context:5.3.30。某天要升级到6.1.3,你得改四处,漏掉一处就是版本漂移。这种问题在模块不多时还暴露不出来,等到了十几二十个模块的规模,光靠人工保证一致是不可能的。
所以 dependencyManagement 的价值不是“减少输入量”,而是“消除版本漂移”。它把所有版本决策收敛到一个文件里,让Maven在构建时替你做全局一致性检查。
4.2 用BOM统一第三方依赖组
Maven的dependencyManagement不仅管理内部模块,也可以引入别人发布的BOM(Bill of Materials)。比如Spring Boot的spring-boot-dependencies就是一个典型的BOM,它把生态里几百个依赖的版本都管好了。
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.7.18</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>关键的两个标签是<type>pom</type>和<scope>import</scope>。import的作用是把这个外部BOM里的dependencyManagement条目“展开”到当前POM里,就好像它们是当前POM自己写的一样。没有import的话,BOM只会被当作一个普普通通的依赖,完全达不到管理版本的效果。
我在维护老项目时常这么干:为了从Spring Boot 2.7过渡到3.x,先把spring-boot-dependencies换成2.7对应版本,再处理Java 17的兼容问题,最后整体升BOM版本。BOM版本一换,全项目绝大多数依赖版本自动切换,这比逐模块改POM高效得多。
4.3 内部BOM:让没有父子关系的项目也能共享版本
有时两个团队维护的是完全独立的多模块工程,既不想互相继承,又希望版本策略一致。可以把公共依赖单独做成一个“BOM工程”,打包类型为pom,里面只放dependencyManagement。其他工程通过import把它引进来。这个模式在大型组织里很常见:没有血缘关系,但有共同的版本契约。
不过要提醒一句:BOM只是约定版本,不能保证依赖一定正确。如果项目里直接引入了某个BOM没有覆盖的传递依赖,还是会产生冲突。所以BOM覆盖范围要尽量全面,至少把团队常用的主流依赖都列进去。
5. settings.xml:决定你“拉得到包”和“发得出去”的背后开关
很多人在本地写好pom,构建时却卡在下载依赖——要么超时,要么404,要么下载的私有依赖始终找不到。这些问题的根源往往不在POM,而在settings.xml。
5.1 两个settings.xml:用户级和全局级
Maven有两处settings.xml。全局的在Maven安装目录conf/settings.xml,用户级的在~/.m2/settings.xml。用户级会覆盖全局级,我建议所有自定义配置都放用户级,避免污染整个Maven安装。
注意一个操作细节:修改settings.xml后,不需要重启IDE就能生效,但要保证IDE的Maven配置里“User settings file”指向的是这个文件。很多人修改了配置却点了“重新导入”,发现没变化,就是因为IDE里指定的settings.xml路径是另一份。
5.2 mirror与repository:别被镜像绕晕
先从词源说起。Maven构建时要去中央仓库(repo.maven.apache.org)下载大部分依赖,而国内访问中央仓库经常慢得让人抓狂。解决办法是配置镜像——把中央仓库的请求完全转发到国内镜像站,这就是<mirror>的作用。
<mirrors> <mirror> <id>aliyun</id> <mirrorOf>central</mirrorOf> <url>https://maven.aliyun.com/repository/public</url> </mirror> </mirrors>mirrorOf这里的写法很讲究。central表示只镜像中央仓库;写*表示镜像所有仓库;写central,!internal则表示除了internal仓库外都镜像。不少初级开发者图省事写*,结果私有仓库的请求也被拦截转发到公网镜像,私有jar铁定下载不到。我在新环境里必做的第一件事,就是确认mirrorOf没有把私有仓库“误伤”。
还有一点:镜像不是仓库。镜像的本质是“流量转交”,而仓库<repository>是真正去加载依赖的来源。两者并存时,Maven对同一个仓库可能先走镜像,私有仓库的访问路径可能会在日志里显示为内部地址,这容易在排查问题时造成一种假象。
5.3 server认证:发私有包时最容易被忽略的配置
如果你要执行mvn deploy把构建产物上传到私有仓库,光在POM里声明distributionManagement还不够。如果私有仓库需要认证,你必须在settings.xml里为仓库id配置对应的<server>块:
<servers> <server> <id>my-snapshots</id> <username>deploy-user</username> <password>${env.DEPLOY_PASSWORD}</password> </server> </servers>这里的<id>必须和POM里distributionManagement的仓库id完全一致,否则Maven找不到对应凭据,deploy会以匿名身份尝试,结果大概率是401。另外,不要在settings.xml里硬编码生产密码,可以用${env.XXX}这种环境变量占位,在CI里单独注入,本地开发时再把真实值填入本地settings。这也是降低密钥泄露风险的习惯做法。
5.4 多环境profile激活
settings.xml里也能定义<profile>,它的作用范围比POM里的profile更底层,常用于激活不同环境的仓库。比如开发环境用snapshot仓库,生产环境只用release仓库:
<profiles> <profile> <id>dev</id> <repositories> <repository> <id>central</id> <url>https://repo.maven.apache.org/maven2</url> </repository> <repository> <id>my-snapshots</id> <url>http://your-nexus/repository/snapshots/</url> <snapshots> <enabled>true</enabled> </snapshots> </repository> </repositories> <activation> <activeByDefault>true</activeByDefault> </activation> </profile> </profiles>激活方式有:按JDK版本激活、按系统属性激活、按环境变量激活、按文件存在激活,最常见的是activeByDefault。但我一般不推荐默认激活dev,这样会导致生产构建时还要特地去排除。更稳妥的是在命令行或CI里显式指定-Pprod,这样“默认不激活特定环境”反而更安全。
6. 私有仓库:从“下载别人包的人”变成“发布包给人用的人”
单模块开发你只需要下载公开依赖,永远没机会了解仓库的另一半——发布。多模块工程做得大了,内部模块之间、团队与团队之间的jar共享都依赖私有仓库。
6.1 先看懂仓库的目录结构
去你的本地仓库(通常是~/.m2/repository)看一眼,你会看到一堆按目录层级存放的目录。以org/springframework/spring-context/5.3.30/spring-context-5.3.30.jar为例,路径规律是:groupId的点被改成目录斜杠,artifactId作为一层目录,version再作为一层,最后是文件名。这个规律很基础,但在排查“明明deploy成功了却拉不下来”的问题时特别有用。
私有仓库内部也是同样的结构,你可以打开Nexus的界面去看看某个jar的坐标,界面里显示的路径和本地仓库的布局完全一致。如果能把“坐标映射成路径”这个直觉建立起来,很多依赖问题你会直接在文件层面就排查出来了。
6.2 distributionManagement:把deploy的目标配置好
在需要发布的模块POM里配置:
<distributionManagement> <repository> <id>my-releases</id> <name>internal releases</name> <url>http://your-nexus/repository/maven-releases/</url> </repository> <snapshotRepository> <id>my-snapshots</id> <name>internal snapshots</name> <url>http://your-nexus/repository/maven-snapshots/</url> </snapshotRepository> </distributionManagement>suffix和带不带SNAPSHOT都影响最终地址:
- 发布版(版本号不带
-SNAPSHOT)走repository指定的仓库; - 快照版(版本号带
-SNAPSHOT)走snapshotRepository。
写错的情况下,产物会被传到错误的库,或者因为release仓库拒绝覆盖同名版本而报错。另外要提醒的是,release仓库通常会配置“不允许覆盖已发布版本”,好处是构建可复现,坏处是你发布了一个坏版本后必须升版本号。想反悔,就得去Nexus手动删除组件,这不是Maven的范畴了。
6.3 Snapshot有什么特殊之处
快照版本号的语义是“我可能每天都会变”。Maven默认情况下,构建时会检查本地快照和远端快照是否一致,不一致就重新拉取。这样开发期你可以一直依赖1.0.0-SNAPSHOT,每次构建自动获取最新。但注意:如果要发布对外稳定版本,一定换成release版本号,否则某次快照被清理,消费者就再也拉不到了。
这里有个经常踩的坑:如果快照依赖在本地仓库已经存在,Maven默认的更新策略是“每天检查一次”,不会每次构建都重新拉取。在开发中对某个快照jar的改动,别人本地构建没有更新引用时报出诡异错误,往往就是这个原因。解决方案是mvn clean install -U,-U强制更新快照。养成“怀疑快照不新就先加-U”的习惯,能省下大量排查时间。
7. 构建提速与条件控制:用命令行技巧告别“全量等待”
多模块项目越大,全量构建越慢。掌握几个命令行参数能极大提升开发效率。
7.1 最常用的构建参数对照
| 命令参数 | 作用 | 示例 |
|---|---|---|
-pl | 只构建指定模块,模块间按artifactId选择 | mvn install -pl web -am |
-am | 同时构建依赖模块(make) | mvn install -pl web -am |
-amd | 同时构建依赖该模块的模块 | mvn install -pl common -amd |
-T | 并行构建,可指定线程数 | mvn install -T 4 |
-DskipTests | 跳过测试编译后的测试执行 | mvn install -DskipTests |
-Dmaven.test.skip=true | 跳过测试编译和执行 | mvn install -Dmaven.test.skip=true |
-U | 强制更新snapshot版本 | mvn install -U |
-o | 离线模式,只用本地仓库 | mvn install -o |
-pl和-am的组合是我最常用的开发姿势:只改web模块时,mvn install -pl web -am会自动把web依赖的core和common也带上重新构建,不会全量编译所有无关模块,省下来的时间非常可观。注意参数的顺序和拼写:-pl 模块名用的是模块的artifactId,而不是目录名。
-Dmaven.test.skip=true和-DskipTests很多人不清楚区别。前者直接跳过测试代码的编译,后者只是不执行测试但会编译测试类。确实有场景需要保留测试编译,比如你想确认测试代码语法没问题但不想跑,就用-DskipTests;如果只是想快速构建发布,就用-Dmaven.test.skip=true。
7.2 用profile做多环境打包
在POM里定义profile是按环境打包的关键:
<profiles> <profile> <id>prod</id> <properties> <env.name>prod</env.name> </properties> <build> <resources> <resource> <directory>src/main/resources/${env.name}</directory> </resource> </resources> </build> </profile> </profiles>配合资源目录的设计,你可以在src/main/resources/prod、src/main/resources/dev下放不同环境的配置文件。打包时用mvn clean package -Pprod就能选中对应配置目录。不要再干“手动改配置文件再去打包”的原始操作了,既容易漏改,也不好追溯。
激活方式可以叠加:-Pprod,test允许同时激活多个profile,但如果两个profile设置了同一个属性,后面者覆盖前面者,这个顺序规则偶尔会坑人。配置profile时应该让属性互斥,或者尽量只激活一个环境profile。
8. 长途奔袭后的复盘:这些坑值得再啰嗦一遍
8.1 常见问题排查速查表
| 现象 | 可能原因 | 排查路径 |
|---|---|---|
| 父POM声明了版本,子模块报“找不到依赖” | 子模块没继承父POM或parent坐标写错 | 看idea里Maven面板是否有红色parent |
| 新增模块后聚合构建不包含它 | 没在<modules>里注册,或路径写错 | 改<module>目录名,重新导入 |
| 本地依赖明明有,却一直下载失败 | 镜像把私有仓库请求也镜像走了 | 检查mirrorOf是否写成了* |
| deploy报401 | settings.xml的server凭据缺失或id不匹配 | 核对仓库id与server id是否一致 |
| 快照依赖被缓存,永远不更新 | 默认更新策略是每日 | 构建加-U |
| 某个包出现两个版本,行为怪异的 | 传递依赖冲突 | 用dependency:tree查看依赖树 |
8.2 习惯一:版本号一定走统一入口
在我的团队里约定是:父POM用<properties>存放所有版本属性,dependencyManagement里只引用这些属性,子模块不允许写版本号。扫码查一查,基本不会出现两个模块里俩版本的情况。定期全局搜索一下“<version>”出现在子模块POM里的情况,发现一次就整改一次,这种纪律带来的收益要比技术方案本身大得多。
8.3 习惯二:遇到诡异问题先查依赖树
mvn dependency:tree是Maven最实用的诊断工具之一。它能打印当前模块的完整依赖树,让你一眼看到某个groupId出现了几次、版本分别是多少。排查依赖冲突时,我习惯把完整输出重定向到文件里,然后用IDE的搜索功能查找可疑依赖。不要靠肉眼在控制台里翻,几屏之后就头晕了。
8.4 习惯三:把构建拆到最少,能省时间就不浪费
改了一个模块,不要习惯性地mvn clean install全量构建。多模块项目规模大了以后,一次全量构建可能耗时几分钟,而-pl web -am可能只要几十秒。这个习惯看着很小,但日积月累,对开发效率的提升非常明显。另外,本地调试尽量用-o离线模式,强制所有依赖走本地仓库,只要依赖没变,回台北就会很快。
8.5 习惯四:私有仓库里快照要“舍得放、也要舍得清”
开发和迭代阶段,频繁发快照、频繁拉快照都是正常的。但一旦进入发布冻结期,要尽快把依赖从-SNAPSHOT切到固定版本。我曾经见过因为某个模块忘了切版本号,导致交付联调时拉到的jar包一直是几周前的旧逻辑,浪费了整整两天。规范起来之后,版本切换几乎不再出错。发布清单里列清楚“哪些模块这次要发release”,逐项核对,比靠记忆靠谱。
8.6 习惯五:新环境第一件事,跑一个“热身构建”
拿到一台新电脑、新开发机,我会先执行一趟某个单模块的完整构建(比如mvn clean verify -pl common -am),确认JDK版本、Maven版本、settings.xml路径、镜像可用性、本地仓库权限全都没问题。如果这个最小闭环跑不通过,后面所有工程都跑不动。很多“环境问题”不是代码问题,而是在错误的地方调了很久,最后发现是Maven版本和JDK版本不匹配。
说回口诀:多模块不是“代码放一起”,而是“依赖有向、版本统一、构建有序、仓库可控”。把这四个维度真正落到工程实践里,Maven才算真的“精通”了。这篇是系列第三篇,每一篇讲的都是同一根主线上的不同节点——第一根是基础,第二根是单项目实战,这一根是多模块工程化。下一根我会挑几个更具体的专题细讲,比如自定义插件开发、archetype生成项目模板、Maven与CI流水线的深度结合。那部分的坑比多模块还花样百出,但如果能把这一篇掌握好,到时候基本就能摸到门路了。