Administrator
发布于 2021-05-25 / 5115 阅读
99

Maven 多模块项目拆分与依赖管理

编译一次 8 分钟,改一行代码等半天

5 月中旬,我们的主工程已经长成一个 47 个 package、21 万行的单体。mvn clean install 的时间:

$ time mvn clean install -DskipTests
[INFO] BUILD SUCCESS
[INFO] Total time:  08:12 min

real    8m12.441s

8 分 12 秒。本地调试时改一行代码要等 8 分钟,一天下来光等编译就浪费快一小时。

更糟的是依赖混乱:pom.xml 有 480 行,同一个 fastjson 在三个地方配了三个不同版本,谁生效全看谁写在前面。

按什么维度拆

拆多模块有两种常见思路:

方式结构问题
按技术分层controller / service / dao 各一个模块业务边界还是模糊,改一个需求要动三个模块
按业务边界order / item / user 各一个模块需要额外抽 api 模块解决互相调用

我们选了按业务边界。判断依据很简单:模块的划分应该和团队的沟通结构一致。我们是按业务分组的,订单组改订单、商品组改商品,模块跟着业务走,代码所有权才清晰。

最终结构

trade-parent/                  <packaging>pom</packaging>
├── pom.xml
├── trade-common/              <packaging>jar</packaging>  工具类、常量、异常
├── trade-order-api/                                       订单对外接口 + DTO
├── trade-order-service/                                   订单业务逻辑 + Mapper
├── trade-item-api/
├── trade-item-service/
├── trade-user-api/
├── trade-user-service/
└── trade-bootstrap/                                       唯一的启动模块

apiservice 分开,是为了解决循环依赖。订单服务要查商品,商品服务也要查订单(比如下单后要更新商品销量),如果只有一个模块就会互相依赖。把对外接口和 DTO 抽到 api 模块,service 只依赖别人的 api,循环就断了。

父 POM 的写法

<project>
    <groupId>com.xxx.trade</groupId>
    <artifactId>trade-parent</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <modules>
        <module>trade-common</module>
        <module>trade-order-api</module>
        <module>trade-order-service</module>
        <module>trade-item-api</module>
        <module>trade-item-service</module>
        <module>trade-user-api</module>
        <module>trade-user-service</module>
        <module>trade-bootstrap</module>
    </modules>

    <properties>
        <java.version>11</java.version>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <!-- 版本集中在这里,子模块引用时不写版本号 -->
        <spring-boot.version>2.4.5</spring-boot.version>
        <mybatis-plus.version>3.4.2</mybatis-plus.version>
        <jackson.version>2.11.4</jackson.version>
        <guava.version>30.1-jre</guava.version>
        <lombok.version>1.18.18</lombok.version>
    </properties>

    <!-- 关键:这里只声明版本,不实际引入依赖 -->
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.baomidou</groupId>
                <artifactId>mybatis-plus-boot-starter</artifactId>
                <version>${mybatis-plus.version}</version>
            </dependency>
            <!-- 项目自己的模块也要声明,子模块引用时才不用写版本 -->
            <dependency>
                <groupId>com.xxx.trade</groupId>
                <artifactId>trade-order-api</artifactId>
                <version>${project.version}</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <pluginManagement>
            <plugins>
                <plugin>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-maven-plugin</artifactId>
                    <version>${spring-boot.version}</version>
                </plugin>
            </plugins>
        </pluginManagement>
    </build>
</project>

dependencyManagement 和 dependencies 的区别

这是新人最容易搞混的一件事,也是这次拆分里我要反复解释的一点。

dependencyManagementdependencies
作用声明版本和作用域实际引入依赖
子模块是否自动继承否,要自己再写一次(可不写版本)是,子模块全部自动继承
典型用法父 POM 统一管版本子模块声明自己要用的

所以子模块里这么写就够了,不用带 <version>

<dependencies>
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <!-- 版本由父 POM 的 dependencyManagement 决定 -->
    </dependency>
</dependencies>

好处是升版本只改一处。我们把 Jackson 从 2.11.2 升到 2.11.4(那次是为了修一个安全漏洞),只改了父 POM 的一行,8 个模块全部生效。

构建顺序:不是按 modules 写的顺序

一个常见误解是 Maven 按 <modules> 里的书写顺序构建。不是。Maven 会先根据依赖关系做拓扑排序,被依赖的先构建。

$ mvn clean install -DskipTests
[INFO] Reactor Summary:
[INFO]
[INFO] trade-parent .................. SUCCESS [  0.412 s]
[INFO] trade-common .................. SUCCESS [  4.821 s]
[INFO] trade-order-api ............... SUCCESS [  3.214 s]
[INFO] trade-item-api ................ SUCCESS [  2.882 s]
[INFO] trade-user-api ................ SUCCESS [  2.441 s]
[INFO] trade-order-service ........... SUCCESS [ 21.882 s]
[INFO] trade-item-service ............ SUCCESS [ 18.204 s]
[INFO] trade-user-service ............ SUCCESS [ 12.441 s]
[INFO] trade-bootstrap ............... SUCCESS [  8.882 s]
[INFO] BUILD SUCCESS
[INFO] Total time:  01:15 min

注意 trade-order-api 写在 trade-item-api 前面,但这是巧合——真实顺序是由 Reactor 算出来的。如果把两个 api 的位置对调,输出顺序不变。

顺带一提,Reactor 还会报循环依赖:

[ERROR] The projects in the reactor contain a cyclic reference:
  trade-order-service --> trade-item-service --> trade-order-service

我们拆分过程中遇到两次,都是靠把接口抽到 api 模块解决的。

提速的三个参数

# 1. 只构建指定模块,-am 表示同时构建它依赖的模块
$ mvn install -pl trade-order-service -am -DskipTests
[INFO] Total time:  38.2 s

# 2. 并行构建,-T 1C 表示每个 CPU 核一个线程
$ mvn clean install -T 1C -DskipTests
[INFO] Total time:  02:48 min      # 4 核机器

# 3. 跳过测试(本地调试时)
-DskipTests

-pl(projects)和 -am(also make)是我用得最多的组合。改订单服务的代码,只构建这一个模块和它依赖的三个模块,38 秒,比之前的 8 分 12 秒快 13 倍。

并行构建 -T 1C 在这套结构里收益有限:模块之间依赖是链式的(common → api → service → bootstrap),能并行的只有几个 api 模块,实测只从 1 分 15 秒降到 1 分 02 秒。真正的提速来自按需构建,不是并行。模块拆得越独立,-T 才越有用。

四个踩到的坑

1. spring-boot-maven-plugin 只能用在启动模块

这个坑几乎所有人都会踩一次。如果在 trade-common 里也配了 spring-boot-maven-plugin,它会把模块打成 fat jar(BOOT-INF/classes 结构),其他模块就引用不到它的类了

[ERROR] Failed to execute goal on project trade-order-service:
Could not resolve dependencies for project ...:
Could not find artifact com.xxx.trade:trade-common:jar:1.0.0-SNAPSHOT

或者更隐蔽的情况:能编译通过,运行时报 ClassNotFoundException

正确做法:只有 trade-bootstrap 配这个插件,其他模块用默认的 maven-jar-plugin

<!-- trade-bootstrap/pom.xml -->
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

2. 依赖冲突的两条仲裁原则

拆完之后第一件事是查冲突:

$ mvn dependency:tree -Dverbose | grep -i "omitted for conflict"
[INFO] |  \- (com.fasterxml.jackson.core:jackson-databind:jar:2.9.8:compile - omitted for conflict with 2.11.4)
[INFO] +- (org.slf4j:slf4j-api:jar:1.7.25:compile - omitted for conflict with 1.7.30)

Maven 解决冲突有两条规则:

  1. 最短路径优先:A → B → C → X(1.0) 和 A → D → X(2.0),选 X(2.0),因为路径更短
  2. 路径相同时,最先声明的优先:谁在 pom 里写前面选谁

第二条很坑:调整 pom 里的依赖顺序会改变最终版本。所以依赖版本的确定不能靠顺序,必须用 dependencyManagement 锁死,它是优先级最高的。

3. 排除传递依赖

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <exclusions>
        <exclusion>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-annotation</artifactId>
        </exclusion>
    </exclusions>
</dependency>

我们排除最多的是日志框架。logbackslf4j-log4j12log4j-over-slf4j 混在一起会导致启动时报 multiple binding 警告。

4. common 模块别什么都往里塞

拆分后 trade-common 迅速膨胀,因为它"谁都能依赖"。三个月后它有了 200 多个类,包含了订单的工具类、商品的常量、营销的枚举——又变成了一个小单体,而且所有人都依赖它,改一行就要全量构建。

我们后来把 trade-common 拆成了 trade-common-core(纯工具,无业务)和 trade-common-model(跨业务的通用 DTO),并且定了一条规矩:业务相关的常量和枚举放在各自的 api 模块里,不许进 common。

scope 的四种取值

scope编译测试运行打包典型
compile(默认)业务依赖
providedlombok、servlet-api
runtimeMySQL 驱动
testjunit、mockito

provided 最典型的例子是 lombok,编译时要用,运行时不需要,打进包里只会让 fat jar 变大。

就写到这。如果哪天你也被《Maven 多模块项目拆分与依赖管理》里同一个坑绊住,回来翻这篇,能省半小时。

参考