ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Maven父项目与依赖:继承与依赖传递的本质区别

Maven父项目与依赖:继承与依赖传递的本质区别 1. 先搞清楚这两个角色到底在解决什么问题很多人在Maven里待了两三年天天写parent和dependency但你要是突然问他一句父项目和依赖的本质区别是什么他大概率会愣一下然后给你一个模模糊糊的答案父项目就是用来继承的依赖就是引用的jar包呗。这个回答不能算错但它没有触及本质也解释不了很多实际工程里的坑。我见过不少团队把父项目当成依赖集中管理仓库来用所有公共jar包全塞到父POM的dependencies里结果下游每个模块都背着几十个根本用不到的jar包启动慢、冲突多、改动一个版本全局遭殃。也见过反过来的把本该继承的插件管理配置写进了依赖里搞得每个子模块都要重复配置一遍插件参数。要理解这两个角色的本质区别先要回到Maven设计的出发点。Maven项目构建的本质是**描述是什么和需要什么**这两个问题。parent回答的是我这个项目是什么、从哪里来、继承了什么基因dependency回答的是我这个项目运行的时候需要借助哪些外部能力。打个比方父项目像是家族血缘你生下来就继承了家族的一些特质这个继承关系在你出生那一刻就确定了是唯一的、单向的、结构性的。而依赖像是你工作后认识的合作伙伴你随时可以换掉某个合作伙伴也可以同时跟多个合作伙伴协作关系是灵活的、可替换的、功能性的。这个类比能帮你建立第一层认知但还远远不够。真正的问题在于为什么Maven要把这两件事分开分开之后各自的边界在哪里一旦边界模糊会出现什么后果这些才是本文要解答的核心。后面我会结合POM文件的实际结构、依赖传递的机制、多模块工程的常见陷阱一层一层把这层窗户纸捅破。2. 同一份POM两种身份解析它们在POM结构上的位置差异先做一件最简单也最容易被忽略的事打开一个Maven项目的pom.xml把标签一个个看过去。你会发现parent永远出现在POM文件的最前面在groupId、artifactId、version之前且只能有一个而dependencies则出现在项目描述之后、构建配置之前可以有多个。这个位置上的差异不是随意的XML顺序规定它反映了Maven模型里两个完全不同的作用域。2.1 parent在POM里的基因注入机制parent的声明长这样parent groupIdcom.example/groupId artifactIdcompany-parent/artifactId version2.3.0/version relativePath../pom.xml/relativePath /parent一旦声明了这个parent当前项目就自动获得了在parent POM中定义的所有非私有配置包括properties中定义的全部属性变量dependencyManagement中锁定的所有依赖版本号pluginManagement中配置的所有插件默认参数build中的资源目录、输出目录等构建配置repositories、pluginRepositories等仓库配置distributionManagement等发布相关配置这就是为什么只在parent里写一个properties变量子模块里直接就能用${project.version}或者${my.custom.property}引用。这不是Maven的某种魔法而是配置继承子POM会先把parent的模型读进来再用自己的配置覆盖或合并。关键在于继承是单向的、静态的、多层的。子项目不能反过来影响父项目的配置一个项目也只能有一个直接的父项目。而且父项目不一定要被打包成jar它往往就是一个packagingpom/packaging的纯配置项目本身不产出任何制品。我自己在实际工程里维护过一个公司级别的parent POM里面只干三件事统一Java版本、统一依赖版本管理、统一插件配置。这三个统一就是父项目这个角色最纯粹的价值。你要是在parent里顺手写了几个dependency那就是把基因和合作伙伴搞混了隐患马上就会在子模块里爆发。2.2 dependency在POM里的能力装载机制再看依赖声明dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId version2.7.18/version scopecompile/scope /dependency /dependencies每个dependency都是独立、平等、可选的当然也会有传递关系后面专门讲。项目对每个依赖只关心一件事我需要你的能力并且限定你提供能力的方式scope决定这个能力在哪个阶段生效。依赖是功能性的组装跟当前项目本身的身份定位没有关系。这里有一个特别典型的误解很多人认为我把A项目加成了B项目的依赖那么B就继承了A里的所有东西。错大错特错。B从A这个依赖里能获得的只有A所产出的jar包里的类、资源文件以及A的POM中通过dependencies所声明的传递依赖。A的properties、build配置、pluginManagementB一概拿不到。换句话说父项目传的是构建基因依赖传的是运行时能力。一个影响你怎么构建、一个影响你构建出来的东西能跑什么功能。这个区别是理解全文的钥匙。3. 两个关键词继承与依赖传递别再混为一谈上一节说了父项目传配置、依赖传能力但实际使用中还有一个最容易踩坑的地方dependencyManagement和依赖的传递性这两件事经常把人绕晕。很多人分不清我继承了父项目里的版本号和我把父项目依赖的jar包也带过来了到底有什么不同。3.1 dependencyManagement父项目管版本不管引入父项目里最常见的是这个结构dependencyManagement dependencies dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.0.0-jre/version /dependency /dependencies /dependencyManagement注意dependencyManagement不是dependencies。它做的事情是建立一张可用的版本清单但并不会给子模块实际引入任何一个jar包。子项目只有在自己的dependencies里声明了com.google.guava:guava可以不写version才会真正引入这个jar并且Maven会去parent的管理表中找到对应版本号自动填上。所以父项目里管了guava的版本为什么我的子项目里用不到guava——因为父项目根本没给你引入它只是给你准备好了版本号。这是新手最容易被绕晕的地方。为什么Maven要这样设计为了延迟决策。父项目没法预知每个子模块需要哪些依赖因为各业务模块的职责不同但它可以统一约束所有依赖的版本避免每个子模块各自为政。子模块按需引入引入的时候自动获得统一版本。这一套组合拳下来既保留了灵活性又保证了全局一致性。3.2 依赖传递依赖带依赖的连锁反应依赖传递是另一套机制。假设项目A依赖了C库项目B又依赖了A。在默认情况下B会自动传递获得C不需要在B里显式声明C。这就是Maven的依赖传递性。听起来很方便对吧但这恰恰是大量冲突的根源。你B想用的C版本和A想用的C版本不一样Maven必须做出仲裁。默认仲裁规则是最短路径优先谁离当前项目路径近谁胜出如果路径一样长先声明者优先。这里就能看出父项目与依赖的本质区别的实践意义了父项目A里声明了C的版本号这只会影响子项目的版本决策不会把C传给孙项目——因为版本号不是jar包没有传递性。如果A在自己dependencies里直接声明了C那么所有依赖A的项目都会被动接收C的这个版本——这就是父项目带依赖造成的连锁反应。我遇到过这样的情况公司公共父项目里为了项目经理要求统一用某SDK直接在dependencies里加了最新版。结果下游十几个服务全都跟这个SDK的老版本冲突排查了两天才发现是公共父项目多此一举的依赖声明引起的。最后把那个依赖从dependencies挪到dependencyManagement全局瞬间清净。4. 两个工程场景看它们在真实项目里如何分道扬镳理论讲得再多不如看两个真实项目结构。我以最常见的多模块Spring Boot工程为例展示父项目和依赖各自承担的职责以及它们处在POM的什么位置。4.1 多模块仓库中的父与子一个典型的多模块工程长这样company-project/ ├── pom.xml (聚合根 父POM) ├── company-common/ (公共工具模块) ├── company-service-a/ (服务A) └── company-service-b/ (服务B)根pom.xml中packagingpom/packaging modules modulecompany-common/module modulecompany-service-a/module modulecompany-service-b/module /modules dependencyManagement !-- 统一管理所有第三方依赖版本 -- /dependencyManagement pluginManagement !-- 统一管理maven插件配置 -- /pluginManagement这里根POM兼任了聚合aggregation和父项目parent两种角色。聚合通过modules实现它的作用是我从哪里启动多模块构建父通过parent实现它的作用是子模块从哪里继承配置。两者常常同时出现在同一个根POM里但本质是两回事你可以聚合某个模块但并不继承它的配置你也可以继承某个POM但并不把它列为聚合模块。在真实项目里这两个角色经常被混在一起但理解时一定要拆开。子模块service-a里则会是parent groupIdcom.example/groupId artifactIdcompany-project/artifactId version1.0.0/version /parent dependencies dependency groupIdcom.example/groupId artifactIdcompany-common/artifactId version${project.version}/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies在这个结构里company-project作为父项目把Java版本、依赖版本、编译插件配置传给了service-a和service-b它们是兄弟关系都从同一套基因出发。company-common作为依赖被service-a和service-b各自引用提供给它们公共的工具类、常量、通用返回值结构等运行能力。service-a和service-b之间不互相依赖都依赖common这是组装关系。这里体现的差异就很清楚了service-a和service-b通过parent的关系长得像同一套构建配置通过dependency的关系用得着需要某个具体的能力模块。4.2 Spring Boot中parent的副作用为什么有人不喜欢继承Spring Boot官方推荐的做法是继承spring-boot-starter-parentparent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version /parent继承这个parent能让你不用写一堆dependencyManagement因为Spring Boot已经帮你把常用依赖的版本全部管理好了。但这里面也有槽点你继承了别人的一切包括它的插件版本、默认配置、仓库设置。如果公司内部有自己的parent规范你还要再套一层自己的parent形成两级甚至三级继承链。一旦Spring Boot升级跟你的插件配置冲突那个排查过程简直让人抓狂。所以现在越来越多的项目开始采用另一种姿势不继承spring-boot-starter-parent改用它作为BOM依赖导入dependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version3.2.4/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement注意这个写法的本质spring-boot-dependencies是一个pom类型的依赖通过scopeimport/scope被导入到当前项目的依赖管理表中。它不是你的父项目你不需要继承它的其他配置你只需要它那份版本清单。这恰恰说明了父项目和依赖不是对立关系而是可以互相配合的你用自己的parent管理自己的构建基因再用import依赖引入外部的版本管理能力。这个场景对理解核心区别极有帮助同样是管版本继承parent和import依赖是两条路但结果截然不同——继承会带来全套配置import只引入版本表。用了import方案之后你的POM更干净不会被子类的插件配置影响升级Spring Boot版本只需要改一个数字。5. 依赖范围与生命周期scope如何暴露依赖的临时性说到依赖就必须聊scope。这是父项目这个概念里根本不存在的东西——因为继承关系没有阶段之分你永远是父项目的子项目从构建到发布都是。但依赖的scope则可以精确控制这个依赖在哪个生命周期阶段存在scope编译期测试期运行期说明compile有有有默认值传给别人provided有有无容器/JDK提供不打包runtime无有有编译不需要跑时需要test无有无仅测试代码用import不参与传递--仅用于dependencyManagement导入BOM这个表一出你就能直观看出依赖这个角色是多面性的同一个库你可以只在测试的时候用它也可以让它跟着打包走还可以假设运行环境里已经有它。这是纯粹的能力用时加载。举个实际例子有个订单服务要用Lombok你在pom.xml里写了scopeprovided/scope因为它只在编译期帮你生成getter/setter运行时根本不需要这个jar。又比如某个内部SDK在测试时需要mock数据你在pom.xml里用testscope它就会被挡在最终的jar/war之外。反过来看父项目它从头到尾都是父没有scope可配。你不能说这个父项目只在测试阶段生效——因为配置继承是全生命周期的properties、pluginManagement里的内容会在你执行mvn test、mvn package、mvn deploy时无差别地生效。这一点我要特别提醒如果你发现某个项目里的依赖可以不加scope、加了scope编译就报错然后又想到父项目里能不能配scope——别配scope只适合用在不共享构建配置的依赖场景里。父项目里如果出现dependency带scope说明你从一开始就没想清楚这个依赖该放在哪一层。6. 实操验证从一段依赖冲突排查看两角色差异纸上得来终觉浅。我拿一个上周刚处理的真实案例来收尾看完这个案例你就能把上面所有理论串起来。6.1 问题现象本地能跑CI上报错事情是这样的同事在负责的会员服务里引入了一个新SDKmember-sdk本地构建完全正常但一上CI就报了一个诡异的错误NoSuchMethodError说某个类里的方法找不到。这个类来自guava库而本地直接用IDEA跑的时候一切正常。第一反应当然是怀疑guava版本冲突。于是执行mvn dependency:tree -Dverbose输出里关于guava的部分有两条路径[INFO] - com.example:member-sdk:jar:1.2.0:compile [INFO] | \- com.google.guava:guava:jar:31.1-jre:compile [INFO] \- com.google.guava:guava:jar:33.0.0-jre:compile本地呢因为IDEA里有时候会用本地的依赖解析缓存也可能跟多人开发环境不一致导致本地实际解析路径和CI不一样。真正的坑在于member-sdk是公司内部项目它的POM里已经声明了依赖guava 31.1-jre而会员服务自己的dependencyManagement注意是它父项目里配的把guava锁到了33.0.0-jre于是在服务里解析时guava实际被选中了33.0.0-jre。member-sdk在31.1里用到了某个方法33.0.0把它改掉了NoSuchMethodError就这么冒出来了。6.2 排查过程一步步定位到父与依赖的交界处我当时做了这几步确认两份guava来自哪里member-sdk的POM位于公司私服另一个guava 33.0.0来自服务的父POM的dependencyManagement。这一步用了mvn dependency:tree -Dverbose定位发现服务里没显式写guava依赖它能出现在依赖树里完全是因为member-sdk传递过来的。对比两个版本的API差异查了31.1-jre和33.0.0-jre之间那个被调用的方法是否存在。结果是33.0.0里确实删掉了那方法。从父项目管版本这个理论上反推服务继承的parent里用dependencyManagement锁了guava版本所以member-sdk传递传递上来的31.1被强制覆盖成了33.0.0。而这个parent是十几个人共用的公共父POMguava版本的变更初衷是为了适配另一个新模块的需求——父项目一个版本的改动波及到了所有子模块的传递依赖解析。验证方案先在本地执行mvn dependency:tree复现问题确认是parent里修改guava版本引起的然后协调把父POM里guava版本从33.0.0调整为32.1.3这个版本兼容member-sdk的调用问题解决。这个案例的核心就是父项目管的是版本决策依赖决定的是实际引入。member-sdk认为它会带着guava 31.1来工作结果父项目一声令下把版本改成了33.0.0。父项目本身没有错它只是履行了自己管版本的本职工作依赖本身也没有错它只是按照自己的POM传递了自己的能力需求。错的是两者在版本决策上产生了交叉而大家当时都没意识到父项目的一个版本锁定值可以凌驾于依赖传递的版本之上。6.3 排查清单以后遇到类似问题按这个顺序来这个案例可以直接沉淀成一套排查路径以后遇到本地正常、CI报错引入新SDK之后类方法找不到莫名多出一个你从没声明过的依赖这类问题按下面这个顺序查先跑mvn dependency:tree -Dverbose把依赖树完整拉出来看到底有哪些重复依赖以及各自的来源路径。区分哪些依赖是直接声明的哪些是传递依赖哪些是被父POM的dependencyManagement强行改版本号的。检查项目继承的parent里有没有锁定冲突版本的dependencyManagement如果有先注释掉试试。检查引入的SDK自身POM是否合格它把不该以compile scope传递的依赖暴露给了你也会造成同样的连锁反应这种情况可以用optionaltrue/optional或调整scope规避。用mvn help:effective-pom查看当前项目的最终有效POM——它会把父项目继承来的配置和当前项目自己的配置合并后的完整结果展示出来这是判断哪些配置从父项目来的最直接工具。7. 实践中的选型权衡什么时候用parent什么时候引入依赖看完上面的分析你肯定会问那我在设计一个多模块工程时哪些东西该往父项目里放哪些东西该作为依赖被人引用我的经验是几个原则直接拿去用放进父项目的统一的properties比如Java版本、编码格式、统一的时间格式dependencyManagement管理所有第三方依赖版本尤其那些被多个子模块共用的依赖pluginManagement统一插件参数比如maven-compiler-plugin的source/target、maven-checkstyle-plugin的规则文件位置统一的repositories、pluginRepositories、私服地址不放进父项目的任何具体的业务功能依赖万一某个子模块根本不需要也会被迫引入scope依赖父项目里不应该出现带scope的依赖除非你有意识要控制所有子模块的某依赖运行范围除了pom packaging以外的任何打包产物作为依赖引入的子模块实际需要的公共模块比如common模块、api模块第三方功能库按需选择scope内部二方库通过只依赖其API包来减少耦合父项目是所有子孙项目的共同底座它应该极简、稳定、改动频率低。依赖则是模块间协作的载体它应该精确、可替换、尽量限制暴露面。这个边界一旦清晰多模块工程的维护成本能降一半以上。8. 几个容易让人反复纠结的边界案例第一类是BOM与父项目的纠缠。很多现代框架Spring Boot、Spring Cloud、JUnit 5都提供BOM你可以选择继承它们的parent也可以用import导入BOM到自己的dependencyManagement。这就产生了一个实际选择要不要用它们的parent我的建议是如果你只是需要版本管理那就用import BOM方案保留自己parent的独立性如果你希望连它们的插件默认配置也一起继承比如Spring Boot的打包插件、Maven-specific配置那继承parent更省事。但这个选择要清楚地知道继承parent意味着你接受了它的全套配置可能与你公司自己的parent规范产生冲突到时候mvn help:effective-pom一出你会发现很多配置其实来自你意想不到的地方。第二类是能否把一个父项目同时当作依赖引用。技术上当然可以只要把父项目打包发布到仓库然后别的项目用dependency引用它即可。但现实里这样做几乎都有问题作为依赖被引用的POM项目它的配置不会传递依赖不传配置它自身也不产出可运行的jarpom打包别人引入它能得到什么大概率只有一堆无效的依赖管理表而已还会引入大量没必要的传递依赖。所以遇到这种需求先停下来反思一下设计是不是出了偏差。第三类是多级继承的团队规范冲突。三层甚至四层的父子关系在大型公司体系里很常见公司级parent - 部门级parent - 业务线parent - 具体项目。每一层都在做依赖版本管理越往下优先级越高这没问题。但问题是每层都觉得自己应该加一些东西层级一深最终项目的effective-pom会膨胀到难以维护而且排查一个依赖版本为什么是某个值要翻三四层POM。我的建议是版本管理尽量收敛在两层以内公司级管全局框架版本部门级管业务中间件版本下面的层级就不要再大面积覆盖了尤其不要在同一套体系里频繁传递dependencyManagement去强行改版本每覆盖一次出问题的概率就指数级增加。9. 总结成一句话外加一条实用检查思路如果要把全文压缩成一句话那就是父项目是构建基因的继承源依赖是运行时能力的组装件前者决定你长什么样后者决定你能干什么。这句话值得贴在每个团队的项目规范文档里。最后再分享一个我在实际工作里常用的检查思路。每当我拿到一个新的多模块项目的pom.xml都会先看三件事在根POM里是先看parent还是先看dependencyManagement。如果parent里嵌套了多层就要特别谨慎地对待版本管理因为配置来源复杂。在子模块里写到dependency时先想一下它是我需要的能力还是我应该继承的配置。如果它跟构建配置相关比如某些插件类依赖那大概率应该通过插件配置或parent管理而不是放在dependencies里。命令行里执行一次mvn help:effective-pom只看一眼被合并进来的东西是否符合预期。凡是effective-pom中出现但你在当前POM里找不到的来源基本都是从父项目或导入的BOM来的心里先有个底。这三点做完我对这个项目的架构质量就基本有数了。你遇到的大部分Maven依赖难题其实都不是依赖本身有多复杂而是父亲的角色和合作伙伴的角色被混为一谈了。把这条边界立住后面的问题都会变得非常简单。
返回列表