1. 从一个让人头大的 POM 文件说起
如果你接触过 Maven 项目,大概率见过这种配置:pom.xml 里堆着一堆<properties>、<dependencyManagement>、<dependencies>、<build>、<plugins>标签,刚上手时真的分不清谁是谁。更头疼的是,很多开源项目的 POM 写得极其复杂,光 plugins 就列了十几个,每个插件还带一堆 configuration,看着就劝退。
今天这篇就把这些标签一次性讲透,包括它们各自管什么、相互之间是什么关系、哪些坑是新手必踩的、实际项目里该怎么组合使用。不管你是刚学 Spring Boot 的小白,还是已经在写多模块项目的进阶选手,这篇都值得看完。
先说结论:这五个标签本质上解决了三个问题——变量怎么定义、依赖怎么管、构建怎么做。properties是变量定义区,dependencyManagement和dependencies管依赖的声明与版本控制,build和plugins则是构建生命周期的定制入口。下面逐个拆,但先别急着背概念,我先带你建立一张全局地图,搞清楚它们在一个项目里各自的位置。
2. 先理清这五个标签在 POM 里的角色定位
2.1 一个典型 POM 的骨架长什么样
先看一个最常见的单模块 Spring Boot 项目。你打开 pom.xml,通常会看到这样的结构,从上到下依次是:项目坐标(groupId、artifactId、version)、parent 继承信息、properties、dependencyManagement、dependencies、build——如果你打开过一些开源框架的源码,还会看到这六个部分前后顺序基本固定,因为 Maven 对 POM 的解析有严格的模型约定。
<project> <groupId>com.example</groupId> <artifactId>demo</artifactId> <version>1.0.0</version> <parent>...</parent> <properties> <java.version>17</java.version> </properties> <dependencyManagement> <dependencies>...</dependencies> </dependencyManagement> <dependencies> <dependency>...</dependency> </dependencies> <build> <plugins>...</plugins> </build> </project>看到没有,dependencyManagement里面套的也是<dependencies>标签,这就让很多人第一次看时直接懵了——怎么同一个标签名套了两层?这就是典型的"不认识 XML 树结构导致的理解混乱"。实际上两者语义完全不同:外层dependencies是"当前项目真正引入的依赖",内层dependencyManagement里的dependencies只是"版本锁定清单",不直接引入任何东西。
2.2 一个生活化的类比:它们就像做饭的流程
我习惯把这五个标签类比成做一道菜:
properties是菜谱顶部的"用料清单备注",比如"酱油用海天的""盐用低钠的"——先把用的品牌、型号定下来,后面所有地方引用同一个名称就行。dependencyManagement是**"供应商价格表"**,它告诉你哪些食材可以从哪里买、什么规格,但你没下单之前,这些东西不会出现在你的冰箱里。dependencies才是真正下单的购物车,你在购物车里加了菜,菜才会到你手里。build是厨房的烹饪流程设置,比如用蒸的还是煮的、最后装盘要不要撒葱花。plugins是厨房里的各种工具——蒸锅、烤箱、料理机,每个工具干一件事,build负责告诉你在哪个环节用哪个工具。
这个类比先记在心里,后面每个标签的细节就是往这个框架里填内容。
2.3 为什么把这些标签搞明白很重要
直接说后果。如果你搞不清dependencyManagement和dependencies的区别,结局通常是两种情况中的一种:要么在父 POM 的dependencyManagement里写了依赖,子模块却到处报 ClassNotFoundException,因为根本没引入;要么所有依赖都写 version,几十个子模块里版本号散落一地,升级一次恨不得全局搜索替换。
如果你搞不清build和plugins的关系,最常见的问题就是打包出来的 jar 不是可执行 jar,Spring Boot 项目 java -jar 直接报"no main manifest attribute"。本质就是少了 spring-boot-maven-plugin 这个插件,而它必须配置在 build/plugins 里,而不是随便放在别的位置。
还有一个非常实际的经验:当你去搜索某个报错时,比如 "failed to load plugins" 或 "cannot read properties of undefined",很多问题根本不是 Maven 配置写错了,而是插件版本不兼容或者构建顺序不对。比如热词里那些 grok build、pnpm approve-builds 报错,本质上都是构建工具链里插件管理出了问题——只是技术栈不同,底层逻辑完全一致。把这些 Maven 标签理解透,换到 npm、Gradle、pnpm 时你也能触类旁通。
3. properties:POM 里的全局变量区,到底能省多少事
3.1 properties 不只是"版本号仓库"
大部分人用<properties>最常见的场景就是定义版本号:
<properties> <java.version>17</java.version> <spring-boot.version>2.7.18</spring-boot.version> <lombok.version>1.18.30</lombok.version> </properties>然后在 dependencies 里通过${spring-boot.version}引用。这是最基础的用法,但它的能力远不止于此。properties本质是 Maven 模型的属性占位符机制,你在<properties>里定义任意键值对,整个 POM 中任何地方都可以用${key}引用。比如:
<properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <skipTests>true</skipTests> </properties>maven.compiler.source和maven.compiler.target这两个属性是 Maven 编译器插件读取的标准属性,定义了 Java 编译版本。project.build.sourceEncoding是 Maven 构建的标准属性,控制源码文件编码。这些属性只要定义在<properties>里,相关的插件会自动读取,不需要你再单独写插件配置。
你甚至可以定义自己的业务属性,比如:
<properties> <custom.api.url>https://api.example.com</custom.api.url> </properties>然后通过 Maven 的 resource filtering 机制,在application.properties里用${custom.api.url}引用,打包时自动替换成真实地址。这个技巧在做多环境打包时非常有用,比手改配置文件优雅太多。
3.2 properties 的继承特性与优先级
properties是支持继承的。子模块的 POM 如果没有显式定义某个属性,会从父 POM 中读取。这意味着你可以把整个项目统一的版本号、编码、编译级别全放在父 POM 的 properties 里,子模块直接继承,不用重复写。而且子模块可以覆盖父 POM 的同名属性——这个特性偶尔会被误用,比如子模块覆盖了java.version,但编译插件读到的可能是父 POM 的值,导致编译版本和预期不一致,排查很久才发现。
所以我的建议是:父 POM 的 properties 里只放全局统一、子模块不应该改的配置,比如编码、Java 版本、所有模块共用的依赖版本。真正需要个性化的配置,放到子模块自己的 properties 里,并且尽量用不同的属性名,避免"继承覆盖"引发的隐性坑。
3.3 properties 与热词搜索中那些"properties"的关系
你可能注意到热搜词里有 "cannot read properties getusermedia"、"cannot read properties of undefined"。这是前端 JavaScript 的报错,和 Maven 的<properties>毫无关系——一个是 XML 里的属性声明机制,一个是 JS 运行时访问对象属性报错。这里提一下是为了防止你搜索时被带偏。热词搜索本质上是一种"噪音过滤"能力:技术关键词一样,但语言、框架、上下文完全不同,必须结合报错前缀来判断属于哪一套体系。比如带 "reading" 和 "undefined" 的,基本都是 JavaScript 运行时错误;带 "Maven" 或 "pom.xml" 的,才是构建配置问题。
4. dependencyManagement:版本锁定的"中央空调"
4.1 dependencyManagement 和 dependencies 最核心的区别
直接把这两个标签的区别列个表,这是本篇最重要的一张表:
| 对比项 | dependencyManagement | dependencies |
|---|---|---|
| 是否直接引入依赖 | 否,只声明版本和管理策略 | 是,声明后立即加入 ClassPath |
| 子模块是否继承 | 是,所有子模块自动继承版本管理 | 否,子模块需要自己声明才会引入 |
| 是否允许不写 version | 可以,由父 POM 统一管理 | 可以,但必须依赖父 POM 的 dependencyManagement 已锁定版本 |
| 适合场景 | 多模块统一版本 | 实际引入依赖 |
| 单独使用结果 | 项目不会新增任何 jar | 项目会新增 jar,但版本可能乱 |
记住一句话:dependencyManagement 管"版本号",dependencies 管"依赖本身"。
举个例子,父 POM 中:
<dependencyManagement> <dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> </dependencies> </dependencyManagement>父 POM 自己不会引入 jackson-databind。但子模块 POM 中写:
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies>注意:子模块里这个依赖没有写 version,却能正常解析到 2.15.2,因为父 POM 的 dependencyManagement 已经锁定了版本。这就是整个多模块项目版本统一的核心机制。
4.2 为什么不推荐在子模块里写 version
很多从单体项目转多模块的开发者,习惯在子模块的依赖里手写 version,结果就是版本散落。假设你有 10 个子模块都引了 Jackson,但有的写 2.14.0、有的写 2.15.2,一旦升级 Jackson,你得挨个改 10 个文件,稍微漏一个就是线上版本冲突。
使用 dependencyManagement 后,升级只需改父 POM 一处,所有子模块同步。而且子模块里不写 version,代码审查时也能一眼看出这个版本是父 POM 统一管理的,而不是某个子模块自己拍脑袋定的。
4.3 import 作用域:把 BOM 引入 dependencyManagement
dependencyManagement里还支持一种特殊作用域——import。这个在 Spring Boot 项目中极其常见:
<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>这个配置的意思是把spring-boot-dependencies这个 BOM(Bill of Materials,物料清单)里定义的所有依赖版本全部导入到当前项目的依赖管理中。这样你就可以在 dependencies 里放心写 Spring Boot 相关依赖而不写 version,版本由 BOM 统一控制。这比自己手动一个个列 Spring 依赖版本要靠谱得多,因为 BOM 是官方整理好的完整版本矩阵,保证兼容性。
4.4 一个容易忽略的细节:dependencyManagement 里的 exclusions
很多人不知道,dependencyManagement里的依赖声明不仅可以锁版本,还可以声明统一的依赖排除。比如你项目里统一不使用某个传递依赖,可以在 dependencyManagement 里预声明排除规则:
<dependencyManagement> <dependencies> <dependency> <groupId>commons-logging</groupId> <artifactId>commons-logging</artifactId> <version>1.2</version> <exclusions> <exclusion> <groupId>*</groupId> <artifactId>*</artifactId> </exclusion> </exclusions> </dependency> </dependencies> </dependencyManagement>子模块引入 commons-logging 时,排除规则会自动生效。不过这个用法实际项目中比较少见,因为管理面太大,容易误伤,了解即可,不建议滥用。
5. dependencies:真正把依赖拉进项目的"购物车"
5.1 坐标、作用域与传递依赖
dependencies里的每个 dependency 必须有 groupId、artifactId,version 可写可不写(取决于父 POM 是否已经管控)。此外还有一个关键属性是 scope,它决定依赖在哪些阶段生效:
| scope | 生效阶段 | 典型场景 |
|---|---|---|
| compile(默认) | 编译、测试、运行、打包全流程 | 大多数业务依赖 |
| provided | 编译、测试 | servlet-api、lombok,容器或 JDK 在运行时已提供 |
| runtime | 运行、打包 | JDBC 驱动 |
| test | 测试编译、测试运行 | JUnit、Mockito |
| system | 编译 | 本机 jar 包,一般不推荐 |
这个 scope 极其重要。我见过很多人把 lombok 配成 compile(默认),或者把 mysql-connector-java 配成 provided,导致打包后运行报 ClassNotFoundException。实际经验是:lombok 在 Maven 中应该配 provided,因为编译后它不需要跟着走;JDBC 驱动一般用 runtime,因为编译时不需要直接引用它的 API。
5.2 依赖冲突:Maven 的"就近原则"和排除手段
当多个依赖传递了同一个库的不同版本时,Maven 遵循最短路径优先原则——离当前项目路径最短的那个版本生效。如果路径相等,先声明者优先。
这个规则平时不会觉得有啥,但一出问题就让你怀疑人生。比如 A 依赖了 Guava 31.1,B 传递依赖了 Guava 30.0,而项目里直接用到了 Guava 31.1 才有的 API,如果 B 的传递链更短导致 30.0 先生效,编译期你能正常过,运行期直接 NoSuchMethodError。
排查依赖冲突的手段有两个:
- 执行
mvn dependency:tree查看完整依赖树,分析版本冲突路径。 - 使用
exclusions排除不需要的传递依赖:
<dependency> <groupId>com.example</groupId> <artifactId>some-lib</artifactId> <version>1.0.0</version> <exclusions> <exclusion> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> </exclusion> </exclusions> </dependency>这是我在实际项目中排查"编译正常、运行报错"类问题最常用的两板斧。
5.3 单模块项目:dependencies 的"省心"写法
如果是单模块项目,且没有父 POM 管控,那么你可以在 dependencies 里直接写全 version,这没问题——反正整个项目只有一个模块,没有统一版本的需求。但如果你未来打算拆分成多模块,建议提前引入 dependencyManagement 结构,哪怕暂时只有一个模块,也能让你后续拆分时少改很多配置。
6. build 与 plugins:定制你的 Maven 构建流水线
6.1 build 标签到底管什么
<build>是 Maven 构建配置的根容器,它负责回答三个问题:构建产物叫什么、资源文件从哪来、构建时执行哪些插件。常见的子标签有以下几种:
<build> <finalName>my-app</finalName> <sourceDirectory>src/main/java</sourceDirectory> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> </resource> </resources> <plugins>...</plugins> </build>finalName:指定构建产物的文件名,比如打成 jar 后叫 my-app.jar 而不是 my-app-1.0.0.jar。sourceDirectory:指定源码目录,默认是 src/main/java,一般不用改。resources:指定资源目录,默认是 src/main/resources。filtering设为 true 表示允许对资源文件进行占位符替换,也就是前面说的 properties 里定义变量后,在资源文件中用${...}引用,打包时自动替换。plugins:构建插件的集合。
还有一个容易被忽略但很实用的配置:<testResources>,指定测试资源目录,默认是 src/test/resources。如果你有测试专用的配置文件,不希望打进生产包,可以在这里单独控制。
6.2 plugins 是 build 的执行单元
没有插件的 Maven 其实什么也做不了。Maven 本身只提供一个生命周期框架,compile、test、package、install 这些阶段的具体行为全部由插件完成。所以,<plugins>里配的就是"在某个生命周期阶段执行什么任务"。最常用的三个插件:
第一个是 maven-compiler-plugin。它负责 Java 编译。设置 Java 版本最直接的方式就是前面提到的 properties 中的maven.compiler.source和maven.compiler.target。但有些项目会发现设置了这两个属性后编译还是用的默认 JDK 版本,这是因为 IDEA 的编译设置或 Maven 默认编译器版本覆盖了它。更稳妥的做法是在插件里显式配置:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>17</source> <target>17</target> <encoding>UTF-8</encoding> </configuration> </plugin>第二个是 spring-boot-maven-plugin。如果你用 Spring Boot,这个插件几乎是必须的。它的核心功能是 repackage:把项目打成一个可执行 fat jar,里面包含所有依赖和启动脚本。不在 build/plugins 里配这个插件,mvn package打出来的 jar 只是一个普通的 jar,无法通过java -jar启动。热词里那个 "no main manifest attribute" 报错几乎都是这个原因。
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>2.7.18</version> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin>注意 executions 里的 goal 配置。repackage这个 goal 默认绑定在 package 阶段,如果你不写 executions,插件确实也会执行默认行为。但显式写出来更清晰,而且有些版本如果不显式配置执行阶段,打包后 jar 还是不能运行,我踩过这个坑。
第三个是 maven-surefire-plugin。它负责运行单元测试。默认会用 JUnit 4 或 JUnit 5 自动匹配测试类。你可以在配置里指定跳过测试:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.1.2</version> <configuration> <skipTests>true</skipTests> </configuration> </plugin>但这只是"跳过执行",测试代码依然会编译。如果你想连测试代码一起跳过编译,用maven.test.skip=true。两者的区别很微妙,但对构建速度影响很大,CI 环境里如果不需要跑测试,用后者的构建时间能省不少。
6.3 pluginManagement 和 plugins 的区别
这就跟 dependencyManagement 和 dependencies 的关系一模一样。pluginManagement是在父 POM 中统一管理插件版本和配置,子模块声明使用插件时可以不写版本。plugins才是真正在构建中启用插件的地方。
举个例子,父 POM:
<build> <pluginManagement> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> </plugin> </plugins> </pluginManagement> </build>子模块要使用这个插件,只需:
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> </plugin> </plugins> </build>插件版本自动从父 POM 的 pluginManagement 中解析。我强烈建议多模块项目把插件版本集中在 pluginManagement 中管理,跟依赖版本集中在 dependencyManagement 中是同一个道理——少改配置、避免各子模块插件版本不一致导致的诡异构建问题。
6.4 一个实际的 build 配置示例
这里给一个我项目里常用的多模块父 POM 的 build 段(精简版):
<build> <finalName>${project.artifactId}</finalName> <pluginManagement> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>2.7.18</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>17</source> <target>17</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> </pluginManagement> </build>子模块中如果某个模块是可启动的应用,就在自己的 build/plugins 里加 spring-boot-maven-plugin 并执行 repackage;普通的 jar 模块不需要加,打包成普通 jar 即可。这样既保证了模块职责清晰,又避免了所有模块都打出 fat jar 的低效问题。
7. 五者联动:一个多模块项目的完整 POM 解读
7.1 父 POM 的完整配置
把前面讲的五块拼起来,看一个典型的多模块项目父 POM。这才是全文最关键的一节,建议你拿出自己的项目对照着看。
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>my-project-parent</artifactId> <version>1.0.0</version> <packaging>pom</packaging> <modules> <module>common</module> <module>service</module> <module>web</module> </modules> <properties> <java.version>17</java.version> <spring-boot.version>2.7.18</spring-boot.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <lombok.version>1.18.30</lombok.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>${spring-boot.version}</version> <type>pom</type> <scope>import</scope> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>${lombok.version}</version> </dependency> </dependencies> </dependencyManagement> <build> <pluginManagement> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <source>${java.version}</source> <target>${java.version}</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> </pluginManagement> </build> </project>注意这个文件里 module 的列表是 common、service、web,但没有在父 POM 的 dependencies 里引入任何依赖,所以父 POM 本身是不携带任何 jar 的,它只负责版本管控和构建配置下发。这个"只管控、不引入"的原则,是父 POM 设计的核心,也是区分你懂不懂多模块依赖管理的关键。
7.2 子模块 POM 的两种典型写法
普通依赖模块(common):
<project> <parent> <groupId>com.example</groupId> <artifactId>my-project-parent</artifactId> <version>1.0.0</version> </parent> <artifactId>common</artifactId> <dependencies> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> </dependencies> </project>这个模块里两个依赖都没写 version,版本从父 POM 的 dependencyManagement 里继承。lombok 配了 provided,因为它只在编译期需要。
可启动应用模块(web):
<project> <parent> <groupId>com.example</groupId> <artifactId>my-project-parent</artifactId> <version>1.0.0</version> </parent> <artifactId>web</artifactId> <dependencies> <dependency> <groupId>com.example</groupId> <artifactId>common</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>web 模块依赖 common 模块,版本写 1.0.0——这时 version 必须写,因为这是模块间依赖,不属于 dependencyManagement 管理的第三方依赖版本。构建时通过 spring-boot-maven-plugin 打出可执行 jar。
7.3 从这套组合中学到的核心经验
把上面的父 POM 和两个子模块 POM 通读一遍,你会发现一个规律:父 POM 只做三件事——定义变量、统一版本、统管构建插件;子模块只做两件事——声明自己需要的依赖、声明自己需要的插件。各司其职,互不越界。这就是 Maven 多模块项目最佳实践的核心思路。
如果你打开一个结构理想的多模块开源项目,会发现它的 POM 分层就是这么清晰。反观很多"能跑但很痛苦"的项目,问题几乎都出在越界——要么子模块里写满了 version,要么父 POM 里引入了实际依赖,要么 build 配置五花八门、每个模块各写一套编译器参数。
8. 常见问题与排查技巧实录
8.1 报错速查表
平时在社区里看到的各种 Maven 相关问题,大多都能归到这次讲的五类标签上。我整理了一个高频问题速查表:
| 报错或现象 | 大概率原因 | 解决方向 |
|---|---|---|
| 子模块依赖找不到版本 | dependencyManagement 没配或没继承 | 检查父 POM 的 dependencyManagement 和子模块的 parent 声明 |
| 父 POM 里写了依赖但子模块用不了 | dependencies 和 dependencyManagement 混用 | 把版本管理挪到 dependencyManagement,子模块自己声明 dependencies |
| 打包后 java -jar 报 no main manifest attribute | 缺少 spring-boot-maven-plugin | 在可启动模块 build/plugins 加 spring-boot-maven-plugin 并配置 repackage |
| 编译版本不对(用了 JDK 17 却编译成 8) | properties 或 compiler plugin 没配置对 | 检查 maven.compiler.source/target 和 maven-compiler-plugin 的 configuration |
| 运行期 NoSuchMethodError | 依赖版本冲突 | 用 mvn dependency:tree 排查,用 exclusions 排除多余版本 |
| 资源文件中变量没替换 | resources 的 filtering 没开 | build/resources/resource 设置 filtering=true |
| 子模块插件版本不一致 | 插件版本散落在各个子模块 | 把插件版本集中到父 POM 的 pluginManagement |
8.2 实战中的三个"血泪"经验
经验一:永远不要在子模块的 dependencies 里写 version。这听起来有点绝对,但在多模块项目里这是铁律。我有一次接手一个老项目,20 多个子模块里手写了几十个版本号,特别是 Jackson 和 Guava,同一个库在三个模块里是三个版本。排查一个序列化问题花了整整两天,最后发现是子模块版本不一致导致的。把版本全部收到父 POM 的 dependencyManagement 后,整个世界清净了。
经验二:改动父 POM 后,要 clean 再 install。很多人改了父 POM 的 dependencyManagement 或 pluginManagement,然后在子模块里 mvn compile,结果发现还是旧版本生效。这是因为父 POM 没有重新安装到本地仓库,子模块用的还是旧的父 POM。改了父 POM 后,先在父目录执行mvn clean install -N(-N 表示只构建父 POM,不构建子模块),再构建子模块,这个问题就消失了。
经验三:配置了 properties 但打包没生效,先检查 filtering。我见过很多次这种操作:在 properties 里定义了环境变量,也在资源文件里写了${env.name},打包后却原样输出。原因就是 resources 的 filtering 没开。这个属性默认是 false,不手动打开就不会做占位符替换。另外需要注意,filtering 开启后会影响构建速度,因为 Maven 会逐个文件扫描替换,如果资源文件很大,建议只对需要替换的文件单独开 filtering。
8.3 与前后端构建工具横向对比
既然热词里出现了 pnpm、Gradle 这些构建工具的报错,顺便说一句:构建工具的管理思路是相通的。pnpm 里的pnpm approve-builds对应的是"信任某些依赖的安装后脚本",本质上就是插件/脚本执行的安全策略;Gradle 里的dependencies配置块和 Maven 的 dependencies 表达的是同一个概念,只是写法不同;npm 里的 devDependencies 对应 Maven 的 provided/test scope。理解了 Maven 这套"声明-管理-执行"的模型,换工具只是换个语法的事。
8.4 热词里那些报错,为什么会和 Maven 扯上关系
最后聊一个现象:你搜索 "failed to load plugins" 时,结果里既有 Maven 的插件加载报错,也有 VS Code 扩展加载失败,还有前端 webpack 的插件加载问题。关键词一样,体系完全不同。技术排错的第一原则永远是先确认你处在哪个上下文——是 Java 构建、前端打包、还是 IDE 运行时。不要被搜索结果的前几条带走,结合你的工具链和报错前后文判断。这也是为什么我前面反复强调标签要放到"全局地图"里理解:同一个名词在不同的地图里含义可能完全不同,你只有先建立起自己的项目地图,才能快速定位到正确的问题域。
回到本文开头的问题——properties、dependencyManagement、dependencies、build、plugins 的作用和区别。如果你能用自己的话解释清楚"dependencyManagement 管版本,dependencies 管引入,properties 管变量,build 管流程,plugins 管工具",并且能在父 POM 和子模块之间正确分配这些标签的职责,那么这篇就没有白看。剩下的就是在实际项目里多写、多试、多踩坑,配置这种东西,光看不练是记不住的。