在英国 Devoxx 的演讲中,JFrog 的开发倡导者 Ixchel Ruiz 和 Oracle 首席产品经理 Andres Almiray 共同介绍了多个“Maven 难题”,以及摆脱阿帕奇 Maven “依赖地狱”的可能解决方案。这次演讲中涉及了直接、传递、父级 POM,以及物料清单(BOM)的导入。
Ruiz 以对工具价值的思考展开了演讲:
Ruiz:作为开发者,如果我的工具在做它该做的,我会感觉很好。
在清楚了解“好工具”时,“魔法”就会发生。正如标题所述,这次演讲主题时关于构建工具,更确切地说,是关于阿帕奇 Maven 的构建工具。
据 JFrog 制品仓库(Artifactory)的统计数据和 JRebel 或 JetBrains 的开发者生产力调查,Maven 仍是主流 Java 开发者的构建工具,市场份额分别占据 68%和 73%。
作为 Gradle 的长期支持者,Almiray 称即使是 Gradle 或 sbt 也或多或少地依赖 Maven。因此,即使是想要以自己的方式编码,人们仍然需要通过 POM 格式解决依赖关系。而有时这些东西又不会如人预期一般正常工作,或是说像是经典的 Maven 一样运作。
在一个新克隆的 maven 项目上,你首先会做什么?演讲者以这个问题为切入,开始了对问题的回答。Almiray 鼓励观众在除项目依赖于其他远程项目的情况下,将本能的“mvn clean install”替换为“|mvn verify”,因为“检查(verify)”是 Maven 生命周期中“安装(install)”的上一步,是通过构建并运行测试进行项目验证的。而安装则仅仅是将构建(编译和打包)结果从文件系统(构建的位置)复制到仓库。
随后,演讲进入了互动问卷环节,由听众对问卷内容进行实时回复。在问题的描述中,如果依赖的坐标(即 groupId、artificatId)相同但版本号不同,那么麻烦就会出现。
主持人在“暖场问题”中确定了听众所使用的 Maven 版本、安装方式,以及是否使用 Maven 守护进程(daemon)。针对听众的选择,二位演讲者建议使用 SDKMAN(即使是在两个不同终端窗口内,也允许使用两套不同版本的 Java),用于提升速度的 Maven 守护进程,以及新版本 Maven 3.9.x 以进入更为颠覆性的 4.x 版本。
在演讲的问答阶段,二位演讲者以谷歌 Guava 依赖为例,但理由不是因为“大家都恨 Guava,而是因为大家都用 Guava”。他们提出了多个场景、问题、解答,以及优化和解决方案的相关建议,并将场景分为了三部分:单 POM 文件的依赖性、对父 POM 文件的依赖性,以及 BOM 导入。
单 POM 文件的依赖管理
第一种情况,连续声明了两个不同版本 Guava 的简单项目,哪一个会被采用?听众的回答几乎是在两个版本和构建错误之间五五开。虽然这些看起来仅仅是个简单的规则应用,但由于目前存在的众多版本和插件组合,事情往往会变得更加复杂。比如,这种情况在 Maven 4.x 中会出现构建错误,但在 3.x 中仅会以警告的形似出现。
这个问题的答案是,在 28.0 版本的 Guava 中会被解决,但因为该版本号不够高,Maven 永远会采用最后一个声明的依赖。在加上 Maven 无法理解版本号,这些在它眼里仅仅只是字符串。为确保这些情况不会再发生,Ruiz 和 Almiray 建议在 Maven 4.x 版本普及之前采用 Maven 增强(enforcer)插件中的“禁止重复 POM 版本号规则”。首次使用这个插件时大概会非常痛苦,因为该规则会生成一个构建错误,迫使你为项目选择合适的版本。
下一个问题是对上一个问题的改版,将第二个依赖换为了更高版本的传递性依赖。即使第二个也是 POM 文件里最后声明的依赖版本号更高,直接的“依赖版本总会赢”。Almiray 提及阿帕奇 Maven 的前主席 Robert Scholte 曾强调,Maven 这项工具无法理解语义上的版本号划分,它只认得依赖在“图中的位置”。此外,依赖图中同一依赖的不同版本可能会导致应用程序时不时的崩溃。为避免这类情况的发生,可使用 Maven 增强插件中的“依赖收敛规则”,以确保版本号的一致。如果需要强调版本号在语义上的一致性,可使用增强规则“需求依赖项上界”,该规则可给出依赖图中的可用新版本。将两项规则相结合后,就能得知同一依赖的两个版本,以及依赖的可用新版本。
第二种情况则引入了依赖管理的概念,其原理更像是查找表。在 Maven 构建图时,会在表中搜索匹配的依赖(artifactId 及 groupId),并选择其所定义的版本。第一个例子中只有依赖管理块和通过谷歌 Truth 获取的横向依赖,而第二个例子中则额外增加了直接依赖的内容。在例子一中,定义在依赖管理块中定义的版本号会“赢”,而例子二中则是直接依赖“赢”,也就是说“无论是在图中哪里定义的,直接依赖总会赢”。
另一个例子中则使用了两个依赖,二者均带来了与根距离相同的传递性依赖(“这点很重要”),而再将依赖管理块加进来又会让情况有所不同。听众们认为可以将直接依赖的情况套用,所以图中最后定义的依赖会“赢”,但传递性依赖的情况却是恰恰相反,即第一个库中、第一且最近的传递性依赖将“赢”。在这种情况下,由于这两个依赖距离相同,所以 Guice 带来的横向依赖(Guava 30.1)会赢。在依赖块加入后,其所定义的版本号将“赢”。
父 POM 文件依赖
在这段演讲中,二位演讲者又在依赖管理中加入了新的复杂因素:父 POM。每个 POM 都可以有一个父 POM,并为依赖管理带来不同的背景。父级关系是在子级层面定义的,父级不会知道任何继承自己子级的信息。每个 POM 都会有一个父级,如果没有明确定义,那么父级将默认成为 super POM。而 Maven 之所以能在只有基本插件的情况下构建项目,是因为 super POM 中包含了所有需要插件。
Almiray:单个 POM 文件很好处理,但难免会出现多个 POM 文件的情况。
Ruiz:当然,这时候事情就好玩了。
在另一个例子中,情况一是父系中定义一个直接依赖,子系中定义一个传递依赖;情况二中则新增了一个依赖管理块。因为父 POM 是直接导入到子 POM 中,我们可以随时回到前一阶段,即有直接依赖、传递依赖,以及依赖块的单 POM,而正如所料,“直接依赖总会赢”。唯一的不同则是生效的 POM 同时也会导入父 POM 中的内容。因此,依赖是否能被解决取决于其在生效 POM 中的位置。
BOM 依赖
演讲中使用的最后一个概念是物料清单(BOM),是与别称“安全物什”的软件物料清单(SBOM)不同的。虽然没有对 BOM 的官方定义或分类,但据 Almiray 的说法,BOM 可以分为以下两类:
库 BOM:定义了项目与单一库的关联。举例来说,JUnit 或 Jackson BOM 定义且仅定义了一切与 JUnit 相关。
堆栈 BOM: Spring 或 Quarkus BOM 可当前项目提供其运行所需的各个项目中的全部依赖关系,每个 DOM 中都包含有最合适运行的依赖版本组合。
Ruiz:就算是没用过,也一定消费过(BOM)。如果用 Spring Boot 或 Quarkus 导入东西,必然会带来更多的依赖……
无论你用的是哪类 BOM 最终的消费形式都是一样的:通过依赖管理块(Ruiz 称这是依赖管理块存在的意义)。消费 BOM 不仅需要 artifactID 和 groupID,还需要添加 POM 类型,否则 Maven 只会试图将其解析为 JAR 文件。POM 文件之中只有元数据。
下面这个问题是前一个的变体。其中包含一个父级 POM 一个子级 POM,生效的 POM 有两个过渡性依赖,父级中的依赖管理模块有一个依赖和一个导入 BOM 的依赖(如上图所示)。另一个问题中则又在其中加入了直接依赖(无图)。生效的 POM 文件将引用父级中定义的依赖块,而 Guava 所选择的版本也是在这个依赖块中定义的。因此,第一个问题中生效的会是 28.0-jre。但对第二个问题而言,演讲者们给自己不断强调的“直接依赖总会赢”的说法打上了补丁,“……除非 BOM 文件是导入的,否则其他都会被忽略”。也就是说,情况二中依赖管理块中使用的版本是 26.0-jre。
另一种情况中(如上图),父级和子级 POM 中都有定义依赖管理模块。子级的 POM 中包含导入的 BOM 和依赖。在这种情况下,子级定义的版本与所用的版本更接近,因此会直接覆盖父级中所定义的依赖版本。也就是说,子级 POM 的依赖块中定义的依赖会被使用,即 26.0-jre。如果父级中有一个依赖块,子级中的依赖块导入了一个 BOM、一个传递性依赖和直接依赖,那么直接依赖将会笑到最后,即 28.0-jre。
在演讲的最后,Ruiz 和 Almiray 给出了演讲中的关键点。她提到了依赖的采用在很大程度上取决于其在 POM 文件内定义的位置,并再次强调了使用 Maven 增强插件的重要性,以确保明确规则的定义和遵循。因此,在依赖定义文件中的不同位置新增一个条目并不会改变构建过程的输出。
Almiray:如果要从这次演讲中学到什么,那就是去使用 Maven 增强插件,并从今天开始,正确地开始项目构建……
Ruiz 在结束语中再次重申了 Maven 依赖的解决规律:直接依赖总会赢,传递性依赖会选择最先也是最近的。依赖管理解析会以目录的形式使用。对 BOM 文件而言,就算不知道也在用。Almiray 最后称,万不得已可以用排除法,只要用 Maven 构建,依赖管理块就是好工具。但如果用不同的消费者,你就需要将所有不同类型的依赖块转换为显示依赖。Maven flatten 可以做到这点。对 Gradle 而言则更是需要,因为 Gradle 除了直接依赖关系之外,不支持任何其他东西。
原文链接:
Ruiz and Almiray at Devoxx UK: Lessons on How to Escape the Maven Dependency Hell
评论