Maven 实战(三)——多模块项目的 POM 重构

  • 许晓斌

2011 年 1 月 9 日

话题:Java重构DevOps语言 & 开发

在本专栏的上一篇文章POM 重构之增还是删中,我们讨论了一些简单实用的 POM 重构技巧,包括重构的前提——持续集成,以及如何通过添加或者删除内容来提高 POM 的可读性和构建的稳定性。但在实际的项目中,这些技巧还是不够的,特别值得一提的是,实际的 Maven 项目基本都是多模块的,如果仅仅重构单个 POM 而不考虑模块之间的关系,那就会造成无谓的重复。本文就讨论一些基于多模块的 POM 重构技巧。

重复,还是重复

程序员应该有狗一般的嗅觉,要能嗅到重复这一最常见的坏味道,不管重复披着怎样的外衣,一旦发现,都应该毫不留情地彻底地将其干掉。不要因为 POM 不是产品代码而纵容重复在这里发酵,例如这样一段代码就有重复:

<dependency>
  <groupId>org.springframework</groupId>
  <artifactid>spring-beans</artifactId>
  <version>2.5</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactid>spring-context</artifactId>
  <version>2.5</version>
</dependency>
<dependency>
  <groupId>org.springframework</groupId>
  <artifactid>spring-core</artifactId>
  <version>2.5</version>
</dependency>

你会在一个项目中使用不同版本的 SpringFramework 组件么?答案显然是不会。因此这里就没必要重复写三次 <version>2.5</version>,使用 Maven 属性将 2.5 提取出来如下:

<properties>
  <spring.version>2.5</spring.version>
</properties>
<depencencies>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactid>spring-beans</artifactId>
    <version>${spring.version}</version>
  </dependency>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactid>spring-context</artifactId>
    <version>${spring.version}</version>
  </dependency>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactid>spring-core</artifactId>
    <version>${spring.version}</version>
  </dependency>
</depencencies>

现在 2.5 只出现在一个地方,虽然代码稍微长了点,但重复消失了,日后升级依赖版本的时候,只需要修改一处,而且也能避免漏掉升级某个依赖。

读者可能已经非常熟悉这个例子了,我这里再啰嗦一遍是为了给后面做铺垫,多模块 POM 重构的目的和该例一样,也是为了消除重复,模块越多,潜在的重复就越多,重构就越有必要。

消除多模块依赖配置重复

考虑这样一个不大不小的项目,它有 10 多个 Maven 模块,这些模块分工明确,各司其职,相互之间耦合度比较小,这样大家就能够专注在自己的模块中进行开发而不用过多考虑他人对自己的影响。(好吧,我承认这是比较理想的情况)那我开始对模块 A 进行编码了,首先就需要引入一些常见的依赖如 JUnit、Log4j 等等:

  <dependency>
    <groupId>junit</groupId>
    <artifactid>junit</artifactId>
    <version>4.8.2</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>log4j</groupId>
    <artifactid>log4j</artifactId>
    <version>1.2.16</version>
  </dependency>

我的同事在开发模块 B,他也要用 JUnit 和 Log4j(我们开会讨论过了,统一单元测试框架为 JUnit 而不是 TestNG,统一日志实现为 Log4j 而不是 JUL,为什么做这个决定就不解释了,总之就这么定了)。同事就写了如下依赖配置:

  <dependency>
    <groupId>junit</groupId>
    <artifactid>junit</artifactId>
    <version>3.8.2</version>
  </dependency>
  <dependency>
    <groupId>log4j</groupId>
    <artifactid>log4j</artifactId>
    <version>1.2.9</version>
  </dependency>

看出什么问题来没有?对的,他漏了 JUnit 依赖的 scope,那是因为他不熟悉 Maven。还有什么问题?对,版本!虽然他和我一样都依赖了 JUnit 及 Log4j,但版本不一致啊。我们开会讨论没有细化到具体用什么版本,但如果一个项目同时依赖某个类库的多个版本,那是十分危险的!OK,现在只是两个模块的两个依赖,手动修复一下没什么问题,但如果是 10 个模块,每个模块 10 个依赖或者更多呢?看来这真是一个泥潭,一旦陷进去就难以收拾了。

好在 Maven 提供了优雅的解决办法,使用继承机制以及 dependencyManagement 元素就能解决这个问题。注意,是 dependencyMananget 而非 dependencies。也许你已经想到在父模块中配置 dependencies,那样所有子模块都自动继承,不仅达到了依赖一致的目的,还省掉了大段代码,但这么做是有问题的,例如你将模块 C 的依赖 spring-aop 提取到了父模块中,但模块 A 和 B 虽然不需要 spring-aop,但也直接继承了。dependencyManagement 就没有这样的问题,dependencyManagement 只会影响现有依赖的配置,但不会引入依赖。例如我们可以在父模块中配置如下:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>junit</groupId>
      <artifactid>junit</artifactId>
      <version>4.8.2</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>log4j</groupId>
      <artifactid>log4j</artifactId>
      <version>1.2.16</version>
    </dependency>
  </dependencies>
</dependencyManagement>

这段配置不会给任何子模块引入依赖,但如果某个子模块需要使用 JUnit 和 Log4j 的时候,我们就可以简化依赖配置成这样:

  <dependency>
    <groupId>junit</groupId>
    <artifactid>junit</artifactId>
  </dependency>
  <dependency>
    <groupId>log4j</groupId>
    <artifactid>log4j</artifactId>
  </dependency>

现在只需要 groupId 和 artifactId,其它元素如 version 和 scope 都能通过继承父 POM 的 dependencyManagement 得到,如果有依赖配置了 exclusions,那节省的代码就更加可观。但重点不在这,重点在于现在能够保证所有模块使用的 JUnit 和 Log4j 依赖配置是一致的。而且子模块仍然可以按需引入依赖,如果我不配置 dependency,父模块中 dependencyManagement 下的 spring-aop 依赖不会对我产生任何影响。

也许你已经意识到了,在多模块 Maven 项目中,dependencyManagement 几乎是必不可少的,因为只有它是才能够有效地帮我们维护依赖一致性

本来关于 dependencyManagement 我想介绍的也差不多了,但几天前和Sunng的一次讨论让我有了更多的内容分享。那就是在使用 dependencyManagement 的时候,我们可以不从父模块继承,而是使用特殊的 import scope 依赖。Sunng 将其列为自己的Maven Recipe #0,我再简单介绍下。

我们知道 Maven 的继承和 Java 的继承一样,是无法实现多重继承的,如果 10 个、20 个甚至更多模块继承自同一个模块,那么按照我们之前的做法,这个父模块的 dependencyManagement 会包含大量的依赖。如果你想把这些依赖分类以更清晰的管理,那就不可能了,import scope 依赖能解决这个问题。你可以把 dependencyManagement 放到单独的专门用来管理依赖的 POM 中,然后在需要使用依赖的模块中通过 import scope 依赖,就可以引入 dependencyManagement。例如可以写这样一个用于依赖管理的 POM:

<project>
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.juvenxu.sample</groupId>
  <artifactId>sample-dependency-infrastructure</artifactId>
  <packaging>pom</packaging>
  <version>1.0-SNAPSHOT</version>
  <dependencyManagement>
    <dependencies>
        <dependency>
          <groupId>junit</groupId>
          <artifactid>junit</artifactId>
          <version>4.8.2</version>
          <scope>test</scope>
        </dependency>
        <dependency>
          <groupId>log4j</groupId>
          <artifactid>log4j</artifactId>
          <version>1.2.16</version>
        </dependency>
    </dependencies>
  </dependencyManagement>
</project>

然后我就可以通过非继承的方式来引入这段依赖管理配置:

  <dependencyManagement>
    <dependencies>
        <dependency>
          <groupId>com.juvenxu.sample</groupId>
          <artifactid>sample-dependency-infrastructure</artifactId>
          <version>1.0-SNAPSHOT</version>
          <type>pom</type>
          <scope>import</scope>
        </dependency>
    </dependencies>
  </dependencyManagement>

  <dependency>
    <groupId>junit</groupId>
    <artifactid>junit</artifactId>
  </dependency>
  <dependency>
    <groupId>log4j</groupId>
    <artifactid>log4j</artifactId>
  </dependency>

这样,父模块的 POM 就会非常干净,由专门的 packaging 为 pom 的 POM 来管理依赖,也契合的面向对象设计中的单一职责原则。此外,我们还能够创建多个这样的依赖管理 POM,以更细化的方式管理依赖。这种做法与面向对象设计中使用组合而非继承也有点相似的味道。

消除多模块插件配置重复

与 dependencyManagement 类似的,我们也可以使用 pluginManagement 元素管理插件。一个常见的用法就是我们希望项目所有模块的使用 Maven Compiler Plugin 的时候,都使用 Java 1.5,以及指定 Java 源文件编码为 UTF-8,这时可以在父模块的 POM 中如下配置 pluginManagement:

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

这段配置会被应用到所有子模块的 maven-compiler-plugin 中,由于 Maven 内置了 maven-compiler-plugin 与生命周期的绑定,因此子模块就不再需要任何 maven-compiler-plugin 的配置了。

与依赖配置不同的是,通常所有项目对于任意一个依赖的配置都应该是统一的,但插件却不是这样,例如你可以希望模块 A 运行所有单元测试,模块 B 要跳过一些测试,这时就需要配置 maven-surefire-plugin 来实现,那样两个模块的插件配置就不一致了。这也就是说,简单的把插件配置提取到父 POM 的 pluginManagement 中往往不适合所有情况,那我们在使用的时候就需要注意了,只有那些普适的插件配置才应该使用 pluginManagement 提取到父 POM 中。

关于插件 pluginManagement,Maven 并没有提供与 import scope 依赖类似的方式管理,那我们只能借助继承关系,不过好在一般来说插件配置的数量远没有依赖配置那么多,因此这也不是一个问题。

小结

关于 Maven POM 重构的介绍,在此就告一段落了。基本上如果你掌握了本篇和上一篇 Maven 专栏讲述的重构技巧,并理解了其背后的目的原则,那么你肯定能让项目的 POM 变得更清晰易懂,也能尽早避免一些潜在的风险。虽然 Maven 只是用来帮助你构建项目和管理依赖的工具,POM 也并不是你正式产品代码的一部分。但我们也应该认真对待 POM,这有点像测试代码,以前可能大家都觉得测试代码可有可无,更不会去用心重构优化测试代码,但随着敏捷开发和 TDD 等方式越来越被人接受,测试代码得到了开发人员越来越多的关注。因此这里我希望大家不仅仅满足于一个“能用”的 POM,而是能够积极地去修复 POM 中的坏味道。

关于作者

许晓斌(Juven Xu),国内社区公认的 Maven 技术专家、Maven 中文用户组创始人、Maven 技术的先驱和积极推动者。对 Maven 有深刻的认识,实战经验丰富,不仅撰写了大量关于 Maven 的技术文章,而且还翻译了开源书籍《Maven 权威指南》,对 Maven 技术在国内的普及和发展做出了很大的贡献。就职于 Maven 之父的公司,负责维护 Maven 中央仓库,是 Maven 仓库管理器 Nexus(著名开源软件)的核心开发者之一,曾多次受邀到淘宝等大型企业开展 Maven 方面的培训。此外,他还是开源技术的积极倡导者和推动者,擅长 Java 开发和敏捷开发实践。他的个人网站是:http://www.juvenxu.com

Java重构DevOps语言 & 开发