【Maven教程】(七)聚合与继承:多模块项目实用特性介绍,反应堆构建及裁剪 ~-LMLPHP

在这个技术飞速发展的时代,各类用户对软件的要求越来越高,软件本身也变得越来越复杂。因此,软件设计人员往往会采用各种方式对软件划分模块,以得到更清晰的设计及更高的重用性。当把Maven应用到实际项目中的时候,也需要将项目分成不同的模块,例如,一个账户注册服务可被划分成 account-emailaccount-persist等五个模块。Maven的聚合特性能够把项目的各个模块聚合在一起构建,而Maven的继承特性则能帮助抽取各模块相同的依赖和插件等配置,在简化POM的同时,还能促进各个模块配置的一致性。本文将结合实际的案例阐述Maven的这两个特性。

1️⃣ account-persist

在讨论多模块Maven项目的聚合与继承之前,先引入账户注册服务的account-persist模块。该模块负责账户数据的持久化,以XML文件的形式保存账户数据,并支持账户的创建、读取、更新、删除等操作。

1.1 account-persist 的POM

首先,看一下 account-persist 模块的POM文件,见代码:

<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
			http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-persist</artifactId>
	<name>Account Persist</name>
	<version>1.0.0-SNAPSHOT</version>
	<dependencies>
		<dependency>
			<groupId>dom4j</groupId>
			<artifactId>dom4j</artifactId>
			<version>1.6.1</version>
		</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-core</artifactId>
			<version>2.5.6</version>
		</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-beans</artifactId>
			<version>2.5.6</version>
		</dependency>
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-context</artifactId>
			<version>2.5.6</version>
		</dependency>
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.7</version>
			<scope>test</scope>
		</dependency>
	</dependencies>
	<build>
		<testResources>
			<testResource>
				<directory>src/test/resourcesc/directory>
				<filtering>true</filtering>
			</testResource>
		</testResources>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<source>1.5</source>
					<target>1.5</target                         >
				</configuration>
			</plugin>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-resources-plugin</artifactId>
				<configuration>
					<encoding>UTF-8</encoding>
				</configuration>
			</plugin>
		</plugins>
	</build>
</project>

该模块的坐标为 com.xiaoshan.mvnbook.account:account-persist:1.0.0-SNAPSHOT,回顾一下前面的文章,该模块 groupIdversionaccount-email模块完全一致,而且arifactId也有相同的前缀。一般来说,一个项目的子模块都应该使用同样的 groupId,如果它们一起开发和发布,还应该使用同样的version, 此外,它们的arifactId还应该使用一致的前缀,以方便同其他项目区分。

POM中配置了一些依赖。其中,dom4j是用来支持XML操作的;接下来是几个 spring-framework的依赖,与account-email中一样,它们主要用来支持依赖注入;最后是一个测试范围的junit依赖,用来支持单元测试。

接着是build元素,它先是包含了一个testResources子元素,这是为了开启资源过滤。
稍后讨论account-persist单元测试的时候,会详细介绍。

build元素下还包含了两个插件的配置。首先是配置maven-compiler-plugin支持Java1.5,虽然这里没有配置插件版本,但由于maven-compiler-plugin是核心插件,它的版本已经在超级POM中设定了。此外,如果这里不配置groupId, Maven也会使用默认的
groupId: org.apache.maven.plugins。除了maven-compiler-plugin, 这里还配置了 maven-resources-plugin 使用UTF-8 编码处理资源文件。

1.2 account-persist的主代码

account-persist 的Java主代码位于默认的 src/main/java 目录,包含Account.java、AccountPersistService.java、AccountPersistServicelmpl.javaAccountPersistException.java 四个文件,它们的包名都是 com.xiaoshan.mvnbook.account.persist, 该包名与 account-persistgroupId: com.xiaoshan.mvnbook.accountartifactId: account-persist对应。

Account类定义了账户的简单模型,它包含idname等字段,并为每个字段提供了一组
getter和setter方法,见代码:

package com.xiaoshan.mvnbook.account.persist;

public class Account{
	private String id;
	private String name;
	private String email;
	private String password;
	private boolean activated;
	public String getId(){
		return id;
	}
	public void setId(String id){
		this.id=id;
	}

	//... getter and setter methods for name,email,paseword,activated
}

account-persist 对外提供的服务在接口 AccountPersistService 中定义,其方法对应了账户的增、删、改、查,见代码:

package	com.xiaoshan.mvnbook.account.persist;
	
public interface AccountPersistService{
	Account	createAccount(Account account) throws AccountPersistException;
	Account	readAccount(String id)throws AccountPersistException;
	Account	updateAccount(Account account)throws AccountPersistException;
	void deleteAccount(String id) throws AccountPersistException;
}

当增删改查操作发生异常的时候,该服务则抛出AccountPersistException
AccountPersistService对应的实现为AccountPersistServicelmpl,它通过操作XML文件实现账户数据的持久化。首先看一下该类的两个私有方法:readDocument()writeDocument(), 见代码:

private String file;
private SAXReader reader = new SAXReader();
private Document readDocument() throws AccountPersistException{
	File dataFile = new File(file);
	if(!dataFile.exists()){
		dataFile.getParentFile().mkdirs();
		Document doc=DocumentFactory.getInstance().createDocument();
		Element rootEle =doc.addElement(ELEMENT_ROOT);
		rootEle.addElement(ELEMENT_ACCOUNTS);
		writeDocument(doc);
	}
	try{
		return reader.read(new File(file));
	}
	catch (DocumentException e){
		throw new AccountPersistException("Unable to read persist data xml",e          );
	}
}

private void writeDocument(Document doc) throws AccountPersistException{
	Writer out=null;
	try{
		out =new OutputStreamWriter(new FileOutputStream(file),"utf-8");
		XMLWriter writer = new XMLWriter(out,OutputFormat.createPrettyPrint                                          ());
		writer.write(doc);
	}catch (IOException e){
		throw new AccountPersistException("Unable to write persist data xml",e);
	}finally{
		try{
			if(out !=null){
				out.close();
			}
		}catch (IOException e){
			throw new AccountPersistException("Unable to close persist data xml writer",e);
		}
	}
}

先看writeDocument()方法。该方法首先使用变量file构建一个文件输出流,fileAccountPersistServicelmpl 的一个私有变量,它的值通过 SpringFramework 注入。得到输出流后,该方法再使用 DOM4J 创建一个XMLWriter,这里的OutputFormat.createPrettyPrint()用来创建一个带缩进及换行的友好格式。得到XMLWriter后,就调用其write()方法,将Document写入到文件中。该方法的其他代码用做处理流的关闭及异常处理。

readDocument()方法与writeDocument()对应,它负责从文件中读取XML数据,也就是Document对象。不过,在这之前,该方法首先会检查文件是否存在,如果不存在,则需要初始化一个XML文档,于是借助DocumentFactory创建一个Document对象,接着添加XML元素,再把这个不包含任何账户数据的XML文档写入到文件中。如果文件已经被初始化
了,则该方法使用SAXReader读取文件至Document对象。

用来存储账户数据的XML文件结构十分简单,如下是一个包含一个账户数据的文件,见代码:

<?xml version="1.0" encoding="UTF-8"?>
<account-persist>
	<accounts>	
		<account>
			<id>xiaoshan</id>
			<name>XIAOSHAN</name>
			<email>xiaoshan@163.com</email>
			<password>this should be encrypted</password>
			<activated>false</activated>
		</account>
	</accounts>
</account-persist>

这个XML文件的根元素是 account-persist, 其下是accounts元素,accounts可以包含零个或者多个account元素,每个account元素代表一个账户,其子元素表示该账户的id、姓名、电子邮件、密码以及是否被激活等信息。

现在看一下readAccount()方法是如何从XML文档读取并构建Account对象的,见代码:

public Account readAccount(String id) throws AccountPersistException{
	Document doc = readDocument();
	Element accountsEle = doc.getRootElement().element(ELEMENT_ACCOUNTS);
	for(Element accountEle:(List<Element>)accountsEle.elements()){
		if (accountEle.elementText(ELEMENT_ACCOUNT_ID).equals(id)){
			return buildAccount(accountEle);
		}
	}
	return null;
}

private Account buildAccount(Element element){
	Account account =new Account();
	account.setId(element.elementText(ELEMENT_ACCOUNT_ID));
	account.setName(element.elementText(ELEMENT_ACCOUNT_NAME));
	account.setEmail(element.elementText(ELEMENT_ACCOUNT_EMAIL)); 
	account.setPassword(element.elementText(ELEMENI_ACCOUNT_PASSWORD));
	account.setActivated(("true".equals(element.elementText(ELEMENT_ACCOUNT_ACTIVATED))?true:false));
	return account;
}

readAccount()方法首先获取XML文档的Document对象,接着获取根元素的 accounts子元素,这里的ELEMENT_ACCOUNTS是一个静态常量,其值就是accounts。接着遍历accounts的子元素,如果当前子元素的 id与要读取的账户的 id一致,并且基于该子元素构建Account对象,这也就是buildAccount()方法。

buildAccount()方法中,先创建一个Account对象,然后当前XML元素的子元素的值设置该对象。ElementelementText()方法能够根据子元素名称返回子元素的值,与ELEMENT_ACCOUNTS类似,这里使用了一些静态常量表示 id、name、email 等XML中的元素名称。Account对象设置完后就直接返回,如果XML文档中没有匹配的 id,则返回null。

这里就不再介绍createAccount()updateAccount()deleteAccount()几个方法的实现。感兴趣的朋友可以参照DOM4J的文档实现这几个方法,过程应该非常简单。

除了Java代码,account-persist 模块还需要一个SpringFramework的配置文件,它位于src/main/resources 目录,其内容见代码:

<xml version="1.0"encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://www,springframework.org/schema/beans
		http://www.springframework.org/schema/beans/spring-beans2.5.xsd">
	<bean id="propertyConfigurer" class="org.springframework.beans.factory.config.PropertyPlaceholderConfigurer">
		<property name="location" value="classpath:account-service.properties"/>
	</bean>
	<bean id="accountPersistService" class="com.xiaoshan.mvnbook.account.persist.AccountPersistServiceImpl">
		<property name="file" value="$(persist.file)"/>
	</bean>
</beans>

该配置文件首先配置了一个id为 propertyConfigurer的 bean, 其实现为 PropertyPlaceholderConfigurer, 作用是从项目classpath载入名为 account-service.properties 的配置文件。随后的bean是 accountPersisfService, 实现为AccountPersistServicelmpl, 同时这里使用属性 persist.file配置其 file字段的值。也就是说,XML数据文档的位置是由项目classpath下account-service.properties文件中persist.fle属性的值配置的。

1.3 account-persist的测试代码

定义并实现了账户的增删改查操作,当然也不能少了相应的测试。测试代码位于src/test/java/ 目录下,测试资源文件位于 src/test/resources/ 目录下。上一节 SpringFramework的定义要求项目classpath下有一个名为 account-service.properties的文件,并且该文件中需要包含一个persist.file 属性,以定义文件存储的位置。为了能够测试账户数据的持久化,在测试资源目录下创建属性文件account-service.properties。其内容如下:

persist.file=${project.build.testoutputDirectory}/persist-data.xml

该文件只包含一个persist.file属性,表示存储账户数据的文件路径,但是它的值并不是简单的文件路径,而是包含了${project.build.testOutputDirectory}。这是一个Maven属性,这里暂时只要了解该属性表示了Maven的测试输出目录,其默认的地址为项目根目录下的 target/test-classes 文件夹。也就是说,在测试中使用测试输出目录下的 persist-data.xml文件存储账户数据。

现在编写测试用例测试AccountPersistService。同样为了避免冗余,这里只测试 readAccount() 方法,见代码:

package com.xiaoshan.mvnbook.account.persist;

import static org.junit.Assert.*;
import java.io.File;
import org.junit.Before;
import org.junit.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathxmlApplicationContext;

public class AccountPersistServiceTest{
	private AccountPersistService service;
	
	@Before 
	public void prepare() throws Exception{
		File persistDataFile=new File("target/test-classes/persist-data.xml");
		if(persistDataFile.exists())
		{
			persistDataFile.delete();
		}
		ApplicationContext ctx= new ClassPathXmlApplicationContext("account-persist.xml*):
		service=(AccountPersistService)ctx.getBean("accountPersistService");
		Account account =new Account();
		account.setId("xiaoshan"):
		account.setName("XIAOSHAN");
		account.setEmail("xiaoshan@163.com");
		account.setPassword("this should be encrypted"):
		account.setActivated(true);
		service.createAccount(account);
	}

	@Test
	public void testReadAccount throws Exception(){
		Account account =service.readAccount("xiaoshan");
		assertNotNull(account);
		assertEquals("xiaoshan",account.getId());
		assertEquals("XIAOSHAN",account.getName());
		assertEquals("xiaoshan@163.com", account.getEmail());
		assertEquals("this should be encrypted", account.getPassword();
		assertTrue(account.isActivated());
	}
}

该测试用例使用与AccountPersistService一致的包名,它有两个方法:prepare()testReadAccount()。其中prepare()方法使用了@Before标注,表示在执行测试用例之前执行该方法。它首先检查数据存储文件是否存在,如果存在则将其删除以得到干净的测试环境,接着使用account-persist.xml配置文件初始化SpringFramwork的IoC容器,再从容器中获取要测试的AccountPersistService对象。最后,prepare()方法创建一个Account对象,设置对象字段的值之后,使用AccountPersistServicecreateAccount()方法将其持久化。

使用@Test标注的testReadAccount()方法就是要测试的方法。该方法非常简单,它根据id使用AccountPersistService读取Account对象,然后检查该对象不为空,并且每个字段的值必须与刚才插入的对象的值完全一致。

该测试用例遵守了测试接口而不测试实现这一原则。也就是说,测试代码不能引用实现类,由于测试是从接口用户的角度编写的,这样就能保证接口的用户无须知晓接口的实现细节,既保证了代码的解耦,也促进了代码的设计。

2️⃣ 聚合

到目前为止,我们实现了用户注册服务的两个模块,它们分别是前面文章中实现的 account-email 和本节实现的account-persist。这时,一个简单的需求就会自然而然地显现出来,我们会想要一次构建两个项目,而不是到两个模块的目录下分别执行mvn命令。Maven聚合(或者称为多模块)这一特性就是为该需求服务的。

为了能够使用一条命令就能构建account-emailaccount-persist两个模块,我们需要创建一个额外的名为accounl-aggregator的模块,然后通过该模块构建整个项目的所有模块。account-aggregator本身作为一个Maven项目,它必须要有自己的POM,不过,同时作为一个聚合项目,其POM又有特殊的地方。如下为account-aggregatorpom.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
		http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-aggregator</artifactId>
	<version>1.0.0-SNAPSHOT</version>
	<packaging>pom</packaging>
	<ename>Account Aggregator</name>
	<modules>
		<module>account-email</module          >
		<module>account-persist</module>
	</modules>
</project>          

上述POM依旧使用了账户注册服务共同的groupId com.xiaoshan.mvnbook.account, artifactId为独立的 account-aggregator,版本也与其他两个模块一致,为 1.0.0-SNAPSHOT。这里的第一个特殊的地方为packaging,其值为POM。回顾account-emailaccount-persist,它们都没有声明packaging, 即使用了默认值jar。对于聚合模块来说,其打包方式packaging的值必须为pom, 否则就无法构建。

POM的name字段是为了给项目提供一个更容易阅读的名字。之后是之前都没提到过的元素modules,这是实现聚合的最核心的配置。用户可以通过在一个打包方式为pom的Maven项目中声明任意数量的module元素来实现模块的聚合。这里每个module的值都是一个当前POM的相对目录,譬如该例中,account-aggregator的POM的路径为D:\…\code\ch-8\account-aggregator\pom.xml, 那么account-email就对应了目录D:\…\code\ch-8\account-aggregator\account-email/, 而account-persist对应于目录D:\…\eode\ch-8\account-aggregator\account-persist/。这两个目录各自包含了pom.xmlsrc/main/java/src/test/java/ 等内容,离开account-aggregator也能独立构建。

一般来说,为了方便快速定位内容,模块所处的目录名称应当与其artifactId一致,不过这不是Maven的要求,用户也可以将account-email项目放到email-account/目录下。这时,聚合的配置就需要相应地改成<module>email-account</module>

为了方便用户构建项目,通常将聚合模块放在项目目录的最顶层,其他模块则作为聚合模块的子目录存在,这样当用户得到源码的时候,第一眼发现的就是聚合模块的POM,不用从多个模块中去寻找聚合模块来构建整个项目。图示为account-aggregator与另外两个模块的目录结构关系。

【Maven教程】(七)聚合与继承:多模块项目实用特性介绍,反应堆构建及裁剪 ~-LMLPHP

从图中能够看到,account-aggregator的内容仅是一个pom.xml文件,它不像其他模块那样有src/main/java、src/test/java 等目录。这也是容易理解的,聚合模块仅仅是帮助聚合其他模块构建的工具,它本身并无实质的内容。

关于目录结构还需要注意的是,聚合模块与其他模块的目录结构并非一定要是父子关系。图展示了另一种平行的目录结构。

【Maven教程】(七)聚合与继承:多模块项目实用特性介绍,反应堆构建及裁剪 ~-LMLPHP

如果使用平行目录结构,聚合模块的POM也需要做相应的修改,以指向正确的模块。

<modules>
	<module>../account-email</module>
	<module>../account-persist</module>
</modules>

最后,为了得到直观的感受,看一下从聚合模块运行mvn clean install命令会得到怎样的输出:

[INFO] Scanning for projects..
[INFO] --------------------------------------------------
[INFO] Reactor Build order:
[INFO]
[INFO] Account Aggregator
[INFO] Account Email
[INFO] Account Persist
[INFO]
[INFO] --------------------------------------------------
[INFO] Building Account Aggregator 1.0.0-SNAPSHOT
[INFO] --------------------------------------------------
[INFO][INFO] --------------------------------------------------
[INFO] Building Account Email 1.0.0-SNAPSHOT
[INFO] --------------------------------------------------
[INFO][INFO] --------------------------------------------------
[INFO] Building Account Persist 1.0.0-SNAPSHOT
[INF0] --------------------------------------------------
[INFO]
[INFO]
[INFO] Reactor Summary:
[INFO] --------------------------------------------------
[INFO] Account Aggregator … … … … … … … … … … SUCCESS[0.496s]
[INFO] Account Emai1 … … … … … … … … … … … … … … SUCCESS[3.372s]
[INFO] Account Persist    … … … … … … … … … .SUCCESS[2.176s]
[INPO] --------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] --------------------------------------------------
[INFO] Total time:6.158s
[INFO] Finished at:Sun Feb 14 16:36:29 CST 2023
[INFO] Final Memory:11M/20M
[INFO] --------------------------------------------------

Maven 会首先解析聚合模块的 POM、 分析要构建的模块、并计算出一个反应堆构建顺序 (Reactor Build Order), 然后根据这个顺序依次构建各个模块。反应堆是所有模块组成的一 个构建结构。

上述输出中显示的是各模块的名称,而不是 artifactId, 这也解释了为什么要在 POM中配置合理的 name 字段,其目的是让Maven 的构建输出更清晰。输出的最后是一个项目构建的小结报告,包括各个模块构建成功与否、花费的时间,以及整个构建花费的时间、使用的内存等。

3️⃣ 继承

到目前为止,我们已经能够使用 Maven 的聚合特性通过一条命令同时构建 account-emailaccount-persist 两个模块,不过这仅仅解决了多模块 Maven 项目的一个问题。那么多模块的项目还有什么问题呢?
细心的朋友可能已经比较过这两个模块的POM,这两个 POM 有着很多相同的配置,如它们有相同的 groupIdversion, 有相同的 spring-core、spring-beans、spring-contextjunit 依赖,还有相同的 maven-compiler-pluginmaven- resources-plugin 配置。程序员的嗅觉对这种现象比较敏感,没错,这是重复!大量的前人经验告诉我们,重复往往就意味着更多的劳动和更多的潜在的问题。在面向对象世界中,程序员可以使用类继承在 一定程度上消除重复,在 Maven 的世界中,也有类似的机制能让我们抽取出重复的配置 ,这就是 POM 的继承。

3.1 account-parent

面向对象设计中,程序员可以建立一种类的父子结构,然后在父类中声明一些字段和方法供子类继承,这样就可以做到 “一处声明,多处使用”。类似地,我们需要创建POM的父子结构,然后在父POM中声明一些配置供子POM继承,以实现 “一处声明,多处使用”的目的。

我们继续以账户注册服务为基础,在 account-aggregator下创建一个名为 account-parent的子目录,然后在该子目录下建立一个所有除 account-aggregator 之外模块的父模块。为此,在该子目录创建一个pom.xml文件,内容见代码:

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmins:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
	http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-parent</artifactId>
	<version>1.0.0-SNAPSHOT</version>
	<packaging>pom</packaging>
	<name>Account Parent</name>
</project>

该POM十分简单,它使用了与其他模块一致的 groupIdversion, 使用的 artifactIdaccount-parent 表示这是一个父模块。需要特别注意的是,它的 packaging 为 pom, 这一点与聚合模块一样,作为父模块的POM, 其打包类型也必须为 pom。

由于父模块只是为了帮助消除配置的重复,因此它本身不包含除POM之外的项目文件,也就不需要 src/main/java/ 之类的文件夹了。有了父模块,就需要让其他模块来继承它。首先将 account-email 的POM修改如下,见代码:

<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
		http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.xiaoshan.mvnbook.account</groupId>
		<artifactId>account-parent</artifactId>
		<version>1.0.0-SNAPSHOT</version>
		<relativePath>../account-parent/pom.xml</relativePath>
	</parent>
	<artifactId>account-email</artifactId>
	<name>Account Email</name>
	<dependencies>
		...
	</dependencles>
	<build>
		<plugins>
		</plugins>
	</build>
</project>

上述POM中使用 parent 元素声明父模块,parent下的子元素groupId、artifactId和version 指定了父模块的坐标,这三个元素是必须的。元素 relativePath 表示父模块POM的相对路径,该例中的../account-parent/pom.xml 表示父POM的位置在与 account-email/ 目录平行的account-parent/目录下。当项目构建时,Maven会首先根据 relativePath检查父POM, 如果找不到,再从本地仓库查找。relativePath的默认值是…/pom.xml,也就是说,Maven默认父POM在上一层目录下。

正确设置relativePath非常重要。考虑这样一个情况,开发团队的新成员从源码库签出一个包含父子模块关系的Maven项目。由于只关心其中的某一个子模块,它就直接到该模块的目录下执行构建,这个时候,父模块是没有被安装到本地仓库的,因此如果子模块没有设置正确的relativePath, Maven将无法找到父POM, 这将直接导致构建失败。如果Maven能够根据relativePath 找到父POM, 它就不需要再去检查本地仓库。

这个更新过的 POM没有为 account-email 声明 groupIdversion, 不过这并不代表 account-email 没有 groupIdversion。实际上,这个子模块隐式地从父模块继承了这两个元素,这也就消除了一些不必要的配置。在该例中,父子模块使用同样的 groupIdversion, 如果遇到子模块需要使用和父模块不一样的 groupId或者 version的情况,那么用户完全可以在子模块中显式声明。对于 artifactId元素来说,子模块应该显式声明,一方面,如果完全继承 groupId、artifactId和version, 会造成坐标冲突;另一方面,即使使用不同的 groupIdversion, 同样的 artifactId容易造成混淆。

为了节省篇幅,上述POM中省略了依赖配置和插件配置,稍后会介绍如何将共同的依赖配置提取到父模块中。与account-email 的POM类似,以下是account-persist更新后的POM, 见代码:

<project xmins="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
		http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.xiaoshan.mvnbook.account</groupld>
		<artifactId>account-parent</artifactId>
		<version>1.0.0-SNAPSHOT</version>
		<relativePath>../account-parent/pom.xml</relativePath>
	</parent>
	<artifactId>account-persist</artifactId>
	<name>Account Persist</name>
	<dependencies>
	</dependencies>
	<build>
		<testResources>
			<testResource>
			<directory>src/test/resources</directory>
			<filtering>true</filtering>
			</testResource>
		</testResources>
		<plugins>
		</plugins>
	</build>
</project>

最后,同样还需要把 account-parent 加入到聚合模块 account-aggregator 中,见代码:

<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
		http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-aggregator</artifactId>
	<version>1.0.0-SNAPSHOT</version>
	<packaging>pom</packaging>
	<name>Account Aggregator</name>
	<modules>
		<module>account-parent</module>
		<module>account-email</module>
		<module>account-persist</module>
	</modules>
</project>

3.2 可继承的 POM 元素

在上一节我们看到,groupIdversion是可以被继承的,那么还有哪些POM元素可以被继承呢? 以下是一个完整的列表,并附带了简单的说明:

  • groupId: 项目组ID, 项目坐标的核心元素。
  • version: 项目版本,项目坐标的核心元素。
  • description: 项目的描述信息。
  • organization: 项目的组织信息。
  • inceptionYear: 项目的创始年份。
  • url: 项目的URL地址。
  • developers: 项目的开发者信息。
  • contributors: 项目的贡献者信息。
  • distributionManagement: 项目的部署配置。
  • issueManagement: 项目的缺陷跟踪系统信息。
  • ciManagement: 项目的持续集成系统信息。
  • scm: 项目的版本控制系统信息。
  • mailingLists: 项目的邮件列表信息。
  • properties: 自定义的Maven属性。
  • dependencies: 项目的依赖配置。
  • dependencyManagement: 项目的依赖管理配置。
  • repositories: 项目的仓库配置。
  • build:包括项目的源码目录配置、输出目录配置、插件配置、插件管理配置等。
  • reporting: 包括项目的报告输出目录配置、报告插件配置等。

3.3 依赖管理

上一节的可继承元素列表包含了 dependencies 元素,说明依赖是会被继承的,这时我们 就会很容易想到将这一特性应用到 account-parent 中。子模块account-emailaccount-persist 同时依赖了 org.springframework:spring-core:2.5.6、org.springframework:spring-beans:2.5.6、 org.springframework:spring-context:2.5.6和 junit:junit:4.7, 因此可以将这些依赖配置放到父模块 account-parent 中,两个子模块就能移除这些依赖,简化配置。

上述做法是可行的,但却存在问题。到目前为止,我们能够确定这两个子模块都包含那四个依赖,不过我们无法确定将来添加的子模块就一定需要这四个依赖。假设将来项目 中需要加入一个 account-util 模块,该模块只是提供一些简单的帮助工具,与 springframework 完全无关,难道也让它依赖 spring-core、spring-beans 和 spring- context 吗? 那显然是不合理的。

Maven 提供的 dependencyManagement 元素既能让子模块继承到父模块的依赖配置,又能保证子模块依赖使用的灵活性。在 dependencyManagement 元素下的依赖声明不会引入实际的依赖,不过它能够约束 dependencies 下的依赖使用。例如,可以在 account- parent 中加入这样的 dependencyManagement 配置,见代码:

<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmins:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/PON/4.0.0
		http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-parent</artifactId>
	<version>1.0.0-SNAPSHOT</version>
	<packaging>pom</packaging>
	<name>Account Parent</name>
	<properties>
		<springframework.version>2.5.6</springframework.version>
		<junit.version>4.7</junit.version>
	</properties>
	<dependencyManagement>
		<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</groupld>
				<artifactId>spring-context</artifactId>
				<version>${springframework.version}</version>
			</dependency>
			<dependency>
				<groupId>org.springframework</groupId>
				<artifactId>spring-context-support</artifactId>
				<version>${springframework.version}</version>
			</dependency>
			<dependency>
				<groupId>junit</groupId>
				<artifactId>junit</artifactId>
				<version>${junit.version}</version>
				<scope>test</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>
</project>

首先该父POM使用了前面我们介绍的方法,将 springframeworkjunit 依赖的版本以Maven变量的形式提取了出来,不仅消除了一些重复,也使得各依赖的版本处于更加明显的位置。
这里使用dependencyManagement 声明的依赖既不会给 account-parent 引入依赖,也不会给它的子模块引入依赖,不过这段配置是会被继承的。现在修改account-email的POM如下,见代码:

<properties>
	<javax.mail.version>1.4.1</javax.mail.version>
	<greenmail.version>1.3.1b</greenmail.version>
</properties>
<dependencies>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-core</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-beans</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-context</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-context-support</artifactId>
	</dependency>
	<dependency>
		<groupId>junit</groupId>
		<artifactId>junit</artifactId>
	</dependency>
	<dependency>
		<groupId>javax.mail</groupld>
		<artifactId>mail</artifactId>
		<version>${javax,mail.version}</version>
	</dependency>
	<dependency>
		<groupId>com.icegreen</groupId>
		<artifactId>greenmail</artifactId>
		<version>${greenmail.version}</version>
		<scope>test</scope>
	</dependency>
</dependencies>           

上述POM中的依赖配置较原来简单了一些,所有的 springframework 依赖只配置了groupldartifactld, 省去了vesion, 而 junit 依赖不仅省去了version, 还省去了依赖范围scope, 这些信息可以省略是因为account-email 继承了aceount-parent 中的dependencyManagement配置,完整的依赖声明已经包含在父POM中,子模块只需要配置简单的groupldartifactld 就能获得对应的依赖信息,从而引入正确的依赖。

使用这种依赖管理机制似乎不能减少太多的POM配置,不过还是强烈推荐采用这种方法。其主要原因在于在父POM中使用dependencyManagement声明依赖能够统一项目范围中依赖的版本,当依赖版本在父POM中声明之后,子模块在使用依赖的时候就无须声明版本,也就不会发生多个子模块使用依赖版本不一致的情况。这可以帮助降低依赖冲突的几率。

如果子模块不声明依赖的使用,即使该依赖已经在父POM的dependeneyManagement中声明了,也不会产生任何实际的效果,如 account-persist 的POM, 见代码:

<properties>
	<dom4j.version>1.6.1</dom4j.version>
</properties>
<dependencies>
	<dependency>
		<groupId>dom4j</groupId>
		<artifactId>dom4j</artifactId>
		<version>${dom4j.version}</version>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-core</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-beans</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework</groupId>
		<artifactId>spring-context</artifactId>
	</dependency>
	<dependency>
		<groupId>junit</groupId>
		<artifactId>junit</artifactId>
	</dependency>
</dependencies>

这里没有声明spring-context-support, 那么该依赖就不会被引入。这正是dependeney-Management的灵活性所在。

前面在介绍依赖范围的时候提到了名为import的依赖范围,推迟到现在介绍是因为该范围的依赖只在dependencyManagement元素下才有效果,使用该范围的依赖通常指向一个POM, 作用是将目标POM中的dependencyManagement配置导入并合并到当前POM的dependencyManagement元素中。例如想要在另外一个模块中使用与某个代码完全一样的dependencyManagement配置,除了复制配置或者继承这两种方式之外,还可以使用import 范围依赖将这一配置导入,见代码:

<dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>com.xiaoshan.mvnbook.account</groupId>
			<artifactId>account-parent</artifactId>
			<version>1.0-SNAPSHOT</version>
			<type>pom</type>
			<scope>import</scope>
		</dependency>
	</dependencies>
</dependencyManagement>

注意,上述代码中依赖的type值为pom, import 范围依赖由于其特殊性,一般都是指向打包类型为pom的模块。如果有多个项目,它们使用的依赖版本都是一致的,则就可以定义一个使用dependencyManagement 专门管理依赖的POM,然后在各个项目中导入这些依赖管理配置。

3.4 插件管理

Maven提供了dependencyManagement 元素帮助管理依赖,类似地,Maven也提供了pluginManagement 元素帮助管理插件。在该元素中配置的依赖不会造成实际的插件调用行为,当POM中配置了真正的 plugin 元素,并且其 groupIdartifactIdpluginManagement 中配置的插件匹配时,pluginManagement 的配置才会影响实际的插件行为。

如果一个项目中有很多子模块,并且需要得到所有这些模块的源码包,那么很显然,为所有模块重复类似的插件配置不是最好的办法。这时更好的方法是在父POM中使用pluginManagement配置插件,见代码:

<build>
	<pluginManagement>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-source-plugin</artifactId>
				<version>2.1.1</version>
				<executions>
					<execution>
						<id>attach-sources</id>
						<phase>verify</phase>
						<goals>
							<goal>jar-no-fork</goal>
						</goals>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</pluginManagement>
</build>

当子模块需要生成源码包的时候,只需要如下简单的配置,见代码:

<build>
	<plugins>
		<plugin>
			<groupId>org.apache.maven.plugins</groupId>
			<artifactId>maven-source-plugin</artifactId>
		</plugin>
	</plugins>
</build>

子模块声明使用了maven-source-plugin插件,同时又继承了父模块的pluginManagement配置,两者基于groupIdarifactId匹配合并之后就相当于上文4.2节中的插件配置。
如果子模块不需要使用父模块中 pluginManagement 配置的插件,可以尽管将其忽略。如果子模块需要不同的插件配置,则可以自行配置以覆盖父模块的pluginManagement配置。

有了pluginManagement元素,account-emailaccount-persist 的POM也能得以简化了,它们都配置了 maven-compiler-pluginmaven-resources-plugin。可以将这两个插件的配置移到 account-parentpluginManagement元素中,见代码:

<build>
	<pluginManagement>
		<plugins>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-compiler-plugin</artifactId>
				<configuration>
					<source>1.5</source>
					<target>1.5</target>
				</configuration>
			</plugin>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-resources-plugin</artifactId>
				<configuration>
					<encoding>UTF-8</encoding>
				</configuration>
			</plugin>
		</plugins>
	</pluginManagement>
</build>

account-emailaccount-persist可以完全地移除关于maven-compiler-pluginmaven-resources-plugin 的配置,但它们仍能享受这两个插件的服务,前一插件开启了Java5 编译的支持,后一插件也会使用UTF-8编码处理资源文件。这背后涉及了很多Maven机制,首先,内置的插件绑定关系将两个插件绑定到了 account-emailaccount-persist的生命周期上;其次,超级POM为这两个插件声明了版本;最后,account-parent中的pluginManagement对这两个插件的行为进行了配置。

当项目中的多个模块有同样的插件配置时,应当将配置移到父POM的 pluginManagement元素中。即使各个模块对于同一插件的具体配置不尽相同,也应当使用父POM的pluginManagement元素统一声明插件的版本。甚至可以要求将所有用到的插件的版本在父POM的 pluginManagement元素中声明,子模块使用插件时不配置版本信息,这么做可以统一项目的插件版本,避免潜在的插件不一致或者不稳定问题,也更易于维护。

4️⃣ 聚合与继承的关系

基于前面三节的内容,大家可以了解到,多模块Maven项目中的聚合与继承其实是两个概念,其目的完全是不同的。前者主要是为了方便快速构建项目,后者主要是为了消除重复配置。

对于聚合模块来说,它知道有哪些被聚合的模块,但那些被聚合的模块不知道这个聚合模块的存在。对于继承关系的父POM来说,它不知道有哪些子模块继承于它,但那些子模块都必须知道自己的父POM是什么。如果非要说这两个特性的共同点,那么可以看到,聚合POM与继承关系中的父POM的 packaging 都必须是pom, 同时,聚合模块与继承关系中的父模块除了POM之外都没有实际的内容,如图所示:

【Maven教程】(七)聚合与继承:多模块项目实用特性介绍,反应堆构建及裁剪 ~-LMLPHP在现有的实际项目中,往往会发现一个POM既是聚合POM, 又是父POM, 这么做主要是为了方便。一般来说,融合使用聚合与继承也没有什么问题,例如可以将 account-aggregatoraccount-parent 合并成一个新的 account-parent, 其POM见代码:

<project xmins="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
		http://maven.apache.org/maven-v4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-parent</artifactId>
	<version>1.0.0-SNAPSHOT</version>
	<packaging>pom</packaging>
	<name>Account Parent</name>
	<modules>
		<module>account-email</module>
		<module>account-persist</module>
	</modules>
	<properties>
		<springframework.version>2.5.6</springframework.version>
		<junit.version>4.7</junit.version>
	</properties>
	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>org.springframework</groupld>
				<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>
			<dependency>
				<groupId>junit</groupId>
				<artifactId>junit</artifactId>
				<version>${junit.version}</version>
				<scope>test</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>
	<build>
		<pluginManagement>
			<plugins>
				<plugin>
					<groupId>org.apache.maven.plugins</groupId>
					<artifactId>maven-compiler-plugin</artifactId>
					<configuration>
						<source>1.5</source>
						<target>1.5</target>
					</configuration>
				</plugin>
				<plugin>
					<groupId>org.apache.maven.plugins</groupId>
					<artifactId>maven-resources-plugin</artifactId>
					<configuration>
						<encoding>UTF-8</encoding>
					</configuration>
				</plugin>
			</plugins>
		</pluginManagement>
	</build>
</project>

在代码中可以看到,该POM的打包方式为pom, 它包含了一个modules元素,表示用来聚合account-persistaccount-email两个模块,它还包含了properties、dependencyManagement 和 pluginManagement 元素供子模块继承。

相应地,account-emailaccount-persist 的POM配置也要做微小的修改。本来 account-parent 和它们位于同级目录,因此需要使用值为../account-parent/pom.xmlrelativePath元素。现在新的 account-parent在上一层目录,这是Maven默认能识别的父模块位置,因此不再需要配置 relativePath,见代码:

<parent>
	<groupId>com.xiaoshan.mvnbook.account</groupId>
	<artifactId>account-parent</artifactId>
	<version>1.0.0-SNAPSHOT</version>
</parent>
<artifactId>account-email</artifactId>
<name>Account Email</name>

5️⃣ 约定优于配置

标准的重要性已不用过多强调,想象一下,如果不是所有程序员都基于HTTP协议开发Web应用,互联网会乱成怎样。各个版本的IE、Firefox等浏览器之间的差别已经让很多开发者头痛不已。而Java成功的重要原因之一就是它能屏蔽大部分操作系统的差异,XML流行的原因之一是所有语言都接受它。Maven当然还不能和这些既成功又成熟的技术相比,但Maven的用户都应该清楚,Maven提倡 “约定优于配置”(Convention Over Configuration), 这是Maven最核心的设计理念之一。

那么为什么要使用约定而不是自己更灵活的配置呢? 原因之一是,使用约定可以大量减少配置。先看一个简单的 Ant 配置文件,见代码:

<project name="my-project" default="dist" basedir="." >
	<description>
		simple example build file
	</description>
	<!--设置构建的全局属性 -->
	<property name="src" location="src/main/java"/>
	<property name="build" location="target/classes" />
	<property name="dist" location="target"/>

	<target name="init">
		<!-- 创建时间戳 -->
		<tstamp/>
		<!-- 创建编译使用的构建目录 -->
		<mkdir dir="${build}"/>
	</target>
	
	<target name="compile" depends="init" description="compile the source">
		<!-- 将java 代码从目录${src} 编译至 ${build} -->
		<javac srcdir="${src}" destdir="${build}" />
	</target>
	
	<target name="dist" depends="compile" description="generate the distribution">
		<!-- 创建分发目录 -->
		<mkdir dir="${dist}/lib"/>
		<!-- 将 ${build}目录的所有内容打包至MyProject-${DSTAMP}.jar file -->
		<jar jarfile="${dist}/lib/MyProject-${DSTAMP},jar" basedir="${build}"/>
	</target>
	
	<target name="clean" description="clean up">
		<!-- 删除 ${build}和 ${dist}目录树 -->
		<delete dir="${build}"/>
		<delete dir="${disc}"/>
	</target>
</project>

这段代码做的事情就是清除构建目录、创建目录、编译代码、复制依赖至目标目录,最后打包。这是一个项目构建要完成的最基本的事情,不过为此还是需要写很多的XML配置:源码目录是什么、编译目标目录是什么、分发目录是什么,等等。用户还需要记住各种Ant任务命令,如delete、mkdir、javac 和 jar

做同样的事情,Maven需要什么配置呢? Maven只需要一个最简单的POM, 见代码:

<project>
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook</groupId>
	<artifactId>my-project</artifactId>
	<version>1.0</version>
</project>

这段配置简单得令人惊奇,但为了获得这样简洁的配置,用户是需要付出一定的代价的,那就是遵循Maven的约定。Maven会假设用户的项目是这样的:

  • 源码目录为src/main/java/
  • 编译输出目录为target/classes/
  • 打包方式为jar
  • 包输出目录为target/

遵循约定虽然损失了一定的灵活性,用户不能随意安排目录结构,但是却能减少配置。更重要的是,遵循约定能够帮助用户遵守构建标准。

如果没有约定,10个项目可能使用10种不同的项目目录结构,这意味着交流学习成本的增加,当新成员加入项目的时候,它就不得不花时间去学习这种构建配置。而有了Maven的约定,大家都知道什么目录放什么内容。此外,与Ant的自定义目标名称不同,Maven在命令行暴露的用户接口是统一的,像 mvn clean install 这样的命令可以用来构建几乎任何的Maven项目。

也许这时候有读者会问,如果我不想遵守约定该怎么办? 这时,请首先问自己三遍,你真的需要这么做吗?如果仅仅是因为喜好,就不要耍个性,个性往往意味着辆牲通用性,意味着增加无谓的复杂度。例如,Maven允许你自定义源码目录,如代码:

<project>
	<modelVeraion>4.0.0</modelVersion>
	<groupId>com.xiaoshan.mvnbook</groupId>
	<artifactId>my-project</artifactld>
	<version>1.0</version>
	<build>
		<sourceDirectory>src/Java</sourceDirectory>
	</build>
</project>    

该例中源码目录就成了src/java 而不是默认的 src/main/java。但这往往会造成交流问题,习惯Maven的人会奇怪,源代码去哪里了?当这种自定义大量存在的时候,交流成本就会大大提高。只有在一些特殊的情况下,这种自定义配置的方式才应该被正确使用以解决实际问题。例如你在处理遗留代码,并且没有办法更改原来的目录结构,这个时候就只能让Maven妥协。

我曾多次提到超级POM, 任何一个Maven项目都隐式地继承自该POM, 这有点类似于任何一个Java类都隐式地继承于Objeet类。因此,大量超级POM的配置都会被所有Maven项目继承,这些配置也就成为了Maven所提倡的约定。

对于Maven3, 超级POM在文件 $MAVEN_HOME/lib/maven-model-builder-xx.x.jar 中的org/apache/maven/model/pom-4.0.0.xml路径下。对于Maven2, 超级POM在文件 $MAVEN_HOME/lib/maven-xx.x-uber.jar中的 org/apache/maven/project/pom-4.0.0.xml目录下。这里的 x.x.x表示Maven的具体版本。

超级POM的内容在Maven2和Maven3中基本一致,现在分段看一下,见代码:

<repositories>
	<repository>
		<id>central</id>
		<name>Maven Repository Switchboard</name>
		<url>http://repol.maven.org/maven2</url>
		<layout>default</layout>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
	</repository>
</repositories>
<pluginRepositories>
	<pluginRepository>
		<id>central</id>
		<name>Maven Plugin Repository</name>
		<url>http://repol.maven.org/maven2</url>
		<layout>default</layout>
		<snapshots>
			<enabled>false</enabled>
		</snapshots>
		<releases>
			<updatePolicy>never</updatePolicy>
		</releases>
	</pluginRepository>
</pluginRepositories>

首先超级POM定义了仓库及插件仓库两者的地址都为中央仓库 http://repol.maven.org/maven2, 并且都关闭了SNAPSHOT的支持。这也就解释了为什么Maven默认就可以按需要从中央仓库下载构件。再看以下内容:

<build>
	<directory>${project.basedir}/target</directory>
	<outputDirectory>${project.build.directory}/classes</outputDirectory>
	<finalName>${project.artifactId}-${project.version}</finalName>
	<testoutputDirectory>${project.build.directory}/test-classes</testoutputDirectory>
	<sourceDirectory>${project.basedir}/src/main/java</sourceDirectory>
	<scriptSourceDirectory>src/main/scripts</scriptSourceDirectory>
	<testSourceDirectory>${project.basedir}/src/test/java</testSourceDirectory>
	<resources>
		<resource>
			<directory>${project.basedir}/src/main/resources</directory>
		</resource>
	</resources>
	<testResources>
		<testRescurce>
			<directory>${project.basedir}/src/test/resources</directory>
		</testResource>
	</testResources>

这里依次定义了项目的主输出目录、主代码输出目录、最终构件的名称格式、测试代码输出目录、主源码目录、脚本源码目录、测试源码目录、主资源目录和测试资源目录。 这就是Maven 项目结构的约定。
紧接着超级 POM 为核心插件设定版本,见代码:

<pluginManagement>
	<plugins>
		<plugin>
			<artifactId>maven-antrun-plugin</artifactId>
			<version>1.3</version>
		</plugin>
		<plugin>
			<artifactId>mavenassembly-plugin</artifactId>
			<version>2.2-beta-4</version>
		</plugin>
		<plugin>
			<artifactId>maven-clean-plugin</artifactId>
			<version>2.3</version>
		</plugin>
		<plugin>
			<artifactId>maven-compiler-plugin</artifactId>
			<version>2.0.2</version>
		</plugin>
	</plugins>
</pluginManagement>

由于篇幅原因,这里不完整罗列,大家可自己找到超级POM了解插件的具体版本。 Maven 设定核心插件的原因是防止由于插件版本的变化而造成构建不稳定。超级POM 的最后是关于项目报告输出目录的配置和 一个关于项目发布的 profile, 这里暂不深入解释。后面会有文章讨论这两项配置。

可以看到,超级POM 实际上很简单,但从这个POM 我们就能够知晓 Maven 约定的由
来,不仅理解了什么是约定,为什么要遵循约定,还能明白约定是如何实现的。

6️⃣ 反应堆

在一个多模块的Maven 项目中,反应堆 (Reactor) 是指所有模块组成的一个构建结构。对于单模块的项目,反应堆就是该模块本身,但对于多模块项目来说,反应堆就包含了各模块之间继承与依赖的关系,从而能够自动计算出合理的模块构建顺序。

6.1 反应堆的构建顺序

我们仍然以账户注册服务为例来解释反应堆。首先,为了能更清楚地解释反应堆的构建顺序, 将account-aggregator 的聚合配置修改如下:

<modules>
	<module>account-email</module>
	<module>account-persist</module>
	<module>account-parent</module>
</modules>

修改完毕之后构建account-aggregator会看到如下的输出:

[INFO]-----
[INFO] Reactor Build Order:
[INFO]
[INFO] Account Aggregator
[INFO] Account Parent
[INFO] Account Emall
[INFO] Account Persist
[INFO]
[INFO]

上述输出告诉了我们反应堆的构建顺序,它们依次为account-aggregatoraccount-par-entaccount-emailaccount-persist。我们知道,如果按顺序读取POM文件,首先应该读到的是 account-aggregator的POM , 实际情况与预料的一致,可是接下来几个模块的构建次序显然与它们在聚合模块中的声明顺序不一致,account-parent跑到了 account-email前面,这是为什么呢? 为了解释这一现象,先看下图:

【Maven教程】(七)聚合与继承:多模块项目实用特性介绍,反应堆构建及裁剪 ~-LMLPHP
图中从上至下的箭头表示POM的读取次序,但这不足以决定反应堆的构建顺序,Maven还需要考虑模块之间的继承和依赖关系,图中的有向虚线表示模块之间的继承或者依赖,该例中account-emailaccount-persist 依赖于 account-parent, 那么account-parent就必须先于另外两个模块构建。也就是说,这里还有一个从右向左的箭头。实际的构建顺序是这样形成的:Maven按序读取POM, 如果该POM没有依赖模块,那么就构建该模块,否则就先构建其依赖模块,如果该依赖还依赖于其他模块,则进一步先构建依赖的依赖。该例中,account-aggregator没有依赖模块,因此先构建它,接着到account-email,它依赖于account-parent模块,必须先构建account-parent, 然后再构建account-email, 最后到account-persist的时候,由于其依赖模块已经被构建,因此直接构建它。

模块间的依赖关系会将反应堆构成一个有向非循环图(Directed Acyclic Graph,DAG),各个模块是该图的节点,依赖关系构成了有向边。这个图不允许出现循环,因此,当出现模块A依赖于B, 而B又依赖于A的情况时,Maven就会报错。

6.2 裁剪反应堆

一般来说,用户会选择构建整个项目或者选择构建单个模块,但有些时候,用户会想
要仅仅构建完整反应堆中的某些个模块。换句话说,用户需要实时地裁剪反应堆。

Maven 提供很多的命令行选项支持裁剪反应堆,输入 mvn -h 可以看到这些选项:

  • -am, --also-make:同时构建所列模块的依赖模块;
  • -amd, -also-make-dependents:同时构建依赖于所列模块的模块;
  • -pl, --projects <arg>:构建指定的模块,模块间用逗号分隔;
  • -rf, -resume-from <arg>:从指定的模块回复反应堆。

下面还是以账户服务为例(为合并聚合和继承), 解释这几个选项的作用。默认情况从
account-aggregator 执行 mvn clean install 会得到如下完整的反应堆:

[INFO] --------------------------------
[INFO] Reactor Build Order:
[INFO]
[INFO] Account Aggregator
[INFO] Account Parent
[INFO] Account Email
[INFO] Account Persist
[INFO]
[INFO]---------------------------------

可以使用-pl 选项指定构建某几个模块,如运行如下命令:

$mvn clean install -pl account-email,account-persist

得到的反应堆为:

[INFO]---------------------------------
[INFO] Reactor Build Order:
[INFO]
[INFO] Account Email
[INFO] Account Persist
[INFO]
[INFO]---------------------------------

使用-am选项可以同时构建所列模块的依赖模块。例如:

$mvn clean install -pl account-email-am

由于account-email依赖于 account-parent, 因此会得到如下反应堆:

[INFO]-----------------------------------
[INFO] Reactor Build Order
[INFO]
[INFO] Account Parent
[INFO] Account Email
[INFO]
[INFO]-----------------------------------

使用 -amd选项可以同时构建依赖于所列模块的模块。例如:

$mvn clean install -pl account-parent -amd

由于 account-emailaccount-persist都依赖于 account-parent, 因此会得到如下反应堆:

[INPO]-------------------------------
[INFO] Reactor Build Order:
[INFO]
[INFO] Account Parent
[INFO] Account Email
[INFO] Account Persist
[INFO]
[INFO]------------------------------

使用 -rf选项可以在完整的反应堆构建顺序基础上指定从哪个模块开始构建。例如:

$mvn clean install -rf account-email

完整的反应堆构建顺序中,account-email 位于第三,它之后只有 account-persist,因此会得到如下的裁剪反应堆:

[INFO]----------------------------------
[INFO] Reactor Build Order:
[INFO]
[INFO] Account Email
[INFO] Account Persist
[INFO]
[INFO]----------------------------------

最后,在-pl -am或者 -pl -amd的基础上,还能应用-f参数,以对裁剪后的反应堆再次裁剪。例如:

$mvn clean install -pl account-parent -amd -rf account-email

该命令中的-pl-amd参数会裁剪出一个 account-parentaccount-emailaccount-persist的反应堆,在此基础上,-rf参数指定从account-email参数构建。因此会得到如下的反应堆:

[INFO]---------------------------------
[INFO] Reactor Build Order:
[INFO]
[INFO] Account-Email
[INFO] Account-Persist
[INFO]
[INFO]---------------------------------

在开发过程中,灵活应用上述4个参数,可以帮助我们跳过无须构建的模块,从而加速构建。在项目庞大、模块特别多的时候,这种效果就会异常明显。

🌾 总结

本文介绍并实现了账户注册服务的第二个模块account-persist。基于这一模块和前面实现的account-email, Maven的聚合特性得到了介绍和使用,从而产生了 account-aggregator模块。除了聚合之外,继承也是多模块项目不可不用的特性。account-parent模块伴随着继承的概念被一并引人,有了继承,项目的依赖和插件配置也得以大幅优化。

为了进一步消除大家可能存在的混淆,本文还专门将聚合与继承做了详细比较。Maven的一大设计理念 “约定优于配置” 在文中得以阐述,大家甚至可以了解到这个概念是如何通过超级POM的方式实现的。最后介绍了多模块构建的反应堆,包括其构建的顺序,以及可以通过怎样的方式裁剪反应堆。


【Maven教程】(七)聚合与继承:多模块项目实用特性介绍,反应堆构建及裁剪 ~-LMLPHP
09-28 10:46