多模块 Maven 项目的管理(一)

发布于:2025-03-01 ⋅ 阅读:(15) ⋅ 点赞:(0)

为什么选择多模块 Maven 项目

在 Java 项目开发的版图中,Maven 早已成为构建和依赖管理的中流砥柱 。而随着项目规模的不断膨胀,功能的日益复杂,单模块 Maven 项目逐渐显得力不从心,多模块 Maven 项目应运而生,成为大型项目开发的得力助手。

单模块项目的困境

单模块 Maven 项目,就像是一个功能齐全但空间有限的小公寓,所有的功能区域(代码、测试、资源等)都挤在一个空间里。在项目规模较小、功能简单时,它的开发和维护成本较低,易于上手,就像小公寓很容易打理一样。但随着项目的发展,代码量急剧增加,功能模块越来越多,单模块项目就会陷入混乱。比如,一个电商项目,如果采用单模块开发,用户模块、商品模块、订单模块等所有代码都混在一起,代码结构会变得错综复杂,维护和扩展难度极大。每次修改一个小功能,都可能牵一发而动全身,引发意想不到的问题。而且,单模块项目的依赖管理也很麻烦,所有依赖都集中在一个pom.xml文件中,版本冲突问题难以解决,就像小公寓里东西太多,找东西都变得困难。

多模块项目的优势

多模块 Maven 项目则像是一座功能分区明确的大型别墅,每个模块都有自己独立的空间和职责,互不干扰。以电商项目为例,我们可以将其拆分为用户模块、商品模块、订单模块、公共模块等。每个模块都有自己独立的pom.xml文件,负责管理本模块的依赖和构建配置。这样做的好处是显而易见的:

  • 代码复用:公共模块可以存放通用的工具类、配置文件等,供其他模块复用。比如,日志记录工具、数据库连接池配置等,在多个模块中都需要用到,放在公共模块中,避免了重复开发,提高了开发效率。
  • 依赖管理:每个模块可以独立管理自己的依赖,避免了依赖冲突。同时,通过父模块的dependencyManagement标签,可以统一管理所有子模块的依赖版本,确保整个项目的依赖一致性。比如,所有子模块都使用相同版本的 Spring 框架,只需要在父模块中统一配置,子模块继承即可。
  • 构建速度:在多模块项目中,当某个模块的代码发生变化时,只需要重新构建该模块,而不需要重新构建整个项目,大大缩短了构建时间。这对于大型项目来说,能显著提高开发效率。
  • 团队协作:每个模块可以由不同的团队或开发人员负责开发和维护,职责清晰,分工明确,减少了团队成员之间的代码冲突,提高了团队协作效率。

搭建多模块 Maven 项目

项目目录结构规划

搭建多模块 Maven 项目,就像建造一座大型建筑,首先要规划好项目目录结构,这是项目的基础框架。常见的多模块 Maven 项目目录结构如下:


my-multi-module-project

├── pom.xml

├── module1

│ ├── src

│ │ ├── main

│ │ │ ├── java

│ │ │ │ └── com

│ │ │ │ └── example

│ │ │ │ └── module1

│ │ │ │ └── *

│ │ │ └── resources

│ │ │ └── *

│ │ └── test

│ │ ├── java

│ │ │ └── com

│ │ │ └── example

│ │ │ └── module1

│ │ │ └── *

│ │ └── resources

│ │ └── *

│ └── pom.xml

├── module2

│ ├── src

│ │ ├── main

│ │ │ ├── java

│ │ │ │ └── com

│ │ │ │ └── example

│ │ │ │ └── module2

│ │ │ │ └── *

│ │ │ └── resources

│ │ │ └── *

│ │ └── test

│ │ ├── java

│ │ │ └── com

│ │ │ └── example

│ │ │ └── module2

│ │ │ └── *

│ │ └── resources

│ │ └── *

│ └── pom.xml

└──...

  • 顶层目录:包含项目的总pom.xml文件,它是整个项目的核心配置文件,负责管理项目的整体信息、模块关系以及统一的依赖版本等,就像建筑的总蓝图。
  • 模块目录(如module1module2:每个模块都有自己独立的目录,包含各自的src目录和pom.xml文件。src目录又分为main和test两个子目录,分别存放主代码和测试代码。main/java目录存放 Java 源代码,main/resources目录存放配置文件、资源文件等,这些文件是模块运行的关键部分,好比建筑中每个房间的具体设施和装修;test/java目录存放单元测试代码,test/resources目录存放测试相关的资源文件,用于验证模块的功能是否正确,就像建筑的质量检测环节。每个模块的pom.xml文件负责管理本模块的依赖和构建配置,定义了该模块的独特需求和特性。

父项目的 pom.xml 配置

父项目的pom.xml文件是整个多模块项目的指挥中心,其中关键标签的配置至关重要。

  • <modelVersion>:固定为4.0.0,它定义了 POM 模型的版本,告诉 Maven 使用哪个版本的 POM 规范来解析和处理这个文件,就像给 Maven 提供了一把解读配置的钥匙。
  • <groupId><artifactId><version>:这三个标签定义了项目的坐标,是项目在 Maven 仓库中的唯一标识。groupId通常是公司或组织的域名倒写,如com.example,用于标识项目所属的组织或团队,就像家庭地址中的小区名称;artifactId是项目的唯一标识符,如my-multi-module-project,用于区分同一组织下的不同项目,类似于家庭地址中的门牌号;version表示项目的版本号,如1.0.0,用于区分项目的不同版本,方便进行版本管理和依赖控制,就像软件的更新版本号。
  • <packaging>:对于父项目,通常设置为pom,表示这是一个聚合项目,主要用于管理子模块,而不包含实际的代码,它就像一个项目的组织者,负责协调各个子模块之间的关系。
  • <modules>:这个标签用于声明项目包含的子模块。每个<module>标签的值是子模块的目录名称,例如:

<modules>

<module>module1</module>

<module>module2</module>

</modules>

通过这种方式,父项目可以将各个子模块聚合在一起,方便统一管理和构建,就像把各个房间组合成一座完整的建筑。

  • <properties>:用于定义项目的属性,这些属性可以在整个项目中复用,提高配置的灵活性和可维护性。比如:

<properties>

<java.version>1.8</java.version>

<spring.version>5.3.10</spring.version>

</properties>

在后续的依赖或插件配置中,可以通过${propertyName}的方式引用这些属性,例如在依赖配置中使用${spring.version}来指定 Spring 框架的版本,这样如果需要修改版本号,只需要在<properties>标签中修改一次即可,而不需要在整个项目中到处查找和修改版本号,大大提高了项目的维护效率。

  • <dependencyManagement>:这是一个非常重要的标签,用于集中管理项目的依赖版本。在这个标签内声明的依赖不会实际引入到项目中,但子模块可以继承这些依赖的版本信息。例如:

<dependencyManagement>

<dependencies>

<dependency>

<groupId>org.springframework</groupId>

<artifactId>spring-core</artifactId>

<version>${spring.version}</version>

</dependency>

</dependencies>

</dependencyManagement>

这样,在子模块中引入spring-core依赖时,只需要指定groupId和artifactId,不需要指定版本号,子模块会自动继承父项目中定义的版本号,确保整个项目中依赖版本的一致性,避免了因版本不一致导致的各种问题,就像给所有房间统一配备相同规格的设施。

  • <build>:用于配置项目的构建过程,包括插件的配置等。例如,配置maven-compiler-plugin插件,指定编译的 Java 版本:

<build>

<plugins>

<plugin>

<groupId>org.apache.maven.plugins</groupId>

<artifactId>maven-compiler-plugin</artifactId>

<version>3.8.1</version>

<configuration>

<source>${java.version}</source>

<target>${java.version}</target>

</configuration>

</plugin>

</plugins>

</build>

通过这个配置,Maven 在编译项目时会使用指定的 Java 版本,确保项目的编译环境与预期一致,就像按照建筑图纸的要求选择合适的建筑材料和工具。

子项目的 pom.xml 配置

子项目的pom.xml文件主要是继承父项目的配置,并可以根据自身需求定义特有的依赖和插件。

  • 继承父项目配置:在子项目的pom.xml文件中,通过<parent>标签来指定父项目的坐标,从而继承父项目的配置。例如:

<parent>

<groupId>com.example</groupId>

<artifactId>my-multi-module-project</artifactId>

<version>1.0.0</version>

</parent>

子项目会继承父项目的依赖版本管理、插件配置等信息,减少了重复配置,提高了开发效率。同时,子项目可以省略groupId和version标签,因为它们会自动继承父项目的相应值,就像孩子继承了父母的部分特征。

  • 定义自身特有的依赖:子项目可以根据自身的功能需求,在<dependencies>标签中定义自己特有的依赖。例如,module1模块可能需要引入spring-web依赖:

<dependencies>

<dependency>

<groupId>org.springframework</groupId>

<artifactId>spring-web</artifactId>

</dependency>

</dependencies>

由于父项目已经在<dependencyManagement>中管理了spring相关依赖的版本,所以子项目在引入依赖时不需要再次指定版本号,Maven 会自动使用父项目中定义的版本,这样既保证了依赖版本的一致性,又满足了子项目的个性化需求,就像在继承了家庭共性的基础上,每个成员又有自己独特的个性和需求。

  • 定义自身特有的插件:如果子项目有特殊的构建需求,也可以在<build>标签中定义自己特有的插件。例如,module2模块可能需要使用maven-assembly-plugin插件来生成包含所有依赖的可执行 JAR 包:

<build>

<plugins>

<plugin>

<groupId>org.apache.maven.plugins</groupId>

<artifactId>maven-assembly-plugin</artifactId>

<version>3.3.0</version>

<configuration>

<descriptorRefs>

<descriptorRef>jar-with-dependencies</descriptorRef>

</descriptorRefs>

</configuration>

<executions>

<execution>

<id>make-assembly</id>

<phase>package</phase>

<goals>

<goal>single</goal>

</goals>

</execution>

</executions>

</plugin>

</plugins>

</build>

通过这个配置,module2模块在打包时会生成一个包含所有依赖的 JAR 包,方便项目的部署和运行,这体现了子项目在继承父项目基本构建流程的基础上,根据自身需求进行个性化定制的能力,就像每个房间在整体建筑风格统一的前提下,可以根据功能需求进行独特的装修和布置。

多模块项目的依赖管理

依赖的传递性

在多模块 Maven 项目中,依赖的传递性是一个重要特性,它就像一张无形的网,将各个模块之间的依赖关系紧密相连 。依赖传递的原理是,当一个模块引入了一个依赖,这个依赖又依赖其他的依赖,Maven 会自动解析这些依赖关系,并将所需的依赖项下载到本地仓库,从而使依赖在模块之间传递。

假设我们有一个多模块项目,包含moduleA、moduleB和moduleC三个模块。moduleA依赖于moduleB,moduleB又依赖于moduleC。在moduleA的pom.xml文件中,我们只需要引入moduleB的依赖:


<dependencies>

<dependency>

<groupId>com.example</groupId>

<artifactId>moduleB</artifactId>

<version>1.0.0</version>

</dependency>

</dependencies>

由于依赖的传递性,moduleA会自动获得moduleB所依赖的moduleC的依赖。这意味着moduleA可以直接使用moduleC中暴露的功能,而无需在moduleA的pom.xml中显式引入moduleC的依赖。

不同类型依赖的传递规则有所不同,主要体现在依赖范围(scope)上。Maven 定义了多种依赖范围,常见的有compile、provided、runtime、test等。

  • compile:这是默认的依赖范围,表示依赖在编译、测试和运行时都需要。这种依赖具有完全的传递性,即如果A依赖B(compile范围),B依赖C(compile范围),那么A也会依赖C。例如,moduleA依赖moduleB(compile范围),moduleB依赖spring-core(compile范围),那么moduleA也能使用spring-core的功能。
  • provided:表示依赖在编译和测试时需要,但在运行时由外部环境提供,如容器或 JDK。这种依赖的传递性是有限的,它只会传递到直接依赖它的模块,不会继续向下传递。比如,moduleA依赖moduleB(provided范围),moduleB依赖servlet-api(provided范围),moduleA不会因为依赖moduleB而自动依赖servlet-api,如果moduleA需要使用servlet-api,则需要在moduleA的pom.xml中显式引入。
  • runtime:表示依赖在测试和运行时需要,但在编译时不需要。它的传递性与compile类似,但在编译阶段不会传递。例如,moduleA依赖moduleB(runtime范围),moduleB依赖mysql-connector-java(runtime范围),moduleA在测试和运行时可以使用mysql-connector-java,但在编译时不会引入该依赖。
  • test:表示依赖仅在测试时需要,不会传递到依赖该项目的其他模块中,也不会参与打包和部署。例如,moduleA依赖moduleB(test范围),moduleB依赖junit(test范围),moduleA不会因为依赖moduleB而依赖junit,只有在moduleB进行测试时才会使用junit。

依赖的传递性大大简化了项目的依赖管理,避免了重复引入相同的依赖,提高了开发效率,但同时也需要我们清楚了解不同依赖范围的传递规则,以避免因依赖传递而带来的问题 。

依赖冲突的解决

在多模块 Maven 项目中,依赖冲突是一个常见且棘手的问题,它就像隐藏在项目中的定时炸弹,随时可能引发项目构建失败或运行时错误 。依赖冲突通常是由于不同模块引入了同一个依赖的不同版本,或者依赖的传递过程中出现了版本不一致的情况。

例如,moduleA依赖libraryX的1.0.0版本,moduleB依赖libraryX的2.0.0版本,当moduleC同时依赖moduleA和moduleB时,就会出现依赖冲突。Maven 在处理依赖冲突时,会遵循一定的原则,其中最主要的是路径优先原则和声明优先原则。

  • 路径优先原则:在依赖传递路径上离项目根节点最近的依赖项版本优先。也就是说,如果一个依赖项在依赖传递路径上离项目更近,它的版本会被优先使用。例如,A -> B -> C -> D(1.0)和A -> E -> D(2.0),由于从A到D通过E的路径更短,所以D(2.0)会被使用。
  • 声明优先原则:如果在同一层级上有多个依赖项引入了同一个依赖项的不同版本,那么会选择首次声明的依赖项版本。比如,在pom.xml中同时引入了libraryX的两个版本:

<dependencies>

<dependency>

<groupId>com.example</groupId>

<artifactId>libraryX</artifactId>

<version>1.0.0</version>

</dependency>

<dependency>

<groupId>com.example</groupId>

<artifactId>libraryX</artifactId>

<version>2.0.0</version>

</dependency>

</dependencies>

按照声明优先原则,1.0.0版本会被优先使用。

虽然 Maven 有这些原则来处理依赖冲突,但在实际项目中,这些原则并不总是能满足我们的需求,因此我们需要主动采取一些方法来解决依赖冲突 。

  • 使用<exclusions>标签:通过<exclusions>标签可以排除不需要的传递依赖,从而避免依赖冲突。例如,moduleA依赖moduleB,而moduleB依赖的libraryX版本与moduleA所需的版本冲突,我们可以在moduleA的pom.xml中排除moduleB对libraryX的依赖:

<dependencies>

<dependency>

<groupId>com.example</groupId>

<artifactId>moduleB</artifactId>

<version>1.0.0</version>

<exclusions>

<exclusion>

<groupId>com.example</groupId>

<artifactId>libraryX</artifactId>

</exclusion>

</exclusions>

</dependency>

</dependencies>

然后在moduleA中显式引入所需版本的libraryX依赖,这样就可以确保moduleA使用的是我们期望的版本。

  • 使用<dependencyManagement>标签:在父项目的pom.xml中使用<dependencyManagement>标签可以统一管理依赖的版本,确保所有子模块使用相同版本的依赖,从而避免版本冲突。例如:

<dependencyManagement>

<dependencies>

<dependency>

<groupId>com.example</groupId>

<artifactId>libraryX</artifactId>

<version>1.0.0</version>

</dependency>

</dependencies>

</dependencyManagement>

在子模块中引入libraryX依赖时,不需要指定版本号,Maven 会自动使用父项目中定义的版本:


<dependencies>

<dependency>

<groupId>com.example</groupId>

<artifactId>libraryX</artifactId>

</dependency>

</dependencies>

如果某个子模块需要使用不同版本的libraryX,可以在该子模块的pom.xml中单独指定版本号,此时子模块会使用自己指定的版本,而不是父项目中定义的版本 。

依赖范围的设置

在 Maven 项目中,依赖范围(scope)是一个非常重要的概念,它就像一把精准的手术刀,用于控制依赖在不同项目阶段的使用情况以及最终是否包含在构建输出中 。合理设置依赖范围可以避免不必要的依赖加载,减少项目的体积,提高项目的性能和可维护性。Maven 定义了多种依赖范围,以下是几种常见的依赖范围及其含义和使用场景。

  • compile:这是默认的依赖范围,表示依赖在编译、测试和运行时都需要。项目的核心依赖,如 Spring 框架、数据库连接池等通常使用compile范围。例如:

<dependency>

<groupId>org.springframework</groupId>

<artifactId>spring-core</artifactId>

<version>5.3.10</version>

<scope>compile</scope>

</dependency>

在这个例子中,spring-core是 Spring 框架的核心模块,在项目的编译、测试和运行阶段都不可或缺,所以使用compile范围。

  • provided:表示依赖在编译和测试时需要,但在运行时由外部环境提供,如容器或 JDK。例如,Servlet API、JSP API 等容器提供的 API 通常使用provided范围。以 Servlet API 为例:

<dependency>

<groupId>javax.servlet</groupId>

<artifactId>javax.servlet-api</artifactId>

<version>4.0.1</version>

<scope>provided</scope>

</dependency>

当我们开发一个 Web 应用时,在编译和测试阶段需要 Servlet API 来编写和测试 Servlet 相关的代码,但在运行时,Web 容器(如 Tomcat)已经提供了 Servlet API 的实现,所以不需要将其打包到应用中,使用provided范围可以避免重复引入。

  • runtime:表示依赖在测试和运行时需要,但在编译时不需要。例如,数据库驱动等只在运行时需要的依赖通常使用runtime范围。以 MySQL 数据库驱动为例:

<dependency>

<groupId>mysql</groupId>

<artifactId>mysql-connector-java</artifactId>

<version>8.0.23</version>

<scope>runtime</scope>

</dependency>

在编译 Java 代码时,只需要数据库连接的接口,不需要具体的驱动实现,而在运行时,才需要加载数据库驱动来建立与数据库的连接,所以mysql-connector-java使用runtime范围。

  • test:表示依赖仅在测试时需要,不会被打包到最终的应用程序中。JUnit、Mockito 等测试框架和库通常使用test范围。例如:

<dependency>

<groupId>junit</groupId>

<artifactId>junit</artifactId>

<version>4.13.2</version>

<scope>test</scope>

</dependency>

JUnit 是一个用于编写和运行单元测试的框架,只在项目进行测试时才需要,所以使用test范围,这样在打包应用程序时,不会将 JUnit 相关的依赖包含进去,减小了应用程序的体积。

  • system:表示依赖在编译和测试时需要,但不会从 Maven 仓库中下载,需要手动指定路径。这种依赖范围不推荐使用,因为它破坏了构建的可移植性。例如:

<dependency>

<groupId>com.example</groupId>

<artifactId>system-library</artifactId>

<version>1.0</version>

<scope>system</scope>

<systemPath>${project.basedir}/lib/system-library.jar</systemPath>

</dependency>

在这个例子中,system-library依赖需要从本地文件系统的${project.basedir}/lib/system-library.jar路径获取,而不是从 Maven 仓库下载,这在不同的开发环境中可能会导致路径不一致的问题,影响项目的可移植性。

  • import(Maven 3.0+):表示依赖的作用类似于<dependencyManagement>元素,用来管理依赖库的版本号,仅用于<dependencyManagement>中的依赖。在多模块项目中,通常会使用import范围从 BOM(Bill of Materials)中导入依赖管理,以统一管理子模块的依赖版本。例如:

<dependencyManagement>

<dependencies>

<dependency>

<groupId>com.example</groupId>

<artifactId>bom</artifactId>

<version>1.0.0</version>

<type>pom</type>

<scope>import</scope>

</dependency>

</dependencies>

</dependencyManagement>

通过这种方式,子模块可以继承bom中定义的依赖版本,方便统一管理和维护项目的依赖版本 。