Maven的坐标和依赖
何为坐标
前面说过,Maven的一大功能是管理项目依赖。为了能自动化地解析任何一个Java构件,Maven就必须将它们唯一标识,这就依赖管理的底层基础——坐标。
关于坐标(Coordinate),大家最熟悉的定义应该来自于立体几何。在一个立体坐标系中,该立体空间内的任何一个点,都能够用坐标(x,y,z)唯一标识。在实际生活中,我们可以将地址看成一种坐标。省市县等一系列信息同样可以唯一标识城市中的任一居住地址,邮局和快递公司正是基于这样一种坐标进行邮件寄送的。
Maven世界中拥有数量非常巨大的构件,如:jar、war等文件。在Maven为这些构件引入坐标概念之前,我们无法使用任何一种方式来唯一标识所有这些构件。因此,当需要用到Spring Framework依赖的时候,大家会去Spring Framework网站寻找,当需要用到log4j依赖的时候,大家又会去Apache网站寻找。又因为各个项目的网站风格迥异,大量的时间花费在了搜索、浏览网页和下载构件。Maven定义了这样的一组规则:世界上任何一个构件都可以使用Maven坐标唯一标识,Maven坐标的元素包括GroupId、artfactId、version、packaging、classifier,现在只要我们提供正确的坐标,Maven就能够帮助我们找到对应的构件。
也许你会奇怪,“Maven是从哪里下载构件呢?”答案很简单,Maven内置了一个中央仓库地址(http://repo1.maven.org/maven2),该中央仓库包含了世界上大部分流行的开源项目构件,Maven会在需要的时候去那里下载。
在我们开发Maven项目的时候,也需要定义适当的坐标,这是Maven强制要求的。在这个基础上,其它的Maven项目才能引用该项目生成的构件。
坐标详解
Maven坐标为各种构件引入了秩序,任何一个构件都必须明确定义自己的坐标,而一组Maven坐标是通过一些元素定义的,它们是GroupId、artfactId、version、packaging、classifier。坐标定义如下:
<groupId>org.sonatype.nexus</groupId> <artifactId>nexus-indexer</artifactId> <version>2.0.0</version> <packaging>jar</packaging>
- groupId:定义当前Maven项目所属的实际项目。首先,Maven项目和实际项目不一定是一对一的关系。比如SpringFramework这一实际项目,其对应的Maven项目会有很多,如spring-core、spring-context等。这是由于Maven中模块的概念,因此一个实际项目往往会被划分为很多模块。推荐的做法是使用项目隶属的组织或公司的反向域名作为前缀,后跟实际项目名称。如上例:GroupId为org.sonatype.nexus,org.sonatype表示Sonatype公司建立的一个非营利性组织,nexus表示Nexus这一实际项目。
- artifactId:该元素定义实际项目中的一个Maven项目(模块),推荐的做法是使用实际项目名称作为前缀,比如上例中,artfactId是nexus-indexer,使用了实际项目名nexus作为前缀,这样做的好处是方便寻找实际构件。默认情况下,Maven生成的构件,其文件名会以artfactId作为开头。
- version:该元素定义Maven项目当前所处的版本,如上例中nexus-indexer的版本是2.0.0。
- packaging:该元素定义项目的打包方式。首先,打包方式通常于所生成构件的文件扩展名对应,如上例中packaging为jar,最终的文件名为nexus-indexer-2.0.0.jar,而用war打包方式的Maven项目,最终生成的构件会有一个.war文件。其次,打包方式会影响到构件的生命周期,比如jar和war会使用不同的打包命令。当不定义packaging时,Maven会使用默认值jar。
- classifier:该元素用来帮助定义构建输出的一些附属构件。附属构件与主构件对应,如上例中的主构件是nexus-indexer-2.0.0.jar,该项目可能还会用过使用一些插件生成nexus-indexer-2.0.0-javadoc.jar、nexus-indexer-2.0.0-sources.jar这样一些附属构件,其中包含了Java文档和源代码。这时候,javadoc和sources就是这两个附属构件的classifier。这样,附属构件也就拥有了自己唯一的坐标。
创建Maven项目
现在,我们试着用idea来创建一个Maven项目,选中标有红框的骨架,点击Next
然后我们设定项目的groupId和artifactId
idea默认会使用自带的Maven,我们要将Maven Home的目录指向我们之前设定好的,另外还要重写配置文件的目录:
最后点击Finish,idea会帮助我们构建Maven项目的骨架,我们就不用再去动手生成pom.xml、以及Maven要要求的main和test目录了,这些idea会帮我们设定好。项目构建完毕后,目录结构如下:
删除idea帮我们创建好的App和AppTest文件,然后我们在main\java\com\leolin\mvnbook和test\java\com\leolin\mvnbook分别创建Hello.java、HelloTest.java文件。代码如下:
Hello.java
package com.leolin.mvnbook; public class Hello { public String sayHello(String name) { return "Hello " + name; } }
HelloTest.java
package com.leolin.mvnbook; import org.junit.Test; import static junit.framework.Assert.*; public class HelloTest { @Test public void testHello() { Hello hello = new Hello(); String result = hello.sayHello("Jack"); assertEquals("Hello Jack", result); } }
然后,我们在pom.xml文件中添加spring的依赖:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>5.2.5.RELEASE</version> </dependency>
然后,我们右击项目→Maven→Reimport,便可以看到项目本身新增了Spring依赖:
之后,我们将基于hello项目本身来学习依赖范围。
依赖范围
Maven在编译主项目代码的时候需要使用一套classpath。在上例中,编译主项目代码的时候需要用到spring-core,该文件以依赖的方式被引入到classpath中。其次,Maven在编译和执行测试的时候会使用另一套classpath。上例中的JUnit就是一个很好的例子,该文件以依赖的方式引入到测试使用的classpath中,不同的是这里的依赖范围是test。最后,实际运行Maven项目的时候,又会使用一套classpath,上例中的spring-core需要在该classpath中,而JUnit则不需要。
依赖范围就是用来控制依赖与这三种classpath(编译classpath、测试classpath、运行classpath)的关系,Maven有以下几种依赖范围:
- compile:编译依赖范围。如果没有指定,就会默认使用该依赖范围。使用此依赖范围的Maven依赖,对于编译、测试、运行三种classpath都有效。典型的例子就是spring-core,在编译、测试和运行的时候都需要使用这种依赖。
- test:测试依赖范围。使用此依赖范围的Maven依赖,只对于测试的classpath有效,在编译主代码或者运行主代码的时候都无法依赖此类依赖。典型的例子是jUnit,它只有在编译测试代码及运行测试代码的时候才需要。
- provided:已提供依赖范围。使用此依赖范围的Maven依赖,对于编译和测试classpath有效,但在运行时无效。典型的例子是servlet-api,编译和测试项目的时候需要该依赖,但在运行的时候,由于容器已经提供,就不需要maven重复地引入一遍。
- runtime:运行时依赖范围。使用此依赖范围的maven依赖,对于测试和运行classpath有效,但在编译主代码时无效。典型的例子是JDBC驱动实现,项目主代码的编译只需要JDK提供的JDBC的接口,只有在执行测试或者运行项目的时候才需要实现上述接口的具体JDBC的驱动。
- system:系统依赖范围。该依赖与三种classpath的关系,和provided是一样的。但是,使用system范围的依赖时不过被依赖时必须通过systemPath元素显式指定依赖文件的路径。由于此类依赖不是通过Maven仓库解析的,而且往往与本机系统绑定,可能造成构建的不可移植,因此要谨慎使用。systemPath可以引用环境变量,如:
<dependencies> <dependency> <groupId>javax.sql</groupId> <artifactId>jdbc-stdext</artifactId> <version>2.0</version> <scope>system</scope> <systemPath>${java.home}/lib/rt.jar</systemPath> </dependency> <dependencies></span>
- import:导入依赖范围。该依赖范围不会对三种classpath产生实际的影响。只有在dependencyManagement下才有效果。
传递性依赖
考虑一个基于Spring Framework的项目,如果不使用Maven,那么在项目中就需要手动下载依赖。由于Spring Framework又依赖于其他开源类库,我们又得手动去下载,这是一件非常麻烦的事情。Maven的传递性依赖机制可以很好地解决这一问题,之前我们在hello项目的pom.xml文件中引入了spring-core。现在,让我们再创建一个新项目,并使其依赖hello项目,但我们不再新项目中引入spring-core,看看新项目是否能正常运行。
首先,我们要对原先的hello项目执行mvn clean install,将其放到Maven仓库中。我们创建一个新的hello-friends的Maven项目,并在pom.xml的<dependencies>元素中放入hello依赖:
<dependency> <groupId>com.leolin.mvnbook</groupId> <artifactId>hello</artifactId> <version>1.0-SNAPSHOT</version> </dependency>
将hello-friends项目重新Reimport,然后看看hello-friends本身的依赖:
可以看到在hello-friends的pom.xml文件中,即便我们没有显式地引入spring-core,但Maven依旧帮助我们引入spring-core。有了传递性依赖机制,在使用Spring Framework的时候就不需要考虑它依赖什么了,也不用担心引入多余的依赖。Maven会解析各种直接依赖的POM,将那些必要的间接依赖,以传递性依赖的形式引入到当前项目中。假设A依赖于B,B依赖于C,我们可以说A对于B是第一直接依赖,B对于C是第二直接依赖,A对于C是传递性依赖。
依赖调解
Maven引入的传递性依赖机制,大大简化和方便了依赖声明。但有的时候,传递性依赖也可能会给我们带来麻烦。例如项目A有这样的依赖关系A→B→C→X(1.0)、A→D→X(2.0),X是A的传递性依赖,但两条依赖路径上有两个版本的X,那么哪个版本的X会被Maven引用呢?Maven依赖调解的第一原则是:路径最近者优先。该例中X(1.0)的路径长度为3,而X(2.0)的路径长度为2,因此X(2.0)会被引用。
但有时最短路径不能解决所有问题,比如A→B→Y(1.0)、A→C→Y(2.0),Y(1.0)和Y(2.0)的依赖路径都是一样的,都为2,那么Maven会使用第二原则,第一声明者优先,在依赖路径长度相等的情况下,在POM中依赖声明的顺序决定了谁会被引用。,如果B的依赖声明在C之前,那么Y(1.0)就会被引用。
可选依赖
假设有这样一个依赖关系,A依赖与B,B依赖于X和Y,B对X和Y的依赖都是可选依赖:A→B、B→X(可选)、B→Y(可选)。由于X和Y都是可选依赖,依赖将不会传递,也就是X和Y不会对A有什么影响。
为什么会有可选依赖呢?可能项目B实现了两种特性,其中一种特性依赖X,另一种依赖于Y,而且这两种特性互斥,用户不能同时使用这两种特性,比如B是一个持久层隔离工具包,它支持多种数据库MySQL、PostgrepSQL等,在构建这个工具包的时候,需要这两种数据库的驱动程序,但再使用这个工具包时,只依赖一种数据库。
下面是项目B可选依赖的配置:
<project> <modelVerion>4.0</modelVersion> <groupId>com.leolin.mvnbook</groupId> <artifactId>project-b</artifactId> <version>1.0.0</version> <dependencies> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.10</version> <optional>true</optional> </dependency> <dependency> <groupId>postgresql</groupId> <artifactId>postagresql</artifactId> <version>8.4-701.jdbc3</version> <optional>true</optional> </dependency> </dependencies> </project>
在上述XML代码中,使用<optional>元素表示MySQL和PostgrepSQL这两种依赖为可选依赖,它们只会对当前项目B产生影响,当A依赖于B时,如果A使用的是MySQL数据库,那么只需要在A中显示地声明MySQL的依赖,如下:
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.leolin.mvnbook</groupId> <artifactId>project-a</artifactId> <version>1.0.0</version> <dependencies> <dependency> <groupId>com.leolin.mvnbook</groupId> <artifactId>project-b</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.10</version> </dependency> </dependencies> </project>
最后,关于可选依赖需要说明的一点是,在理想的情况下,是不应该使用可选依赖的。前面我们可以看到,使用可选依赖的原因是某一个项目实现了多个特性,在面向对象设计中,有个单一职责性原则,意指一个类应该只有一项职责,而不是糅合太多的功能 。这个原则在规划maven项目的时候也同样适用。在上面的例子中,更好的做法是为MySQL和PostgrepSQL分别创建一个maven项目,基于同样的groupId分配不同的artifactId。
排除依赖
来看一个场景,现在有这样的项目依赖:A→B→C(SNAPSHOT-0.1),但C(SNAPSHOT-0.1)是不稳定版本,存在一些BUG,并影响到项目A的运行,我们想引入C(1.0.0)版本的依赖。可以在pom.xml文件中使用元素exclusion来排除依赖:
<project> <modelVersion>4.0.0</modelVersion> <groupId>com.leolin.maven</groupId> <artifactId>project-a</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <dependencies> <dependency> <groupId>com.leolin.maven</groupId> <artifactId>project-b</artifactId> <version>1.0.0</version> <exclusions> <exclusion> <groupId>com.leolin.maven</groupId> <artifactId>project-c</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>com.leolin.maven</groupId> <artifactId>project-c</artifactId> <version>1.1.0</version> </dependency> </dependencies> </project>
代码中使用exclusions元素声明排除依赖,exclusions可以包含一个或者多个exclusion子元素,因此可以排除一个或者多个传递性依赖。需要注意的是,声明exclusion的时候只需要groupId和artifactId,而不需要version元素,这是因为只需要groupId和artifactId就能唯一定位依赖图中的某个依赖。换句话说,maven解析后的依赖中,不可能出现groupId和artifactId项目,但是version不同的两个依赖。
归类依赖
关于Spring Framework的依赖有很多,他们来自同一个项目的不同模块。比如:org.springframework:spring-core:2.5.6、org.springframework:spring-beans:2.5.6、org.springframework:spring-context:2.5.6,它们是来自同一项目的不同模块。可以预见,未来如果要升级Spring Framework,这些依赖版本会一起升级,这意味着我们要修改多处依赖的版本号。为了提高我们的开发效率,Maven为我们提供了常量这一做法:
<properties> <springframework.version>2.5.6</springframework.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-core</artifactId> <version>${springframework.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-beans</artifactId> <version>${springframework.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>${springframework.version}</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-support</artifactId> <version>${springframework.version}</version> </dependency> </dependencies>
这里简单使用到了Maven的属性,首先使用properties元素定义了Maven的属性,该例子中定义了一个springframework.version子元素,其值为2.5.6,有了这个属性之后,Maven运行的时候,会将POM中所有${springframework.version}替换成2.5.6。也就是说,可以使用美元符号和大括弧环绕${}的方式来引用Maven的属性,然后将所有Spring Framework依赖的版本值用这一属性引用。