一文彻底搞懂 Maven 依赖——从 <dependency> 到依赖冲突,带你看懂 Maven 的“江湖规矩”

486 阅读6分钟

🌱 一、前言:为什么要研究依赖?

写 Java 项目,谁没被 Maven “支配”过呢?

你加了个 Spring Boot Starter,结果一堆库跟着进来;
别人告诉你“scope 写错了”;
编译正常但运行报错,或者 jar 包体积暴涨到 200MB。

这一切背后,其实都是 Maven 依赖系统 在发挥作用。

要真正掌握 Maven,就得先搞清楚:

“依赖是什么?”、“它怎么传递?”、“怎么解决冲突?”、“什么时候该 provided?”


🧱 二、依赖的本质:三段坐标

Maven 的核心设计哲学之一是“声明式依赖”。
你不需要手动下载 jar,只要写出三个坐标:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>3.3.2</version>
</dependency>

这三个坐标就像一个图书馆的“索书号”:

  • groupId:组织名(相当于出版社)
  • artifactId:模块名(相当于书名)
  • version:版本号(相当于第几版)

Maven 仓库 = 全球最大“开源图书馆”
依赖声明 = 你要借的“书目清单”

元素含义
<groupId>组织或公司标识
<artifactId>模块名称
<version>版本号
<scope>依赖作用范围(compile、provided、runtime...)
<optional>是否为可选依赖
<exclusions>排除指定传递依赖

🧩 三、Maven 的依赖来源

Maven 在解析依赖时,会按照以下顺序查找 jar 包:

  1. 本地仓库~/.m2/repository
    → 最近一次构建下载过的包会被缓存到这里。
  2. 远程中央仓库https://repo.maven.apache.org/maven2/
    → Maven 官方中央仓库。
  3. 私有仓库(公司 Nexus / Artifactory)
    → 企业内部维护的依赖镜像。

Maven 会自动从上往下找,找不到就报错:

“Could not resolve dependencies…”


🧠 四、依赖范围(Scope)详解

Scope 是 Maven 的依赖生命周期规则,定义了依赖在哪些阶段可用、是否参与打包、是否传递。

Scope编译时可见测试时可见运行时可见打包带上可传递典型场景
compile默认值,大多数库
provided容器已提供(Servlet、Lombok)
runtimeJDBC Driver、Logback
testJUnit、Mockito
system手动指定 jar
import仅用于依赖管理

🧩 五、每种 Scope 的典型示例

1️⃣ compile —— 默认的依赖方式

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.14.0</version>
</dependency>

特点:

  • 编译、运行、测试全阶段可用;
  • 可传递;
  • 打包会带上。

适合:核心依赖(比如 Spring Context、Apache Commons)。


2️⃣ provided —— 编译要用,运行别带

<dependency>
    <groupId>javax.servlet</groupId>
    <artifactId>javax.servlet-api</artifactId>
    <version>4.0.1</version>
    <scope>provided</scope>
</dependency>

适合:由容器(Tomcat、Jetty)或环境提供的类库。
打包带上会冲突。


3️⃣ runtime —— 运行时才需要的依赖

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>9.1.0</version>
    <scope>runtime</scope>
</dependency>

特点:

  • 编译不需要(用接口即可);
  • 运行时才加载;
  • 打包会带上。

适合:数据库驱动、日志实现等。


4️⃣ test —— 仅在测试阶段使用

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.0</version>
    <scope>test</scope>
</dependency>

不会参与最终打包,测试用完即止。


5️⃣ system —— 手动指定路径

<dependency>
    <groupId>com.company</groupId>
    <artifactId>internal-lib</artifactId>
    <version>1.0</version>
    <scope>system</scope>
    <systemPath>${project.basedir}/lib/internal-lib.jar</systemPath>
</dependency>

⚠️ 注意:

  • 不推荐使用;
  • 不可传递;
  • 会破坏构建的可移植性。

6️⃣ import —— 依赖版本管理用

用于在 dependencyManagement 中引入 BOM(Bill of Materials)

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.3.2</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

它不会引入依赖本身,只是导入一组“版本约定”。


🧩 六、依赖传递机制:Maven 的“层层借书”

假设:

  • A → 依赖 B
  • B → 依赖 C

则 A 间接依赖了 C(称为传递依赖)。

Maven 的传递规则如下:

A 的 ScopeB 的 ScopeC 是否传递说明
compilecompile默认传递
compileprovided不传递
providedcompile不传递
test任意不传递
runtimecompile/runtime传递

简单理解:

只有“compile”或“runtime”的依赖才会往下传递。
test / provided 不会传递。


⚔️ 七、依赖冲突与解决策略

当两个不同版本的相同依赖出现时:

  • 最近路径优先(Nearest Definition Wins)
    → Maven 会选择依赖树中路径最短的版本。

例:

ABcommons-lang3:3.12.0  
ACcommons-lang3:3.14.0

A 直接依赖 C 的路径更短,则取 3.14.0。

如果两者路径一样长:

  • 则选择 声明顺序靠前 的依赖。

💡 查看依赖树命令:

mvn dependency:tree

可查看传递依赖及冲突来源。


🔧 强制指定版本:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.14.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

dependencyManagement 只定义版本,不自动引入依赖。


🧩 八、依赖排除(Exclusion)

有时候我们不想要某个传递依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

比如:自己要用 Undertow 或 Jetty,而不想要 Tomcat。


🧩 九、最佳实践总结

场景Scope 建议原因
普通库依赖compile默认
容器内置库(Servlet、JSP)provided环境已提供
运行时驱动(JDBC、日志实现)runtime只运行时用
测试框架test不参与打包
编译工具(Lombok、MapStruct)provided编译期生效
公司内部 jarsystem(慎用)构建可移植性差
统一管理版本import(BOM)方便升级维护

依赖管理,是项目整洁性的基石。
依赖范围(Scope)决定了依赖的“生死周期”;
依赖传递规则决定了“家族关系”;
依赖冲突解决机制,则是 Maven 的“江湖规矩”。

💡 记忆口诀:

compile → 全能型
provided → 借用不打包
runtime → 运行才用
test → 只在测试用

像玩 RPG 游戏一样,你给每个依赖分配“职业技能”,
打包、传递、运行都明明白白,不再踩坑!

🧩 十、<optional> —— 控制“依赖传递”的另一种方式

现在我们聊聊另一个常被忽略的兄弟:<optional>

<optional> 用于告诉 Maven:

“我这里用了一个依赖,但我不想把它传递给下游项目。”

例子:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-simple</artifactId>
    <version>2.0.9</version>
    <optional>true</optional>
</dependency>

这意味着:

  • 当前模块能用 slf4j-simple
  • 但依赖此模块的下游项目不会自动拿到它;
  • 如果想用,必须手动声明。

✅ 使用场景

场景是否适合
SDK、框架模块✅ 非常推荐
Spring Boot Starter✅ 常用
应用层⚠️ 一般不用
工具类库❌ 不推荐

⚔️ optional vs provided

特征<optional>true</optional><scope>provided</scope>
控制对象依赖传递生命周期
编译期可见
运行期可见❌(环境提供)
传递性❌ 不传递❌ 不传递
场景模块设计、SDKWeb 环境、容器依赖

📎 通俗地说:

  • scope 决定“何时使用”;
  • optional 决定“要不要传下去”。

🧩 十一、依赖冲突与解决规则

Maven 在面对同一个依赖的多个版本时,遵循两条核心规则:

  1. 最近路径优先(Nearest Definition Wins)
    —— 谁离当前模块更近,用谁。
  2. 先声明优先(First Declaration Wins)
    —— 同层级冲突时,谁先写谁赢。

可通过以下命令查看依赖树:

mvn dependency:tree

🧠 十二、全景图:Maven 依赖生命周期与传递机制(附图)

📊 下图展示了 Scope 对编译、运行、测试、打包阶段的影响,以及依赖传递与冲突决策逻辑。

cc.png


🧾 十四、总结与金句彩蛋 🎁

元素控制内容核心作用
<scope>生命周期控制在哪些阶段可见
<optional>传递性决定是否下游继承
<exclusions>精准排除清理依赖树

🎯 一句话记忆

<scope> 管“时间”,
<optional> 管“范围”,
<exclusions> 管“洁癖”。


💬 尾声:让依赖管理优雅如诗 🌸

“优秀的 Maven 工程,不是依赖越多越强,
而是边界清晰、传递干净、结构优雅。”

每次写 <dependency>,都像在雕琢项目的骨架。
当你真正理解 scopeoptional 与传递关系的微妙平衡,
你就离“构建大师”更近一步了。