解决Java 17 Maven项目BigQuery Storage的JPMS包冲突
2025-03-25 05:39:53
修复 Java 17 下 BigQuery Storage 库的 JPMS 包冲突 (Maven)
一、问题来了
在使用 Java 17 和 Maven 构建项目,想集成 Google Cloud BigQuery 时,碰到了一个棘手的编译问题。具体来说,是用了 google-cloud-bigquerystorage
这个库。
pom.xml
文件里的相关配置大概是这样:
<properties>
<java.version>17</java.version>
<!-- ... 其他属性 ... -->
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>libraries-bom</artifactId>
<!-- 注意 BOM 版本,这可能是关键点 -->
<version>26.49.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-bigquery</artifactId>
<!-- 注意这个版本比较老旧 -->
<version>1.2.8.RELEASE</version>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-bigquery</artifactId>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-bigquerystorage</artifactId>
</dependency>
<!-- ... 其他 Spring Boot 或项目依赖 ... -->
</dependencies>
同时,项目使用了 Java 模块系统 (JPMS),module-info.java
文件里写了:
module com.example.myproject {
// ... 其他 requires ...
requires proto.google.cloud.bigquerystorage.v1;
requires grpc.google.cloud.bigquerystorage.v1;
requires google.cloud.bigquerystorage; // 可能也需要这个
// ... 需要暴露的包 ...
// opens com.example.myproject.somepackage to spring.core;
}
一编译,哪怕还没开始用 BigQuery Storage API 的任何类,就直接报错:
java: java.lang.reflect.InvocationTargetException
... (堆栈跟踪) ...
Caused by: java.lang.module.ResolutionException: Modules google.cloud.bigquerystorage and grpc.google.cloud.bigquerystorage.v1 export package com.google.cloud.bigquery.storage.v1 to module spring.core
错误信息说得很明白:google.cloud.bigquerystorage
模块和 grpc.google.cloud.bigquerystorage.v1
模块都向 spring.core
模块(或者其他需要这个包的模块)导出了同一个包 com.google.cloud.bigquery.storage.v1
。
二、为什么会这样?剖析 JPMS 的“包分裂”问题
Java 9 引入的模块系统 (JPMS) 有一条核心规则:一个模块不能同时从两个不同的模块读取同一个包 。这就是所谓的“包分裂”(Split Package)问题。当编译器或运行时发现目标模块(比如报错信息里的 spring.core
或你自己的模块)的模块路径上,有两个不同的模块 JAR 文件都包含并导出了名为 com.google.cloud.bigquery.storage.v1
的包时,JPMS 就会阻止这种行为,因为它无法确定应该使用哪个模块提供的类。
在这个场景里:
google.cloud.bigquerystorage
这个库本身,为了提供高级 API,其内部实现会依赖底层的 Protobuf 定义 (proto.google.cloud...
) 和 gRPC 服务存根 (grpc.google.cloud...
)。它很可能在其自身的module-info.class
里重新导出了这些底层包,或者包含了这些包的内容。- 同时,我们又直接或间接地(可能通过
spring-cloud-gcp-starter-bigquery
)依赖了grpc.google.cloud.bigquerystorage.v1
这个专门提供 gRPC 接口的模块。
结果就是,两个模块 (google.cloud.bigquerystorage
和 grpc.google.cloud.bigquerystorage.v1
) 都试图提供 com.google.cloud.bigquery.storage.v1
包给其他模块,JPMS 不允许,编译失败。
虽然 Google 推荐在新项目中使用 Java 17,但看起来他们的一些库在模块化支持上可能存在需要用户特殊处理的地方,或者与其他库(尤其是较旧的集成库)配合时容易触发这类问题。
三、怎么办?解决包冲突的几种方案
别慌,这种情况通常有几种思路可以尝试解决。
方案一:检查并统一依赖版本
这是最常见的依赖冲突原因。pom.xml
中 spring-cloud-gcp-starter-bigquery
的版本 1.2.8.RELEASE
是一个相当早期的版本(大约发布于 2020 年末)。而 com.google.cloud:libraries-bom
的版本 26.49.0
是较新的(发布于 2024 年)。
老版本的 spring-cloud-gcp-starter-bigquery
可能会传递依赖一个旧版本 的 Google Cloud 库(包括 BigQuery Storage 相关的底层库),这个旧版本与 BOM 管理的新版本产生了结构性冲突,尤其是在 JPMS 环境下对包的导出方式可能发生了变化。
原理和作用: 通过统一主要依赖(尤其是 Spring Cloud GCP 和 Google Cloud 库)到相互兼容且较新的版本,可以避免因版本混杂导致的底层库实现差异和模块声明冲突。
操作步骤:
-
升级 Spring Cloud GCP 依赖: 查找与你使用的 Spring Boot 版本兼容的、更新的
spring-cloud-gcp
版本。Spring Cloud 有自己的版本发布序列(如2023.x.x
),通常需要配合对应的 Spring Boot 版本。查阅 Spring Cloud GCP 文档 来确定合适的版本。
例如,更新pom.xml
中的spring-cloud-gcp-starter-bigquery
版本。你可能还需要引入 Spring Cloud 的 BOM 来管理相关版本。<dependencyManagement> <dependencies> <!-- 引入 Spring Cloud BOM --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <!-- 使用与 Spring Boot 匹配的 Spring Cloud 版本 --> <version>2023.0.2</version> <!-- 示例版本,请替换为实际兼容版本 --> <type>pom</type> <scope>import</scope> </dependency> <!-- Google Cloud BOM --> <dependency> <groupId>com.google.cloud</groupId> <artifactId>libraries-bom</artifactId> <version>26.49.0</version> <type>pom</type> <scope>import</scope> </dependency> <!-- 注意:Spring Cloud GCP 有自己的 BOM,推荐使用 --> <dependency> <groupId>com.google.cloud</groupId> <artifactId>spring-cloud-gcp-dependencies</artifactId> <!-- 使用与 Spring Cloud 版本匹配的 GCP 版本 --> <version>5.2.3</version> <!-- 示例版本,请替换为实际兼容版本 --> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- 移除版本号,让 BOM 管理 --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-gcp-starter-bigquery</artifactId> </dependency> <dependency> <groupId>com.google.cloud</groupId> <artifactId>google-cloud-bigquery</artifactId> </dependency> <dependency> <groupId>com.google.cloud</groupId> <artifactId>google-cloud-bigquerystorage</artifactId> </dependency> <!-- ... --> </dependencies>
-
分析依赖树: 使用 Maven 命令查看实际解析的依赖版本,找出冲突点。
mvn dependency:tree -Dverbose
仔细查看输出,特别是与
bigquery
,grpc
,protobuf
相关的库,看看是否有多个不同版本的同名库被引入,尤其关注grpc-google-cloud-bigquerystorage-v1
和proto-google-cloud-bigquerystorage-v1
。 -
调整或移除 Google Cloud BOM: 作为诊断步骤,可以暂时移除
com.google.cloud:libraries-bom
,看看错误是否改变。如果错误消失或变化,说明问题确实与 BOM 管理的版本有关。但这通常不是最终方案,BOM 的目的是保证 Google Cloud 库之间的一致性。关键在于找到与 Spring Cloud GCP 兼容的 BOM 版本。
安全建议: 升级依赖库可能会引入 API 变更或行为变化,务必仔细测试应用程序的功能。查阅库的发布说明(Release Notes)。
方案二:简化 module-info.java
中的 requires
你是否真的需要同时 requires
proto...
和 grpc...
这两个底层模块?google-cloud-bigquerystorage
这个高级库通常会负责在其 module-info
中声明对底层模块的依赖(requires transitive ...
)。
原理和作用: 通过只声明对最高层、你直接使用的库(如 google-cloud-bigquerystorage
)的依赖,让 JPMS 自动处理传递依赖。这样可以避免手动声明可能导致冲突的底层模块。
操作步骤:
-
修改
module-info.java
,尝试移除对底层proto
和grpc
模块的直接requires
语句:module com.example.myproject { // ... 其他 requires ... // 只保留对直接使用的 BigQuery Storage 库的依赖 requires google.cloud.bigquerystorage; requires google.cloud.bigquery; // 如果直接使用了 BigQuery Client API requires spring.cloud.gcp.starter.bigquery; // 如果需要引用 starter 提供的 bean // 移除下面这两行,除非你明确需要直接使用它们导出的类型 // requires proto.google.cloud.bigquerystorage.v1; // requires grpc.google.cloud.bigquerystorage.v1; // ... 其他配置,如 opens ... opens com.example.myproject.bq Cto spring.beans, spring.context; opens com.example.myproject.config to spring.beans, spring.context; // ... 根据需要开放包给 Spring 等框架 }
-
重新编译项目。
进阶使用技巧: 如果你的代码确实需要直接使用 proto...
或 grpc...
包中的类型(比如构造特定的 Request 对象),那么你可能还是需要 requires
它们。但在这种情况下,更应该首先考虑方案一(版本统一),因为这通常意味着上层库的模块设计就可能存在问题。如果版本统一后,requires google.cloud.bigquerystorage;
就能传递性地满足你对底层包的需求,那就不需要手动加了。
方案三:使用 Maven 的 <exclusions>
排除冲突依赖
如果通过 mvn dependency:tree
分析发现,某个特定依赖(比如那个旧的 spring-cloud-gcp-starter-bigquery
)引入了与 BOM 指定版本冲突的 grpc-google-cloud-bigquerystorage-v1
或 proto-google-cloud-bigquerystorage-v1
,你可以尝试从该依赖中排除掉这些冲突的传递依赖。
原理和作用: 强制 Maven 在解析依赖时忽略掉特定路径引入的某个库,让你有机会通过其他路径(例如,由 google-cloud-bigquerystorage
传递依赖的,或者直接声明的)引入你想要的、兼容的版本。
操作步骤:
假设分析后发现 spring-cloud-gcp-starter-bigquery
引入了问题 grpc
库:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gcp-starter-bigquery</artifactId>
<version>1.2.8.RELEASE</version> <!-- 仍然使用旧版本(不推荐,但用于演示 exclusion) -->
<exclusions>
<exclusion>
<!-- 替换为实际冲突的 groupId 和 artifactId -->
<groupId>com.google.api.grpc</groupId>
<artifactId>grpc-google-cloud-bigquerystorage-v1</artifactId>
</exclusion>
<!-- 可能还需要排除 proto 库 -->
<exclusion>
<groupId>com.google.api.grpc</groupId>
<artifactId>proto-google-cloud-bigquerystorage-v1</artifactId>
</exclusion>
<!-- 根据 dependency:tree 的结果添加其他需要排除的冲突库 -->
</exclusions>
</dependency>
<!-- 确保 BOM 或直接依赖引入了正确版本的库 -->
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-bigquerystorage</artifactId>
<!-- 版本由 BOM 控制 -->
</dependency>
重要提示: 使用 <exclusions>
要非常小心!
- 必须先用
mvn dependency:tree
精确找到是哪个依赖引入了哪个冲突项。groupId
和artifactId
必须准确无误。 - 排除后,要确保项目仍然能获得所需的功能。如果被排除的库是必需的,你需要通过其他途径(比如 BOM 或直接声明)引入一个兼容的版本。
- 过度使用
exclusions
会让pom.xml
变得复杂且难以维护。优先考虑方案一(统一版本)。
方案四:放弃 JPMS 模块化(如果可行)
如果项目对 JPMS 的强封装性、显式依赖等特性需求不高,或者模块化带来的麻烦大于收益,可以考虑退回到传统的 classpath 模式。
原理和作用: 不使用 module-info.java
文件,项目就不会按照 JPMS 的规则进行编译和运行。JVM 会使用 classpath 来查找类,classpath 对“包分裂”的检查比较宽松(虽然运行时仍可能遇到问题,但编译通常能过)。
操作步骤:
- 删除 项目源代码根目录下的
module-info.java
文件。 - 确保 Maven 编译插件配置没有强制使用模块路径。通常,移除
module-info.java
后,Maven 会自动切换回 classpath 模式。 - 清理并重新编译项目 (
mvn clean package
)。
安全建议/注意事项:
- 这样做会失去 JPMS 带来的好处,比如更强的封装性、更快的启动速度(对某些应用而言)、更清晰的依赖关系等。
- 即使在 classpath 模式下,如果运行时真的加载了来自不同 JAR 的同名包下的类,依然可能发生难以预料的行为或
LinkageError
。只是编译期检查被绕过了。
方案五:使用 --patch-module
(进阶)
这是一个更高级的 JPMS 技术,用于在编译或运行时“修补”模块,比如将某个包的内容“合并”到另一个模块中。
原理和作用: 告诉 Java 编译器或 JVM,某个模块中的特定包,其内容应该从另一个位置(比如另一个 JAR 或目录)加载,或者将另一个模块的内容视为目标模块的一部分。这可以用来“欺骗”JPMS,让它认为冲突的包实际上来自同一个模块。
操作步骤 (编译时示例):
假设你想让 grpc.google.cloud.bigquerystorage.v1
中的 com.google.cloud.bigquery.storage.v1
包被视为 google.cloud.bigquerystorage
模块的一部分:
你需要在 Maven 编译器插件的配置中加入 --patch-module
参数:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version> <!-- 使用较新版本 -->
<configuration>
<source>17</source>
<target>17</target>
<compilerArgs>
<arg>--patch-module</arg>
<!-- 格式: <module-name>=<path/to/jar/or/dir> -->
<!-- 这里需要找到包含 grpc...v1 包的 JAR 文件的实际路径 -->
<arg>google.cloud.bigquerystorage=${settings.localRepository}/com/google/api/grpc/grpc-google-cloud-bigquerystorage-v1/x.y.z/grpc-google-cloud-bigquerystorage-v1-x.y.z.jar</arg>
<!-- 注意:上面路径中的 x.y.z 版本号和具体路径需要根据实际情况确定 -->
<!-- 可能需要添加多个 patch -->
</compilerArgs>
</configuration>
</plugin>
运行时也需要添加类似的 JVM 参数。
安全建议/注意事项:
--patch-module
非常强大但也非常复杂。你需要精确知道哪个 JAR 文件包含了哪个包,并且这个 JAR 在构建环境或运行环境中的确切路径。这在不同环境(开发、CI、生产)中可能难以维护。- 它隐藏了底层的模块设计问题,可能导致维护困难和意外的行为。
- 通常被视为最后的手段,当无法通过调整依赖或修改代码来解决问题时才考虑。优先尝试前面几种方案。
尝试以上方案,大概率能解决你遇到的 JPMS 包冲突问题。建议从方案一开始,它通常是根本原因所在。