何为依赖

一个软件系统中的两个或多个模块、组件或对象之间的关系,其中一个模块(被称为被依赖项或依赖对象)需要另一个模块(被称为依赖项或依赖)来完成某些功能或任务。

软件开发效率得以提高的关键在于复用,可以站在巨人的肩膀上,可以将已经存在的工具包直接拿来使用,不用重复造轮子,这些被直接拿来使用的工具包就是「依赖」。

何为坐标

「依赖」有了之后,那怎么使用呢?在没有引入项目管理工具之前,通常是通过「复制/粘贴」方式把需要用到的依赖拷贝到项目目录下,这样做既麻烦又低效。于是相关的依赖管理工具便应运而生,而 Maven 就是其中比较优秀的一个。

为了解决使用依赖的麻烦以及低效的问题,首先就需要有一套标准化的使用和管理流程,而「坐标」就是 Maven 的依赖使用和管理的底层基础。

在 Maven 的世界中,所有的依赖都可以通过坐标来定位,坐标最基础的三个元素是:groupId,artifactId,version。绝大部分情况下,只要通过这三个坐标元素就可以定位一个依赖了。

依赖配置

了解了依赖和坐标的概念之后,我们就可以开始学习怎么使用坐标来配置依赖了。一个依赖的声明可以包含以下元素:

<project>

    <dependencies>
        <dependency>
            <groupId>...</groupId>
            <artifactId>...</artifactId>
            <version>...</version>
            <type>...</type>
            <classifier>...</classifier>
            <scope>...</scope>
            <optional>...</optional>
            <systemPath>...</systemPath>
            <exclusions>
                <exclusion> 
                    <groupId>...</groupId>
                    <artifactId>...</artifactId>
                </exclusion> 
            </exclusions>
        </dependency>
    <dependencies>

</project>
  1. groupId(实际项目),artifactId(模块名),version:基本元素,必需值,否则无法定位依赖。
  2. type:依赖类型,对应项目的 packaging 值。大部分情况无需配置,默认值为 jar。
  3. classifier:附属构建扩展坐标。
  4. scope:依赖范围,用来控制依赖生效的 classpath 范围。
  5. exclusions:排除传递性依赖。
  6. optional:依赖是否可选,值为 true 或者 false,默认为 false。当配置成 true 时,此依赖不会被传递。

我的理解:1、2、3 关注的是使用哪个依赖,4、5、6 关注的是如何使用。

接下来详细了解一下 3、4、5。

classifier

该元素用来帮助定位一些附属构建,例如主构件是 spring-web-6.0.11.jar,在生成主构件的同时可以通过插件生成 spring-web-6.0.11-javadoc.jar,spring-web-6.0.11-sources.jar 两个附属构建,javadoc、sources 就是这两个附属构建的 classifier。当然,这两种类型的包一般不会当做依赖引入。

还有一种常见的用法,当某一个项目的构建需要兼容不同版本的 jdk 时,打包是会生成多个构建,例如这样:json-lib-2.2.1-jdk9.jar、json-lib-2.2.1-jdk17.jar。其中的 jdk9,jdk17 就是 classifier,而且像这种类型的包必须指定 classifier,只通过三个基本元素是定位不到的。

scope

scope 用来规定依赖与三种 classpath 的关系,分别是:编译 classpath,测试 classpath,运行时 classpath。

这三种 classpath 分别也对应着 maven 中主程序编译,测试用例编译、执行,主程序打包这三个流程。

设置 scope 是为了更精确的控制依赖生效的 classpath 范围,使引入的依赖在该生效的 classpath 中生效,在不该生效的 classpath 中无效。这样才能避免最终运行的程序中出现依赖冗余或者缺失的情况。

scope 类型

依赖的 scope 的类型一共有 6 个,常用的是前 4 个,每一个 scope 的类型控制依赖会在某一个或者某几个 classpath 中生效。

  1. compile:默认值,此类型的依赖在主程序编译,测试用例编译、运行生效,项目打包时也会打进包里(也就是运行时 classpath)。
  2. test:只在测试用例编译、执行时生效,主程序编译及项目打包时都不会生效。
  3. provided:主程序编译,测试用例编译、执行时生效,但是程序打包时不会生效,也就是不会被打进包里,因为程序运行时的环境会提供此类型的依赖,打进包里反而可能出现依赖冲突的情况。
  4. runtime:主程序编译、测试用例编译时不会生效,测试用例执行以及主程序运行时会生效。
  5. system:同 provided,只不过使用时必须通过 <system-Path> 元素显式指定依赖文件路径,一般与本机强绑定,可能造成构建的不可移植,应谨慎使用。
  6. import:用来导入其他 pom 类型的依赖,不实际引入 jar 依赖,只用在 dependencyManagement 元素中。

简洁版的对应关系描述可以看下面的表格。

scope 与 classpath 关系

依赖范围(scope) 编译 classpath 有效 测试 classpath 有效 运行时 classpath 有效 例子
compile Y Y Y spring-core
test N Y N JUnit
provided Y Y N servlet-api
runtime N Y Y JDBC 驱动
system Y Y N 本地的,Maven 仓库之外的类库文件

exclusions

要理解 exclusions 这个元素,需要先了解下什么是传递性依赖,然后再理解为什么要排除传递性依赖。

传递性依赖

举个例子,项目 A 依赖 spring-web-5.2.8.RELEASE.jar,而 spring-web-5.2.8.RELEASE.jar 又依赖 spring-core-5.2.8.RELEASE.jar,如果要保证项目 A 能正常运行就需要把 spring-core-5.2.8.RELEASE.jar 这个依赖也添加进来,一旦依赖多了之后就会非常麻烦。Maven 的传递性依赖机制就解决了这个问题,每个项目只需要关注自己的直接依赖,间接依赖会通过传递性依赖的方式自动引入。

传递性依赖范围

依赖范围不仅可以控制依赖与三种 classpath 的关系,还对传递性依赖范围产生影响。举例,A 依赖 B,B 依赖 C,A 对于 B 是第一直接依赖,B 对于 C 是第二直接依赖,A 对于 C 是传递性依赖。第一直接依赖范围和第二直接依赖的范围决定了传递性依赖的范围。如下表,最左边一列表示第一直接依赖范围,最上面一行表示第二直接依赖范围,中间的交叉单元格则表示传递性依赖范围。 --《Maven 实战》- 许晓斌

compile test provided runtime
compile compile - - runtime
test test - - test
provided provided - provided provided
runtime runtime - - runtime

排除传递性依赖

为什么要排除传递性依赖?简单来说,有时候传递性依赖并不是我们需要的,并且还有可能导致我们想要的功能实现不了。举个例子,spring-boot-starter-logging 默认使用的是日志框架是 logback,当我们想换成 log4j2 的时候,单单引入 log4j2 的依赖并不行,因为 spring 的逻辑是只要在 classpath 中存在 logback 的包,会优先使用 logback。所以,如果我们想用 log4j2 替换掉 logback,只能使用 exclusions 将 logback 的传递性依赖排除掉,然后再加上 log4j2 的依赖。

依赖调解

得益于 Maven 的传递性依赖的机制,大大减少了声明依赖的操作,但是当发生依赖冲突时,我们需要知道如何去解决,解决的首要条件就是得知道传递性依赖是从哪条路径引入进来的。Maven 的依赖调解有两大原则:

  • 第一原则:路径最近者优先。
  • 第二原则:第一声明者优先。

例 1,有 A -> B -> C -> X(1.0) 和 A -> D -> X(2.0) 两条依赖关系,X 是 A 的传递性依赖,这个时候两条路径都有 X 这个依赖,根据第一原则,路径短的传递性依赖会生效,也就是 2.0 版本的 X 会成为 A 项目的最终传递性依赖。

例 2,有 A -> B -> Y(1.0) 和 A -> C -> Y(2.0) 两条依赖关系,根据第一原则,两个版本的 Y 依赖路径长度相同,无法调解,这个时候就需要用到第二原则,先声明的生效,也就是说在项目的 pom 文件中先定义的依赖先生效,比如 B 声明在 C 前面,那生效的传递性依赖就是 1.0 版本的 Y,反之则是 2.0 版本的 Y。

最佳实践

在了解的 Maven 依赖相关的内容之后,这里有几条实践的建议供大家参考:

  1. 排除 snapshot 版本的依赖,只使用 release 版本
  2. 归类依赖,把来自同一项目的不同模块的依赖版本号统一定义在一个地方,其他地方直接引用
  3. 优化依赖,将 Used undeclared dependencies 显示声明,Unused declared dependencies 仔细检查,确实无效的可以删除,但是一定要谨慎测试验证。