为什么 2025 年了你还是绕不开 Maven?
先问你一个直球问题:
你有多少次,在 IDEA 里点了一下绿色小三角,应用就跑起来了,
但从来没认真想过:这一切到底是谁在背后"打工"?
再问三个更扎心一点的:
-
你知道项目里的这些 jar 包是 从哪儿来的 吗?
-
你知道 Jenkins / GitHub Actions 里那条
mvn clean package -DskipTests,每个单词都在干嘛吗? -
你有没有遇到过"我改了一个依赖版本,结果线上全挂了,但我也说不清楚为啥"的场景?
如果你写的是 Java / Spring Boot,答案八成和一个东西有关:
Maven。
很多人对 Maven 的印象还停留在:
-
"那不就是 IDEA 自动帮我生成的那个
pom.xml吗?" -
"反正有报错就搜一下,把那段
<dependency>复制进去就行了" -
"构建慢?那就
-DskipTests呗"
但现实是 ------ 在 2025 年这个 云原生 + CI/CD 已经成为默认配置 的时代,只要你:
-
想搞清楚"本地跑得好好的,为啥 CI 上就过不了"
-
想搞定"多模块大项目 + 私服 + 环境划分"的那堆坑
-
想让自己的项目"稍微有点工程味儿"而不是"到处 copy 的 demo"
你就绕不过 Maven。
Maven 已经不只是一个"老牌构建工具",而是 Java 世界的事实标准基础设施:
-
所有依赖都用它的"坐标系统"来命名
-
大多数开源框架的 README,第一个给你的就是 Maven 的
<dependency>片段 -
绝大部分企业 Java 项目的 CI/CD pipeline 底层,跑的都是 Maven 命令
所以,这篇文章的目标很简单:
不只是教你"会写 pom.xml",
而是帮你建立一张 清晰的 Maven 心智地图:
1)它在 Java 生态里是什么角色?
2)它整体是怎么工作的?
3)
pom.xml每一块大概在干嘛?这样以后你再遇到 Maven 问题,
就不是"到处百度复制粘贴",而是真正知道自己在动哪根神经。
什么是构建工具?Maven 在 Java 生态里的位置
先别急着看 pom.xml,我们先把问题退回到最原始的那个:
"写完代码,到底要经过哪些步骤,才能变成一个'可以上线的东西'?"
粗略列一下(不同语言略有差异,但套路差不多):
1)编译 / 转换
-
Java:
javac把.java编成.class,再打成.jar/.war -
TypeScript:
tsc把.ts→.js -
Go:
go build编成二进制
2)依赖管理
-
把各种三方库搞到本地
-
把 classpath 配好
3)测试
-
单元测试
-
集成测试
4)打包
- 把编译结果 + 资源文件,打成 jar / war / 镜像 / zip
5)发布 / 部署
-
上传到制品库(Nexus / Artifactory)
-
部署到测试环境 / 生产环境 / K8s
如果完全靠你手动做,可能是这样:
bash
javac -cp 一大坨路径 xxx.java
jar cvf xxx.jar ...
scp xxx.jar some-server
ssh some-server 'java -jar xxx.jar'
项目一旦稍微大一点,模块多一点,依赖多一点,环境多一点,就会变成:
-
命令很难记
-
很难保证所有人、所有环境都用同一套命令
-
CI/CD 上的脚本和本地完全是两套逻辑
-
很难把"构建过程"变成可维护、可讨论的东西
构建工具的出现,就是为了解决这类问题。
一句话版定义
构建工具(Build Tool)= 把"源代码 + 依赖 + 配置"自动变成"可运行 / 可发布产物"的那套标准化流程和工具集合。
Maven 做的事,主要有三件:
1)构建自动化
-
把编译、测试、打包、安装、发布这整条链路标准化
-
所有人都用同一套命令:
mvn clean package、mvn install
2)依赖管理
-
通过 GAV 坐标(
groupId/artifactId/version),从仓库拉取依赖 -
处理传递依赖 & 冲突
3)项目结构与生命周期规范
-
统一目录结构:
src/main/java、src/test/java... -
统一"构建阶段"的定义:
compile、test、package、install、deploy -
用插件(Plugin)在不同阶段插入不同的行为
Maven 在 Java 生态里的"地位"
简单排个时间线:
-
Ant:早期 Java 构建工具,类似"XML 版脚本语言"
-
Maven:引入 POM、生命周期、依赖管理,统一了一大堆"大家各玩各的"的东西
-
Gradle:基于 Groovy/Kotlin DSL,更灵活,性能更好,Android 领域几乎一统天下
但如果聚焦在"传统 Java / Spring Boot / 企业应用"这个大盘上,会看到一个现实:
Maven 依然是事实上的标准:
1)会 Maven 是 Java 后端工程师的"默认技能"
2)Gradle 是"加分项"和某些团队的主选项
原因也很现实:
-
大量老项目和框架都是 Maven 起步的
-
企业内部的私服、CI/CD、规范文档全部是 Maven 思维
-
绝大多数开源 Java 库,文档里的第一段依赖示例代码就是 Maven
<dependency>
所以,如果你把 Java 技术栈想象成一座城市:
JDK / JVM 是地下基础设施;
Spring 全家桶是城里的建筑群;
Maven 更像是"城市的物流系统":
-
负责把各种依赖和构建产物运来运去,
-
把这些东西规范地摆放在正确的位置,
-
CI/CD 工具(Jenkins / GitHub Actions / GitLab CI)都要跟它打交道。
先画一张心智图:Maven 整体工作流程长什么样?
很多人一开始学 Maven,是从各种 XML 配置、命令、插件开始的,结果越学越乱。
其实最好的方式,是先在脑子里画一张 "Maven 怎么工作"的总流程图。
我们先给一个简化版:
bash
源代码 + pom.xml
↓
解析 POM(parent + super POM + profiles + properties)
↓
解析依赖(构建依赖树,解决版本 & 冲突)
↓
组装生命周期(Lifecycle)和每个阶段要执行的插件 Goal
↓
执行你输入的命令(比如 mvn clean package):
clean: 删除 target
compile: 编译 Java
test: 执行单元测试
package: 打包成 jar/war
install: 安装到本地仓库 (~/.m2/repository)
deploy: 部署到远程仓库 (Nexus/Artifactory)
↓
得到产物(jar/war/docker 镜像等),交给后面的部署系统
把它拆开说,就是这么几步:
第一步:解析 POM(Project Object Model)
Maven 看任何项目,第一件事就是读 pom.xml:
1)读当前项目的 pom.xml → 生成一个 Project Model
2)看是否有 <parent>,如果有:
-
找到父 POM(本地或仓库里)
-
把父 POM 的配置和当前 POM 合并(类似"继承 + 覆盖")
3)在最顶上,还有一个 Maven 内置的 Super POM
-
里面有默认的仓库(比如 Maven Central)
-
默认的插件绑定等
4)最终得到一个"合并后的 POM",也就是:effective POM
你可以用:
bash
mvn help:effective-pom
来看看 Maven 最终"理解到的那份 POM"长什么样。
第二步:解析依赖,生成依赖树
拿到 effective POM 之后,Maven 会:
1)根据 <dependencies> 中的条目,一个个去仓库查找 POM
2)每个依赖也有自己的 dependencies,于是递归展开,形成一棵 依赖树
3)在这棵树上应用各种规则:
-
scope:compile/test/provided/runtime
-
optional:要不要被传递
-
exclusions:排除某些依赖
-
冲突时用"就近优先 / 先声明优先"等策略解决版本
最终,Maven 得到:
-
编译时 classpath
-
测试时 classpath
-
运行时 classpath
这些 classpath,后面都会被各种插件(compile/test/package)使用。
第三步:组装生命周期(Lifecycle)和插件执行计划
Maven 有三套内置生命周期:
-
clean -
default(最重要) -
site
我们最常用的是 default 这条链,它内部有一串 Phase:
bash
validate → compile → test → package → verify → install → deploy
然后,Maven 会做这么一件事:
1)看当前项目的 <packaging> 是 jar、war 还是 pom 等
2)根据 packaging 类型,加载默认的"插件绑定表"
-
比如:
compile阶段默认会绑定maven-compiler-plugin:compile -
test阶段绑定maven-surefire-plugin:test
3)再把你在 <build><plugins> / <build><pluginManagement> 里写的配置叠加进去
4)得到一张"执行计划表":
- 每个 Phase,要调用哪些插件、这些插件的哪些 Goal,以及这些 Goal 的配置参数
所以,当你敲下 mvn package 的时候,其实是在说:
"请执行从
validate到package的每一个 Phase,每个 Phase 把你已经计划好的所有插件 Goal 都跑一遍。"
第四步:执行命令,产生构建产物
举个常见命令:
bash
mvn clean package
Maven 会:
1)执行 clean 生命周期:删除 target 目录
2)执行 default 生命周期中,直到 package 为止的所有 Phase:
-
validate:校验项目结构
-
compile:编译
src/main/java -
test:编译 & 运行测试(使用 surefire 插件)
-
package:打包
jar或war(使用 jar/war 插件或 spring-boot 插件重打包)
如果你再加一个:
bash
mvn clean install
还会多做一步:
install:把打出来的构件放入本地仓库 ~/.m2/repository
- 方便后续被其他项目 / 模块直接通过 GAV 坐标来依赖
再进一步 mvn deploy,就是把构建好的产物发到远程仓库(比如公司私服),给别的项目/环境用。
GAV 身份证:Maven 如何唯一标识一个构件?
前面我们说到,"依赖管理"的核心在于:你得有一套能唯一标识每个构件的命名系统。
在 Maven 里,这套系统就是 GAV:
XML
<groupId>com.example</groupId>
<artifactId>demo-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
你可以把它理解成一个"身份证":
-
groupId:姓氏 + 家族 -
artifactId:名字(在这个家族内部的唯一名字) -
version:这个构件的版本 -
packaging:它是"什么类型"的东西(jar/war/pom/...)
groupId:你从哪来?
groupId 一般用 反转域名 风格:
-
org.springframework.boot -
com.fasterxml.jackson.core -
io.micrometer
它的意义是:
-
标识"这是哪个组织 / 团队 / 公司"的构件
-
在仓库里,也会反映到目录结构上,比如:
bash
~/.m2/repository/
org/springframework/boot/spring-boot-starter-web/3.3.1/...
团队内部起 groupId 时,一般会:
-
先定公司层级:
com.company -
再加业务线:
com.company.payment、com.company.order等 -
再往下才是 artifactId 来区分具体服务/模块
artifactId:你叫什么?
artifactId 就是构件在 groupId 内部的"名字":
-
spring-boot-starter-web -
spring-boot-starter-actuator -
spring-context -
logback-classic
同一个 groupId 下,artifactId 必须唯一。
在仓库目录结构里,它就是 groupId 下面的下一层目录名。
version:你是哪个版本?
版本号就更直观了:
-
1.0.0、1.0.1、2.0.0... -
也有
1.0.0-SNAPSHOT这种正在开发中的版本
Maven 不关心你版本号语义是不是严格"语义化版本控制(SemVer)",
但 它非常关心 version 这个字符串本身,因为:
-
依赖解析时,GAV 必须一起才能锁定一个具体构件
-
本地仓库里,version 对应目录的那一层:
bash
~/.m2/repository/com/example/demo-app/1.0.0/demo-app-1.0.0.jar
packaging:你是什么类型的构件?
常见值:
-
jar:普通库 / 应用 jar -
war:Web 应用(传统 Servlet 容器部署) -
pom:纯 POM 项目(一般用作父 POM / 聚合工程) -
其他:
ear、zip等也可以
packaging 会影响很多东西:
1)哪个插件负责 package 阶段
-
jar→maven-jar-plugin -
war→maven-war-plugin
2)有哪些默认的 lifecycle 绑定
3)在仓库里的文件名后缀是什么(.jar or .war)
一张"小结图":Maven 坐标系长什么样?
你可以用这样一串路径来帮助自己记忆:
groupId → 目录层级(org/springframework/boot)
artifactId → 目录名(spring-boot-starter-web)
version → 版本目录(3.3.1)
packaging + GAV → 文件名(spring-boot-starter-web-3.3.1.jar)
整合在一起,就是:
~/.m2/repository/org/springframework/boot/spring-boot-starter-web/3.3.1/
spring-boot-starter-web-3.3.1.jar
spring-boot-starter-web-3.3.1.pom
一旦你接受了"所有依赖都是通过 GAV 来定位"的这个设定,
Maven 里很多看起来很"玄学"的东西都会变得非常顺理成章。
pom.xml 拆解一遍:从"项目名片"到"构建大脑"
很多人一打开 pom.xml,第一反应是:
"好长一坨 XML,看两行就想关掉。"
但如果你把它当成一个 结构化的"项目说明书 + 构建脚本",再慢慢拆,就会发现其实很有章法:
顶部:项目是谁(名片)
中间:项目需要什么(依赖 + 仓库)
底部:项目怎么被构建(build + plugins + profiles)
这一节,我们用一个精简版示例,从上到下拆一遍。
顶部:项目的"身份证"和血统
先看最上面这一块:
XML
<project
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.1</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-app</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
</project>
这一块基本是 Maven 看项目的第一印象:
modelVersion:POM 模型版本,几乎永远是 4.0.0;
parent:说明这份 POM 继承自谁
-
Spring Boot 通常继承
spring-boot-starter-parent; -
你自己的多模块项目,会继承公司自定义的 parent POM;
groupId / artifactId / version / packaging:上一节讲过,就是 GAV 坐标 + 类型。
你可以这么理解:
-
parent决定了你的"家族"(继承谁的规则、依赖版本规范、插件默认配置) -
groupId/artifactId/version/packaging决定了你"自己是谁、要打成什么东西"
在 Maven 构建流程中,这一块会被先解析出来,用来:
-
拼接出仓库里的路径和文件名(GAV)
-
决定使用哪套默认 lifecycle / 插件绑定(由
packaging决定) -
决定往上要再加载哪些父 POM(
parent)
项目信息区:更多是"介绍文字",但也很有用
继续往下,你会看到类似这样的内容:
XML
<name>demo-app</name>
<description>A demo Spring Boot application.</description>
<url>https://example.com/demo-app</url>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
</license>
</licenses>
<developers>
<developer>
<id>renda</id>
<name>Renda Zhang</name>
<email>renda@example.com</email>
</developer>
</developers>
<scm>
<connection>scm:git:https://github.com/example/demo-app.git</connection>
<url>https://github.com/example/demo-app</url>
</scm>
这一块更多是元数据(Metadata):
对构建结果没有直接影响;
但会:
-
出现在仓库 UI 上;
-
出现在生成的 Maven site 文档里;
-
对开源项目尤其重要(别人一看 POM 就知道你是谁、代码在哪里)。
在公司内部项目里,你可以视情况简化,但最起码:
-
name/description写清楚; -
scm标一下 Git 仓库地址;
以后你在 CI/CD、制品库、SonarQube、项目门户里打开这个构件时,这些信息会让你少很多"这是谁的服务?哪里改代码?"的困惑。
properties:POM 里的"全局变量系统"
再往下,通常会有一段 <properties>:
XML
<properties>
<java.version>17</java.version>
<spring.boot.version>3.3.1</spring.boot.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target>
</properties>
这里有几个关键点:
1)<properties> 定义的是 "POM 层面的变量" ,可以在整份 POM 中通过 ${xxx} 使用;
2)Maven 解析 effective POM 时,会做一次 属性插值(interpolation):
-
把
${java.version}替换成实际值; -
也支持内置属性,比如
${project.artifactId}、${project.version}等;
3)属性可以被:
-
父 POM 覆盖子 POM;
-
profile 覆盖默认属性;
-
命令行
-Dxxx=yyy覆盖 POM 中定义的属性。
这带来几个好处:
-
统一版本 :比如所有地方都用
${java.version},以后改版本只改一处 -
简化配置 :插件配置 / 资源过滤 / 打包名字都可以引用
${project.*} -
方便按环境调整 :配合
<profiles>,不同环境可以用不同的属性值(例如数据库地址、打包标签等)
心智模型:
properties= POM 级别的 "config center";
${}= 把这些配置注入到具体的插件参数 / 依赖版本 / 资源文件里。
dependencies vs dependencyManagement:谁是真依赖,谁是"版本规范表"?
这一块是很多人一开始最迷糊的地方。
先看两个块:
XML
<!-- 版本规范表:不会直接进 classpath -->
<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>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 真正参与构建的依赖清单 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
</dependencies>
区别一句话总结:
dependencies= 真正会进 classpath 的依赖
dependencyManagement= 只定义"默认版本和配置"的规范表
更细一点说:
dependencies 里的每一条依赖,都会被 Maven 加入依赖树;
- 如果你不写
version,Maven 会去所有 parent + 当前 POM 的dependencyManagement里找;
dependencyManagement 不会主动把依赖加进 classpath,它只负责"当别人引用我时,应该用哪个版本、什么配置"。
典型用法:
在父 POM 的 dependencyManagement 里统一规定版本:
-
Spring Boot 的 BOM 就是这么干的;
-
你公司内部也可以搞一个自己的 "依赖版本 BOM";
在子模块里,只写:
XML
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
版本自动从 parent 的 dependencyManagement 里继承。
为什么要这么设计?
-
多模块项目里,如果每个模块都自己写版本,很快就乱套;
-
BOM +
dependencyManagement让版本管理变成"中央统一控制", 子模块只负责"你要不要用这个依赖",而不是"你决定它用哪个版本"。
build:构建行为发生在这里(插件是核心)
来到 build 区块:
XML
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
可以拆成几个小块看:
finalName:产物的"文件名"
XML
<finalName>${project.artifactId}-${project.version}</finalName>
决定了 target/ 里打包文件的基本名:
-
demo-app-1.0.0.jar -
或
demo-app-1.0.0.war
配合 CI/CD 时,很多 pipeline 就是通过这个名字去找制品的。
resources / testResources:哪些目录算"资源文件"
XML
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
Maven 会把这里列出的目录里的内容,复制到最终打包的 classes 路径下;
filtering=true 表示:
-
会对资源文件里的
${xxx}做属性替换(用的就是<properties>和内置属性); -
典型用法:
application-${app.env}.yaml里写占位符,打包时替换成 profile 对应的值。
testResources 同理,只是作用域是测试。
pluginManagement vs plugins:又一组"规范表 vs 实际使用"
这和 dependencyManagement / dependencies 的关系非常像:
<pluginManagement>:
-
定义"推荐/统一的插件版本和配置";
-
不会自动执行。
<plugins>:
-
这里列出的插件才会真正绑定到生命周期里参与执行;
-
如果没有写版本,就去
pluginManagement里找。
举个例子,父 POM:
XML
<build>
<pluginManagement>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>...</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
子模块只需要:
XML
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
</plugins>
</build>
版本和配置会自动从父 POM 的 pluginManagement 里继承下来。
底层机制:
Maven 组装 lifecycle 的时候,会:
1)先加载 packaging 决定的默认插件绑定;
2)再用 pluginManagement 的配置"覆盖默认值、加上统一配置";
3)最后看 <plugins> 里实际启用了哪些插件,并进行更精细的覆盖和绑定。
仓库与发布:repositories / pluginRepositories / distributionManagement
这一块是 Maven 和"外部世界"的接口:
XML
<repositories>
<repository>
<id>aliyun-maven</id>
<url>https://maven.aliyun.com/repository/public</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>central-plugins</id>
<url>https://repo.maven.apache.org/maven2</url>
</pluginRepository>
</pluginRepositories>
<distributionManagement>
<repository>
<id>releases-repo</id>
<url>https://repo.example.com/maven/releases</url>
</repository>
<snapshotRepository>
<id>snapshots-repo</id>
<url>https://repo.example.com/maven/snapshots</url>
</snapshotRepository>
</distributionManagement>
repositories:
-
用来拉 普通依赖 的远程仓库;
-
默认已经有 Maven Central,一般只需要额外加公司私服 / 镜像(有些会放到
settings.xml里)。
pluginRepositories:
- 专门用来拉 插件 JAR 的仓库,一般较少改。
distributionManagement:
-
决定
mvn deploy要把构建结果 发到哪个远程仓库; -
通常对应公司的 Nexus / Artifactory 的 release / snapshot 仓库。
心里要有的一点:
repositories 决定"依赖从哪拉 ",distributionManagement 决定"产物往哪推"。
profiles:一份 POM 装下多套"配置世界线"
最后一个非常关键但经常被忽略的区域:
XML
<profiles>
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<app.env>dev</app.env>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<app.env>prod</app.env>
</properties>
<build>
<resources>
<resource>
<directory>src/main/resources-prod</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
</profile>
</profiles>
你可以把 <profile> 理解成:
一块可以"按条件叠加上去的配置 patch"。
它可以里边放:
-
properties:环境变量 -
dependencies:某些环境才需要的组件 -
build:不同环境用不同资源目录 / 插件配置 -
repositories:某些环境用不同仓库
激活方式:
-
<activeByDefault>true</activeByDefault>:默认生效 -
命令行:
mvn clean package -Pprod -
根据环境变量 / JDK 版本 / 操作系统等条件激活(
<activation>里可以写)
在 Maven 底层:
1)先读取所有 profile;
2)判断哪些 profile 被激活;
3)把它们的配置合并到主 POM 的模型里;
4)再走"依赖解析 + lifecycle 组装"的那套流程。
小结:再看 pom.xml,就按这 7 个区块读
以后你再看到一份 pom.xml,可以按下面顺序扫一遍:
1)顶层身份区 :modelVersion + parent + GAV + packaging
- 这是谁?继承谁?打成什么?
2)项目信息区 :name / description / scm / developers
- 这是谁写的?代码在哪?
3)属性区 :properties
- 有哪些"全局变量"?后面
${}都从这里翻
4)依赖区 :dependencyManagement vs dependencies
- 统一版本是怎么管的?真正用到哪些依赖?
5)构建区 :build + pluginManagement + plugins + resources
- 编译 / 测试 / 打包的插件是谁?怎么配置?
6)仓库 & 发布区 :repositories / pluginRepositories / distributionManagement
- 依赖从哪拉?产物往哪发?
7)Profile 区 :profiles
- 有几套"世界线"?不同环境下 POM 其实长什么样?
把这 7 块吃透之后,pom.xml 就不再是"看不懂的 XML 垃圾堆",而是一个:
可以被你读懂、修改、复用、抽象成模板的"构建大脑"。
依赖管理的真实世界:传递依赖、scope 与冲突调解
讲 Maven,绕不开三个字:依赖地狱。
你可能已经遇到过这些场景:
-
引了个 A 依赖,结果多出一堆莫名其妙的 jar
-
改了一个依赖版本,线上日志开始疯狂报
NoSuchMethodError -
本地跑得好好的,换个同事的电脑就编译不过
这些现象背后,基本都绕着三个关键词打转:
传递依赖、scope、冲突调解。
传递依赖:你只写了 A,B/C/D 也跟着进来了
最朴素的例子:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
你只写了一个 starter,但实际上:
spring-boot-starter-web 自己又依赖了:
- Spring MVC、Jackson、Tomcat/Netty、Validation、日志组件......
它们又各自依赖别的库。
最终,Maven 会生成一棵这样的依赖树(简化版):
bash
spring-boot-starter-web
├─ spring-boot-starter
├─ spring-boot-starter-json
│ ├─ jackson-databind
│ ├─ jackson-core
│ └─ jackson-annotations
├─ spring-webmvc
└─ spring-boot-starter-tomcat
├─ tomcat-embed-core
└─ ...
这就是 传递依赖(Transitive Dependencies):
你依赖 A,A 依赖 B,B 依赖 C,最终 B / C 都会加进你的 classpath。
好处:
-
你不用手写一大堆依赖
-
维护起来轻松很多(尤其是 Spring 这种"套娃式"框架)
问题:
-
版本冲突(同一个库的不同版本)
-
引进来一堆你没意识到的东西(安全、体积、兼容问题)
scope:依赖"在哪些阶段"生效?
Maven 的依赖 scope 本质上是在回答:
"这个依赖,在哪些生命周期阶段需要?在哪些 classpath 里出现?"
最常用的几个:
1)compile(默认)
-
编译 + 测试 + 运行 都需要
-
比如 Spring 框架本体、你自己的业务公共模块等
2)test
-
只在测试阶段需要
-
典型:JUnit、Mockito、Spring Boot 的 test starter
-
不会打进最终产物
3)provided
-
编译需要,但运行时由"外部环境"提供
-
典型:
javax.servlet-api(老式 war 部署时由容器提供)、lombok(只在编译生成代码) -
如果你把
provided的东西打进可执行 jar 里,通常是"不合适"的设计
4)runtime
-
运行时需要,但编译时不一定需要
-
典型:JDBC 驱动
-
编译时只依赖 JDBC 接口,运行时才需要具体实现
你可以用一张表来记:
| scope | 编译 | 测试编译 | 测试运行 | 打包产物 | 备注 |
|---|---|---|---|---|---|
| compile | ✅ | ✅ | ✅ | ✅ | 默认 |
| test | ❌ | ✅ | ✅ | ❌ | 只在 test classpath |
| provided | ✅ | ✅ | ✅ | ❌ | 运行时由外部环境提供 |
| runtime | ❌ | ❌ | ✅ | ✅ | 只在运行期、测试运行期需要 |
冲突调解:同一个库不同版本,谁说了算?
再来看一个经典大坑:
-
依赖 A 用
log4j:2.20.0 -
依赖 B 用
log4j:2.17.0 -
你项目里同时引了 A 和 B
最后 classpath 里到底是哪个版本?
Maven 的默认策略可以简化理解为两条:
1)"最短路径优先"(就近优先)
2)在同一深度上,"先声明者优先"
用依赖树来看:
bash
你的项目
├─ A (依赖 log4j:2.20.0)
└─ B (依赖 log4j:2.17.0)
如果 A/B 都是直接依赖(同一深度),那由谁先写在 <dependencies> 里决定:
XML
<dependencies>
<!-- 先声明 A,A 带的 log4j 更"占理" -->
<dependency>
<groupId>com.example</groupId>
<artifactId>A</artifactId>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>B</artifactId>
</dependency>
</dependencies>
要是嵌套更深一点:
bash
你的项目
├─ A (依赖 log4j:2.20.0)
│ └─ log4j:2.20.0
└─ C
└─ B (依赖 log4j:2.17.0)
└─ log4j:2.17.0
通常会选路径更短、距离根更近的那个版本。
现实工程里一般怎么做?
不依赖"玄学默认规则",而是 把版本"拉到明面上"统一管理:
1)在父 POM 的 dependencyManagement 里写死版本:
XML
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.20.0</version>
</dependency>
</dependencies>
</dependencyManagement>
2)在子模块里,如果 A/B 各自带了不同版本的 log4j,可以通过 <exclusions> 排掉:
XML
<dependency>
<groupId>com.example</groupId>
<artifactId>B</artifactId>
<exclusions>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
</exclusion>
</exclusions>
</dependency>
3)真正要用的版本,由你自己的 POM 决定,而不是让第三方库"绑架"你。
BOM:把"版本地狱"变成"版本清单"
Spring Boot 的做法特别值得学:
XML
<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>
</dependencies>
</dependencyManagement>
这个 spring-boot-dependencies 就是一个 BOM(Bill of Materials):
-
它本身是个 POM,里面列了一大堆常用依赖的"推荐版本"
-
你项目里只要
import了它,就可以在<dependencies>里不写 version
好处:
-
Spring 官方帮你选好"一整套配套兼容的版本"
-
你不容易因为自己乱配版本把 Spring 家族玩崩
-
自己公司也可以学这个套路,搞一个内部 BOM 来管理统一依赖版本
仓库体系:本地仓库 / 远程仓库 / Maven Central / 私服
Maven 世界里,依赖和构件都被统一放在"仓库(Repository)"里。
你可以把它想象成一个多层级图书馆系统:
1)你电脑上的小书架(本地仓库);
2)公司楼下的阅览室(私服);
3)城市里的公共图书馆(Maven Central)。
本地仓库:~/.m2/repository
第一次你执行 mvn clean package 时,可能会看到 Maven 疯狂打印:
- Downloading from central: ...
这些 jar 下完之后,都被放到了一个固定地方:
-
Windows:
C:\Users\<你用户名>\.m2\repository -
macOS / Linux:
~/.m2/repository
Maven 的基本策略是:
1)优先查本地仓库;
2)找不到才去远程仓库拉;
3)拉回来后,缓存到本地仓库,后面再用就很快。
好处:
-
减少网络请求;
-
不同项目之间共享同一份依赖;
-
CI 机器也会有自己的本地仓库,配合 Maven 缓存可以大幅加速构建。
远程仓库:公共仓库 + 公司私服
远程仓库简单分两大类:
1)公共仓库(公网可访问)
-
典型代表:Maven Central;
-
很多国内会用阿里云等镜像加速。
2)私服(公司内部搭建)
Nexus / Artifactory / JFrog / Harbor(部分场景)等;
用来:
-
缓存公共依赖(代理 Maven Central,防止外网问题);
-
存放公司私有库(不公开的 jar / war / pom);
-
做 Release / Snapshot 的版本管理与审核。
在 POM 中,你会看到类似:
XML
<repositories>
<repository>
<id>aliyun-maven</id>
<url>https://maven.aliyun.com/repository/public</url>
</repository>
</repositories>
在 CI / 公司项目里,更常见的是把这些配置放到 settings.xml 里,通过 mirror 统一重定向所有请求到私服。
Maven Central:开源 Java 世界的"官方图书馆"
-
几乎所有主流 Java 开源库,都会发布到 Maven Central;
-
你在文档里看到的
<dependency>,90% 对应的 jar、pom 就在这里; -
默认情况下,Maven 就是从 Central 拉依赖。
换句话说:
只要你掌握了 Maven 的坐标体系(GAV),
就能在 Maven Central 这座"图书馆"里 准确拿出任何一本书。
distributionManagement:构件"往哪儿发"
repositories 决定"从哪拉依赖",
而 distributionManagement 决定"发构件到哪"。
XML
<distributionManagement>
<repository>
<id>releases-repo</id>
<url>https://repo.example.com/maven/releases</url>
</repository>
<snapshotRepository>
<id>snapshots-repo</id>
<url>https://repo.example.com/maven/snapshots</url>
</snapshotRepository>
</distributionManagement>
配合命令:
bash
mvn deploy
Maven 就会:
-
把当前构件(jar/pom 等)上传到
releases或snapshots仓库; -
如果 version 以
-SNAPSHOT结尾,就进 snapshot 仓库; -
否则进 release 仓库。
在 团队协作 / 多项目复用 里,这一步极其关键:
A 项目打包 deploy 到私服 → B 项目在 <dependencies> 里直接用 GAV 引用;
比较大的公司,通常会要求:
-
只有"过了测试 / 代码审查"的版本,才能 deploy 到 release 仓库;
-
快速迭代的版本停留在 snapshot 仓库。
生命周期 & 插件:从 mvn clean package 看懂整条流水线
现在再回头看你最常敲的一句命令:
bash
mvn clean package
我们就不把它当"魔法咒语",而是拆成两部分:
-
clean:对应 clean 生命周期; -
package:对应 default 生命周期 里的一个 phase。
三大生命周期:clean / default / site
Maven 内置了三套 Lifecycle:
1)clean
-
负责清理以前构建的产物(主要是删
target/) -
常见 phase:
pre-clean → clean → post-clean
2)default(构建主线)
-
编译 / 测试 / 打包 / 安装 / 部署
-
常见 phase:
bash
validate
compile
test
package
verify
install
deploy
3)site
-
生成项目信息站点(文档、报表)
-
现在用得相对少
关键点:
- 执行
mvn package,其实是在说:
"请把 default 这个生命周期里,从 validate 一直跑到 package。"
- 执行
mvn clean package,等于:
"先跑一遍 clean 生命周期,再跑 default 生命周期到 package。"
Phase、Plugin、Goal:三件事的关系
我们先画一个关系图:
bash
Lifecycle = 一条流水线
↓
Phase = 流水线上的一个站点(compile/test/package/...)
↓
Plugin + Goal = 在这个站点上要调用哪些"工具"(任务)
-
Phase (阶段):
compile、test、package等; -
Plugin(插件):一个 jar 包,里面装着一堆具体的"任务";
-
Goal(目标):插件里提供的某个具体任务(方法)。
例如:
maven-compiler-plugin:compile
-
Plugin =
maven-compiler-plugin -
Goal =
compile -
通常绑定到
compilephase
maven-surefire-plugin:test
-
Plugin =
maven-surefire-plugin -
Goal =
test -
绑定到
testphase
你在 pom.xml 里配置插件,实际就是在发指令:
"在某个 phase 上,请调用某个插件的某些 goal,参数如下......"
mvn clean package 背后到底发生了什么?
假设有这么一个经典的 Spring Boot 项目 pom:
XML
<packaging>jar</packaging>
<build>
<plugins>
<!-- 编译 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<!-- 单元测试 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<!-- Spring Boot 重打包 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
当你执行:
bash
mvn clean package
大致会经历这些步骤(略简化):
1)clean 生命周期
-
调用
maven-clean-plugin的cleangoal -
删除
target/目录
2)default 生命周期直到 package
validate
- 检查项目结构是否正常(这个阶段很多项目没有自定义插件)
compile
-
调用
maven-compiler-plugin:compile -
编译
src/main/java→target/classes
test
-
编译
src/test/java -
调用
maven-surefire-plugin:test运行单元测试 -
把测试报告放到
target/surefire-reports
package
-
调用
maven-jar-plugin:jar(默认)打一个普通 jar -
再调用
spring-boot-maven-plugin:repackage把普通 jar 重打成可执行 fat-jar(这一步就是你可以用java -jar demo-app-1.0.0.jar直接跑起来的核心)
最终,你得到:
bash
target/
demo-app-1.0.0.jar # 可执行 Spring Boot fat-jar
demo-app-1.0.0.jar.original # 原始的普通 jar(可选)
classes/ # 编译后的 class & 资源
test-classes/ # 测试 class
如果你接着执行:
bash
mvn install
Maven 会在上一步基础上多做一件事:
-
把
demo-app-1.0.0.jar+ 对应的pom.xml安装到本地仓库~/.m2/repository/com/example/demo-app/1.0.0/ -
其他项目就可以通过
<dependency>来引用它了
读插件配置的时候,脑子里该怎么想?
拿 maven-compiler-plugin 为例:
XML
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
你可以在脑子里翻译成:
"在 compile phase 上,
用 版本 3.13.0 的编译插件 来编译代码,
编译参数:
-source 17 -target 17。"
再看 Spring Boot 插件:
XML
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
翻译成大白话就是:
"在 package 这个阶段,
除了默认打 jar 以外,
再执行一下
spring-boot-maven-plugin的repackage任务,把刚打完的 jar 重打成可执行 fat-jar。"
多模块工程:parent + modules + 继承与聚合
当项目还只有一个 demo-app 的时候,一份 pom.xml 足够优雅。
但当你开始想拆:
-
公共工具抽出来一个
common -
接口定义抽出来一个
api -
业务服务有
order-service/user-service/payment-service -
再加一个
web模块做管理后台
如果每个模块都是独立 Maven 工程,很快就会变成:
-
每个项目 copy 一份差不多的
pom.xml -
版本号各改各的,半年后谁也说不清现在线上到底跑的是什么组合
-
想全局升级一个依赖版本,要进 N 个项目挨个改
这时候,就该上 多模块 Maven 工程 了。
核心有两个关键词:
继承(parent) + 聚合(modules)
继承:抽出一个"家长 POM",统一规则
先看一个典型父 POM:
XML
<!-- demo-parent/pom.xml -->
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>demo-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging>
<properties>
<java.version>17</java.version>
<spring.boot.version>3.3.1</spring.boot.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- Spring Boot 官方 BOM -->
<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>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>
注意几点:
packaging 是 pom:这个模块不会生成 jar,而是专门做 父 POM / 配置中心;
里面主要放:
-
统一的
properties(JDK 版本、Spring Boot 版本等); -
统一的
dependencyManagement(依赖版本规范表); -
统一的
pluginManagement(插件版本规范表)。
子模块只要继承它:
XML
<!-- demo-service/pom.xml -->
<project ...>
<parent>
<groupId>com.example</groupId>
<artifactId>demo-parent</artifactId>
<version>1.0.0</version>
<relativePath>../pom.xml</relativePath>
</parent>
<!-- 不需要再写 groupId 和 version,可从 parent 继承 -->
<artifactId>demo-service</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
</dependencies>
</project>
这时候你会发现:
-
子模块可以不写
groupId/version -
子模块依赖
mapstruct可以不写version -
子模块配置
maven-compiler-plugin可以不写version,都走父 POM 的
继承解决的是:
多个模块之间的 "配置复用 + 规则统一" 问题。
聚合:让 mvn install 一把跑完所有模块
父 POM 还有一个常用角色:聚合工程(Aggregator)。
在父 POM 里加上:
XML
<modules>
<module>demo-api</module>
<module>demo-service</module>
<module>demo-web</module>
</modules>
目录结构大致变成:
bash
demo-parent/
pom.xml # 父 POM,同时也是聚合入口
demo-api/
pom.xml
demo-service/
pom.xml
demo-web/
pom.xml
这时你在 demo-parent 目录执行:
bash
mvn clean install
Maven 会做两件事:
1)从父 POM 的 <modules> 里找到所有子模块
2)根据模块之间的依赖关系,按顺序构建:
-
先构建被依赖的模块(比如
demo-api) -
再构建依赖它的模块(比如
demo-service、demo-web)
聚合解决的是:
"一个命令构建整个工程树"的问题,方便 CI/CD 和本地开发。
很多时候,一个顶层 POM 会同时扮演这两个角色:
-
packaging=pom -
既是 parent,又是 aggregator
-
既承载了"项目规则",又是"构建入口"
多模块里的依赖关系怎么设计?
常见几种模式:
1)API / SPI 抽象层
-
demo-api模块只放接口、DTO、VO -
demo-service依赖demo-api,实现这些接口 -
demo-web同时依赖demo-api和demo-service
2)公共工具模块
-
demo-common放通用工具:异常封装、统一返回体、基础配置 -
其他业务模块都依赖
demo-common
3)严格分层
-
demo-domain:领域模型 -
demo-infra:基础设施(DB/缓存/消息) -
demo-app:应用服务 -
demo-interface:对外接口(REST/RPC)
多模块带来的最大好处是:
-
IDE 内一个工程就能看全所有模块的依赖关系
-
CI 一个 pipeline 就能完整构建 & 测试 & 打包所有模块
-
逻辑分层非常清晰,模块间谁依赖谁一目了然
Profile:一套 pom 搞定多套环境配置
现实世界不只有一个"环境":
-
开发环境(dev):本地 DB、本地 Redis、本地 MQ
-
测试环境(test):共享测试库、测试 MQ
-
预发环境(staging):接近生产配置
-
生产环境(prod):高可用、限流、监控、告警全部拉满
问题来了:
到底是为每个环境搞一套不同的
pom.xml,还是一份 POM 搞定所有环境差异?
Maven 给出的答案就是:Profile。
Profile 是什么?
一句话:
Profile = 可以按条件激活的一组"配置 patch"。
它可以覆盖 / 增补很多内容:
-
properties:环境变量 -
dependencies:某环境特有依赖 -
build:资源目录、插件参数 -
repositories:不同环境用不同仓库(比如内网 / 外网)
简单例子:
XML
<profiles>
<!-- 开发环境:默认激活 -->
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<app.env>dev</app.env>
<db.url>jdbc:mysql://localhost:3306/demo_dev</db.url>
</properties>
</profile>
<!-- 生产环境:手动指定 -Pprod 激活 -->
<profile>
<id>prod</id>
<properties>
<app.env>prod</app.env>
<db.url>jdbc:mysql://prod-db:3306/demo_prod</db.url>
</properties>
<build>
<resources>
<resource>
<directory>src/main/resources-prod</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
</profile>
</profiles>
Profile 是怎么"叠加"到 POM 上的?
构建时,Maven roughly 会做:
1)解析主 POM
2)找到所有 <profile>
3)根据激活规则,确定谁生效:
-
<activeByDefault>true</activeByDefault> -
命令行
-Pprod -
<activation>里根据 JDK 版本、OS、系统属性等条件自动激活
4)把这些 profile 里的配置合并进主 POM 的 Model
5)再走依赖解析 + lifecycle + 插件执行那套流程
你可以理解为:
POM = 主配置 + (0~N 个 profile patch)
Profile 的常见激活方式
1)默认激活
XML
<activation>
<activeByDefault>true</activeByDefault>
</activation>
项目不指定 -P 时,就用这个 profile。
2)命令行激活
bash
mvn clean package -Pprod
可以指定多个:
bash
mvn clean package -Pprod,with-monitor,with-metrics
3)按环境 / JDK / 系统属性激活
XML
<activation>
<jdk>[17,)</jdk> <!-- JDK ≥ 17 时生效 -->
</activation>
<activation>
<os>
<family>unix</family>
</os>
</activation>
<activation>
<property>
<name>env</name>
<value>prod</value>
</property>
</activation>
配合命令行:
bash
mvn clean package -Denv=prod
就能在 CI 里按变量激活对应 profile。
Profile 能帮你做什么?
几个常见、很实用的场景:
1)按环境切换资源目录
-
dev 用
src/main/resources -
prod 用
src/main/resources-prod
2)按环境控制依赖
-
dev 引入
spring-boot-devtools -
prod 不要这个依赖
3)按环境控制插件行为
-
dev 跳过某些耗时检查(比如集成测试、静态扫描)
-
CI / prod profile 开启全量检查
4)按环境切换编译参数 / 打包参数
- 比如 prod 打包时额外带上某些 metadata 或启用某种混淆插件
Maven 在云原生时代:和 Spring Boot / Docker / CI/CD 怎么配合?
说到这里,你可能会问:
"这些东西听起来都挺工程化的,
那放在 Spring Boot + Docker + K8s + CI/CD 的世界里,
Maven 具体扮演什么角色?"
用一句稍微"硬核"一点的话说:
在云原生 Java 服务的落地流程里,
Maven 是"从源码到制品"的标准流水线,
后面的 Docker / K8s / Helm / ArgoCD 都是接在它后面的。
典型流水线:从源码到 K8s 的一条链
我们用一个典型微服务为例,看一条完整链路:
bash
Git 代码提交
↓
CI 拉取代码
↓
Maven 构建:mvn clean package -DskipTests
↓
得到可执行 jar(Spring Boot fat-jar)
↓
Docker 构建镜像(Dockerfile / Jib / Buildpacks)
↓
推送镜像到镜像仓库(Harbor / ECR / ACR / ...)
↓
K8s 部署(kubectl / Helm / ArgoCD 等)
↓
服务在集群中运行
在这条链上:
Maven 负责:
-
解析依赖
-
编译
-
测试
-
打包
后面的阶段(Docker / K8s)只关心:
- "我拿到一个标准的 jar(或者直接拿到构建出来的镜像)"
所以你会看到,在 CI 的脚本里,常常有这样几行:
bash
# GitHub Actions / GitLab CI / Jenkinsfile 里类似的步骤
- name: Build with Maven
run: mvn -B clean package -DskipTests
- name: Build Docker image
run: docker build -t registry.example.com/demo-service:${GIT_COMMIT} .
Spring Boot + Maven:最常见的组合拳
Spring Boot 官方给你准备好了非常顺手的一套:
1)继承 spring-boot-starter-parent
2)使用 spring-boot-dependencies 这个 BOM 管理版本
3)使用 spring-boot-maven-plugin:
-
repackage:打可执行 jar -
spring-boot:run:本地开发快速启动
最小 pom:
XML
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.1</version>
</parent>
<dependencies>
<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>
配套命令:
bash
# 本地开发
mvn spring-boot:run
# 构建可执行 jar(CI 常用)
mvn clean package -DskipTests
Maven 驱动 Docker 构建的几种方式
在"容器化时代",常见有三种玩法:
方式一:传统 Dockerfile
1)用 Maven 打 jar:
bash
mvn clean package -DskipTests
2)Dockerfile:
bash
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY target/demo-app-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
3)然后 CI 里:
bash
docker build -t registry.example.com/demo-app:${GIT_COMMIT} .
docker push registry.example.com/demo-app:${GIT_COMMIT}
优点:灵活、通用;
缺点:有两套配置(Maven & Dockerfile),需要保持一致。
方式二:Jib / Buildpacks 等"Maven 插件直接出镜像"
比如 Spring Boot 3.x 官方推荐使用 Buildpacks:
bash
mvn spring-boot:build-image \
-Dspring-boot.build-image.imageName=registry.example.com/demo-app:${GIT_COMMIT}
Spring Boot 插件会帮你:
-
打 jar
-
基于 buildpacks 构建容器镜像
-
推送到仓库(只要配置好权限)
好处:
-
不需要 Dockerfile
-
构建步骤更统一:所有操作都挂在 Maven 插件下
-
对安全 / 多阶段构建 / 小镜像体积已经做了优化
Jib 同理(jib-maven-plugin),直接在 Maven lifecycle 里产生镜像。
方式三:多阶段 Docker + Maven 缓存优化
适合对构建性能和镜像体积有极致追求的场景:
bash
# 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
# 运行阶段
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /build/target/demo-app-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
这里 Docker 和 Maven 紧密配合:
利用 mvn dependency:go-offline + Docker layer cache:
- 依赖不变就复用缓存,加速构建。
运行镜像中只保留最终 jar,构建工具 & 源码不进入最终镜像。
Maven 在 CI/CD 中的"契约"角色
在一个成熟一点的团队里,通常会有这样的"分工":
1)Maven 负责:
构建逻辑层面的契约:
-
项目结构;
-
依赖树;
-
构建产物类型(jar/war);
-
测试标准(哪些测试算 pass);
CI 只是负责"调用 Maven 构建",不关心具体细节。
2)CI/CD 工具(Jenkins / GitHub Actions / GitLab CI)负责:
-
触发时机(push / PR / tag / 手动);
-
并行策略 / 缓存策略;
-
构建环境(JDK 版本、Maven 版本、Docker daemon 等);
-
后续步骤(制品上传、镜像推送、部署到 K8s)。
这种分工的好处是:
-
Maven 配置是项目的一部分,版本受 Git 管控;
-
CI/CD 配置更多是"运行时逻辑",可以复用 / 模板化;
-
当你从 Jenkins 换到 GitHub Actions 时,只要能跑 Maven 命令,构建本身不用重写。
为什么说"搞懂 Maven,云原生不会太慌"?
最后给一个有点"鸡汤",但又挺实在的结论:
当你真正搞清楚 Maven 的:
1)依赖管理(GAV / 传递依赖 / BOM);
2)生命周期 & 插件(Lifecycle / Phase / Plugin / Goal);
3)多模块 & Profile(parent / modules / profiles)。
你会发现:
1)换 Gradle,只是换个 DSL;
2)换 CI/CD,只是换个"谁来执行
mvn xxx";3)换云平台(AWS / 阿里云 / K8s / 自建集群),只是换个"jar / 镜像最后被部署到哪"。
Maven 本质上帮你把 "代码 → 产物"这一段 pipeline 变成可描述、可复用、可迁移的东西,
这就是为什么,哪怕都 2025 年了,你依然绕不开它。
实战部分:从 0 写一个"干净可维护"的 Maven 项目
前面讲了一大圈概念,这一节就来点"落地"的:
如果你现在开一个新的 Java / Spring Boot 项目,要怎么从 0 搭一份 既干净、又方便扩展、多模块 & CI 友好 的 Maven 工程?
我给你一个可以直接拿去复用的思路,分 5 步走。
Step 1:先想清楚 GAV 命名 & 目录结构
不要一上来就 mvn archetype:generate,先在草稿上想几件事:
1)groupId:公司 / 组织 / 业务线
bash
com.yourcompany.platform
com.yourcompany.shop
com.yourcompany.payment
2)artifactId:这个"工程"的名字
先定一个顶层工程名,比如:
bash
shop-platform
3)准备好以后拆多模块的空间
目录先这么想:
bash
shop-platform/
pom.xml # 父 POM + 聚合工程
shop-common/ # 公共工具模块
pom.xml
shop-user-service/ # 用户服务
pom.xml
shop-order-service/ # 订单服务
pom.xml
即使你现在只打算先写一个模块,也建议从一开始就有 parent POM 的概念,后面要拆多模块会轻松很多。
Step 2:写一个"高内聚"的父 POM(parent + aggregator)
顶层 shop-platform/pom.xml:
XML
<?xml version="1.0" encoding="UTF-8"?>
<project
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 顶层 groupId / artifactId / version -->
<groupId>com.yourcompany.shop</groupId>
<artifactId>shop-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<!-- 作为聚合工程:收纳所有子模块 -->
<modules>
<module>shop-common</module>
<module>shop-user-service</module>
<module>shop-order-service</module>
</modules>
<!-- 统一属性 -->
<properties>
<java.version>17</java.version>
<spring.boot.version>3.3.1</spring.boot.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<!-- 依赖版本规范表(可以引入 Spring Boot BOM,也可以引自己公司的 BOM) -->
<dependencyManagement>
<dependencies>
<!-- Spring Boot 版本统一管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 公司内部统一依赖,如 mapstruct / lombok 等 -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
</dependencies>
</dependencyManagement>
<!-- 插件版本规范表 -->
<build>
<pluginManagement>
<plugins>
<!-- 编译插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
</plugin>
<!-- 单元测试插件 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</pluginManagement>
</build>
<!-- 环境 Profile 放到顶层,子模块统一使用 -->
<profiles>
<profile>
<id>dev</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<app.env>dev</app.env>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<app.env>prod</app.env>
</properties>
</profile>
</profiles>
</project>
这个父 POM 已经扮演了两个角色:
-
parent:提供统一的属性 / 版本 / 插件配置;
-
aggregator:一次
mvn clean install,能把所有子模块都跑完。
到这里,你已经有了一个"团队级模板"的雏形了。
Step 3:写一个典型业务模块的 POM(以 user-service 为例)
shop-user-service/pom.xml:
XML
<project
xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<!-- 继承顶层父 POM -->
<parent>
<groupId>com.yourcompany.shop</groupId>
<artifactId>shop-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<!-- 只需要声明 artifactId -->
<artifactId>shop-user-service</artifactId>
<packaging>jar</packaging>
<name>shop-user-service</name>
<description>User service of shop platform</description>
<dependencies>
<!-- Spring Boot Web / Actuator / JPA 等依赖,都不写 version -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- 数据库驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 公共模块 -->
<dependency>
<groupId>com.yourcompany.shop</groupId>
<artifactId>shop-common</artifactId>
<version>${project.version}</version>
</dependency>
<!-- MapStruct,版本由父 POM 的 dependencyManagement 管理 -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<!-- 直接使用父 POM 里定义好的插件版本 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<!-- Spring Boot Maven 插件 -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
注意这里的几个"克制"设计:
-
不在子模块重复写
groupId/version/ 插件版本; -
不在子模块写
<dependencyManagement>/<pluginManagement>; -
只关心:这个模块自己需要哪些依赖 & 插件;
-
版本、规则、环境 profile 都交给父 POM 统一管。
这样一来,你后面要做全局升级,比如:
-
从 JDK 17 升级到 21;
-
从 Spring Boot 3.3.x 升级到 3.4.x;
-
从某个依赖的 1.x 升级到 2.x。
只要改一处:顶层父 POM。
Step 4:给团队约定一份"pom 规范模板"
你可以把上面这两个 POM 精简成一份规范:
-
parent-pom-template.xml:团队统一 parent -
service-pom-template.xml:新建服务时 copy 的模板
并且约定好几点:
1)所有服务必须继承团队的 parent POM
2)子模块中严禁随意写 version(除非有特殊说明)
3)统一放到某个 Git 仓库 / Wiki,新建项目直接 copy
4)CI/CD 里只允许执行有限的几条 Maven 命令:
bash
mvn -B clean verify
mvn -B clean package -DskipTests
mvn -B deploy -Pprod
有了这个"模板 + 准入规则",你的 Maven 工程就从"个人习惯"升级成了"团队工程规范"。
Step 5:配一条最小可用的 CI 脚本
以 GitHub Actions 为例,一个最小的 Java + Maven + Docker 的 workflow:
bash
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
cache: maven
- name: Build with Maven
run: mvn -B clean package -DskipTests
# 如果需要构建 Docker 镜像,再加一段:
# - name: Build Docker image
# run: docker build -t registry.example.com/shop-user-service:${{ github.sha }} .
你会发现:
-
CI 只负责拉代码 + 配环境 + 调用 Maven;
-
具体依赖版本、构建逻辑、模块关系,全写在
pom.xml里; -
当你把项目迁到 Jenkins / GitLab CI,只要能执行
mvn,脚本改动很小。
到这一步,一个 "干净、可维护、易扩展、多环境友好" 的 Maven 项目就算成型了。
把 Maven 放进你的 Java 心智模型
最后,我们不再谈 XML、命令、插件这些细节,回到一个更大的问题:
在你的 Java 技术栈认知里,Maven 应该被放在哪个位置?
如果你之前看过类似《Servlet = Java 时代最底层的 Web 请求处理机制》、《JDBC = Java 访问数据库的最底层规范》这类文章,可以试着这样画一张图:
bash
JVM / JDK → 最底层执行和语言能力
JDBC / Servlet → 最底层 IO / Web / DB 规范
Spring 全家桶 → 上层框架与生态
Maven → 把"代码 + 依赖 + 配置"变成"可部署制品"的流水线
CI/CD → 自动化执行这一套流水线 + 部署到环境
K8s / 云平台 → 运行这些制品的基础设施
你会发现:
1)Maven 不直接参与业务逻辑
但它决定了:
-
你的项目怎么组织
-
依赖怎么管理
-
构建产物长什么样
2)Maven 也不是传统意义上的"框架"
它更像是:
这座"Java 城市"里的物流系统 和生产流水线标准。
当你真正把 Maven 放进心智模型里,很多事情会变得更清晰:
1)当你看到一个陌生项目的 pom.xml,脑子里会自动按 7 个区域去解析:
-
身份 & 血统(parent / GAV / packaging)
-
元信息(name / description / scm)
-
属性(properties)
-
依赖(dependencies / dependencyManagement)
-
构建(build / plugins / pluginManagement)
-
仓库 & 发布(repositories / distributionManagement)
-
环境(profiles)
2)当你配 CI/CD 时,不再是"瞎写几条脚本让它跑起来",而是很自然地问自己:
-
这一条
mvn命令,对应的是哪条生命周期? -
我希望在 CI 里做到哪个 Phase?
test还是verify还是install? -
产物是 jar 还是 war,还是直接打镜像?后面谁会用到这些产物?
3)当你看 Gradle / Bazel / SBT 时,也不会慌:
-
底层概念基本一样:依赖管理、生命周期、任务、插件、仓库
-
只是换了一套 DSL 和命令风格而已
4)当你写"工程级"的 Java 项目(而不是 demo)时,Maven 不再是 IDE 帮你生成的那坨 XML,而是:
-
你团队工程规范的一部分
-
你简历上"工程能力"的一部分(特别是在讲多模块、CI/CD、云原生的时候)
-
你能和别的后端 / DevOps 高效协作的基础"共同语言"
学 Maven,不是为了"背命令、背 XML 标签",
而是为了在脑子里多长出一条"从源码到制品"的清晰流水线。
当这条线清晰之后,
你在 Java 世界做的所有工程化选择 ------ 无论是 Spring、Gradle、Docker 还是 K8s ------ 都会有一个更稳的落点。