ARTICLE DETAIL

资讯详情

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

Maven Scope引发的运行时崩溃:从NoClassDefFoundError到依赖治理

Maven Scope引发的运行时崩溃:从NoClassDefFoundError到依赖治理 先讲一个我上个月真实遇到的现场项目在本地IDE里跑得好好的单元测试全绿mvn clean package也顺利通过结果java -jar一启动就抛java.lang.NoClassDefFoundError。我第一反应是依赖没打进去可pom里明明写着依赖坐标。折腾了半个多小时最后发现罪魁祸首就是一个不起眼的scopeprovided/scope。这个经历让我意识到很多人对Maven scope的理解停留在知道有这个东西但完全没意识到它能在运行时给你捅出多大的娄子。这篇文章就围绕maven scope引起的程序崩溃展开讲清楚scope在类加载链路里的真实作用并用几个我实际排查过的崩溃案例带你走一遍完整的定位思路和修复方案。无论你是刚接触Maven的新手还是被线上服务折腾过几回的开发老手看完这篇都能少踩几个坑。1. scope背后的类可见性规则先理解它为什么能搞崩程序很多人把scope理解成依赖的一个属性标签这个理解没错但它忽略了一个关键点scope决定了这个jar在编译、测试、运行、打包四个阶段是否出现在classpath上。这个概念一旦没建立起来遇到运行期崩溃就很容易抓瞎。1.1 Maven生命周期与classpath的对应关系Maven的生命周期大概分为validate、compile、test、package、verify、install。每个阶段都有对应的classpath。同样是classpath编译期的和运行期的完全可能是两套。举个例子编译主代码时classpath包含所有compile和provided范围的依赖编译测试代码时classpath会在此基础上追加test范围的依赖运行时如果直接跑java -jar你依赖的是最终打包产物的类路径这时候provided范围的依赖往往不在里面。这个编译期有、运行期无的时间差就是大量崩溃的根源。1.2 各scope参与阶段对照表我整理了一份表把每种scope在四个阶段的参与情况列清楚建议收藏。scope主代码编译测试编译运行时是否打入产物典型使用场景compile是是是是默认绝大多数业务依赖provided是是否容器提供否servlet-api、lombokruntime否是是是通常JDBC驱动等SPI实现test否是否否JUnit、Mockitosystem是是是否需显式指定路径极少用非本地仓库依赖import仅用于BOM导入---dependencyManagement注意看provided那一行的运行时列是否这个否的含义不是JVM不加载它而是你不负责把依赖一起分发出去由运行环境提供。问题就出在如果你项目的实际运行环境里没人提供这个类它就崩了。1.3 用生活类比理解scope可以把scope理解成这个工具是你自己背进工地的还是工地现场就有的。compile你自己背进去走哪带哪最保险provided你觉得工地肯定有空着手进去结果到了现场发现这个工地没配这个工具runtime你一开始用不到等干到一半才需要此时发现工具在包里但一时没翻出来还容易被其他工具挡住test只有质检环节才用正式施工根本不带。这个类比虽然粗糙但方向是对的。很多scope导致的崩溃核心就是你以为环境里有但实际没有或者你以为环境里没有结果有了个别的版本。1.4 为什么编译通过不等于运行正常这里要讲一下Java的类加载机制。你的源代码里写了import com.xxx.Foo;在编译期javac需要能在classpath里找到Foo类于是编译通过。但编译通过后生成的.class文件里的引用是符号引用等到JVM运行到对应代码时才由类加载器按类名去classpath里找真实的类文件。这个编译期验证和运行期加载的分离意味着只要编译期classpath里有类编译必过但运行期classpath里有没有编译完全不关心。这就是maven scope能通过了编译却在运行时崩掉你的根本原因。理解了这一点下面案例里的每个崩溃现场你都能看得很透彻。2. 崩溃现场一provided的依赖在fat jar里彻底消失了2.1 现象与第一反应项目是一个Spring Boot服务本地IDE里启动完全正常接口调得飞起。但mvn clean package后丢到测试服务器上java -jar app.jar刚启动就报Caused by: java.lang.NoClassDefFoundError: com/xxx/sdk/ClientFactoryNoClassDefFoundError这个类名基本可以断言编译期这个类是存在的但运行期加载不到了。因为如果编译期就没有根本不可能生成对它的引用。我的第一反应是检查target/app.jar的内容看看这个类到底在不在。2.2 排查链路一翻jar包用一个命令就能看到jar包里有没有你要的类unzip -l target/app.jar | grep ClientFactory如果输出为空说明这个类确实不在最终产物里。这时候有两个可能一是没有声明依赖二是声明了但被scope排除了。我继续翻了pom发现依赖是有显式声明的dependency groupIdcom.xxx/groupId artifactIdinternal-sdk/artifactId version2.1.0/version scopeprovided/scope /dependency问题就出在这个provided上。2.3 为什么当初会写成provided问了一下以前写这个pom的同事他的解释是当时看到网上说容器环境会提供servlet-api一类的依赖所以顺手把内部SDK也写成了provided。这个理由非常典型。provided的本意是运行环境已经存在这些类你不需要打进去它适用于很有限的场景比如传统Servlet应用部署到TomcatTomcat自身会提供javax.servlet的类。但Spring Boot的默认部署方式是用内嵌Tomcat打成fat jar之后用java -jar直接在裸JVM上跑。在这个运行模式下没人替你提供任何东西所有运行时需要的类都必须出现在fat jar里。2.4 修复方式把scope去掉或者显式写成compiledependency groupIdcom.xxx/groupId artifactIdinternal-sdk/artifactId version2.1.0/version /dependency重新打包后再看jar内容类已经出现了启动恢复正常。2.5 这个坑的经验总结provided的正确使用场景我个人总结就三类运行容器已经提供的类比如传统war包部署在Tomcat中的servlet-api编译期的注解处理器例如lombok它只需要在编译期生成代码运行期不需要某些仅在测试代码里使用的工具但这种情况一般用test更合适。除此之外一个普通的业务运行依赖如果被写成provided基本等于埋雷。判断标准就一句话你打出来的包在目标环境跑的时候这个类到底有没有人给它兜底不确定就用compile。3. 崩溃现场二runtime scope拖垮了SPI加载机制3.1 现象与场景另一个项目是接入某支付通道的对接服务数据库用的MySQLpom里mysql-connector-java被声明为runtime范围dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version scoperuntime/scope /dependency在IDE里跑的时候一切正常。部署到服务器之后服务启动阶段突然崩了堆栈里能看到java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver3.2 为什么runtime会出问题JDBC的加载过程是这样的调用DriverManager.getConnection时JDBC 4.0是通过ServiceLoader机制读取classpath下所有jar包中META-INF/services/java.sql.Driver文件然后加载里面声明的驱动类。但如果你的代码里显式写了Class.forName(com.mysql.cj.jdbc.Driver)或者某些连接池框架在初始化时会先尝试加载驱动类这时候JVM就需要在classpath里真的能找到com.mysql.cj.jdbc.Driver这个类。那runtime会不会导致它找不到呢理论上在Spring Boot的fat jar中runtime范围的依赖默认也是会被打包的类应该是存在的。问题出在我遇到的那个项目的实际构建方式上它没有用Spring Boot的repackage插件而是用了自定义的assembly插件这个插件配置的scope过滤条件不同导致runtime范围内的代码没有被打进最终产物。3.3 排查链路二从崩溃点到依赖树排查步骤是这样的第一步看报错位置。ClassNotFoundException和NoClassDefFoundError不同它通常是代码里显式Class.forName或ClassLoader.loadClass时才抛出的。于是我先在IDE里搜索了Class.forName发现项目里的动态数据源配置类里确实有这行代码。第二步看依赖树mvn dependency:tree -Dincludesmysql:mysql-connector-java输出了以下内容[INFO] - mysql:mysql-connector-java:jar:8.0.33:runtime依赖本身是存在的scope是runtime。第三步判断依赖是否进入产物。这次我用的命令是jar tf target/xxx.jar | grep com/mysql/cj/jdbc/Driver输出为空。说明依赖确实没被assembly插件打包进去。3.4 根因与修复根因就是当前构建方式使用了自定义assembly插件它会按照配置的scope过滤规则来决定哪些依赖进入最终包。在这个项目的配置里只打入了compile范围的依赖runtime和provided都被排除。IDE不会理睬这套打包规则它直接按Maven的依赖模型构建classpath所以本地不崩。修复方案有两种方案一改pom把scope从runtime改成compile。虽然对一个JDBC驱动来说直接用compile有点浪费编译期类引用但它能确保打包不被过滤掉。方案二改assembly插件的配置放开runtime范围的过滤。这个改动相对大一些要考虑会不会引入其他运行时依赖的变更。我最终采用的是方案一因为改动最小也最稳妥。3.5 经验总结runtime应该什么时候用runtime的设计意图是编译期不需要运行期需要。典型场景是你在代码里面向接口编程接口是java.sql.Driver具体的驱动实现在运行期才从classpath加载。这种设计能避免编译期对具体实现类的强依赖。实际上除非你用DriverManager/ServiceLoader加载实现类并且代码完全没有直接引用否则一个依赖写不写runtime都无所谓反而容易和各种打包插件产生冲突。我的建议是不要为了优雅而把一个普通运行依赖写成runtime除非你明确知道打包插件会如何处理它。4. 崩溃现场三scope传递依赖引发的幽灵版本4.1 现象与奇怪现场第三种崩溃更隐蔽发生在多模块项目的公共模块里。应用启动时报java.lang.NoSuchMethodError: com.google.common.base.Stopwatch.start()Lcom/google/common/base/Stopwatch;NoSuchMethodError和ClassNotFoundException完全是两码事。类的确存在但类里的方法签名对不上这几乎都是版本冲突导致的编译期使用的Guava版本有Stopwatch.start()运行期加载的Guava版本没有start()方法或者方法签名不同。4.2 排查链路三依赖树与scope的交叉作用先看一下项目依赖关系A模块底层公共库引用了Guava 32.1.0B模块上层服务引用了A模块同时B又在自己的pom里写了一个Guava 19.0还标了provideddependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version19.0/version scopeprovided/scope /dependencyMaven的依赖仲裁规则是最近优先。B模块自己的声明路径比A模块传递过来的声明路径更短于是最终参与编译的Guava版本被仲裁成了19.0。这里有个关键点B模块把Guava标成provided意味着它认为Guava由运行环境提供不需要打包。但编译时B的代码和A的代码用的都是19.0的Guava啊。A模块的代码是在Guava 32.1.0上编译出来的运行期却被塞进来19.0很多API就在编译期对不上运行期了。结果就是任务执行到某个A模块内部使用Stopwatch.start()的地方JVM一加载这个方法发现实际类里没有直接抛NoSuchMethodError。4.3 用依赖树验证mvn dependency:tree -Dverbose注意这里的-Dverbose它能显示出依赖仲裁过程中的冲突细节不然你只会看到最终结果看不到为什么被仲裁成这个版本。最终输出的关键部分长这样[INFO] - com.xxx:module-a:jar:1.0.0:compile [INFO] \- com.google.guava:guava:jar:32.1.0-jre:compile [INFO] - com.google.guava:guava:jar:19.0:provided看到这里就明白了因为B模块自己的guava:19.0是直接依赖路径更深不对是更浅。Maven选中的就是它。4.4 修复方法移除B模块里那个多余的provided声明让版本仲裁回到A模块提供的32.1.0或者更稳妥地在父pom的dependencyManagement里统一声明Guava的版本dependencyManagement dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.0-jre/version /dependency /dependencyManagement用dependencyManagement的好处是无论哪个子模块声明Guava最终都会统一到指定版本避免最近优先规则在不同模块里产生不同的仲裁结果。修复完之后看依赖树mvn dependency:tree -Dincludescom.google.guava:guava确认Guava版本变成32.1.0且scope是compile或默认的jar再重启服务问题消失。4.5 经验总结由scope引起的版本冲突这类问题最难的一点是NoSuchMethodError的堆栈信息往往指向方法调用处而不是真正的依赖声明处。你如果只去查方法的调用方代码很可能一头雾水因为代码本身没问题。必须直接上依赖树从运行期的真实classpath里有什么这个角度去排查。另外在多人协作的项目里我强烈建议每个子模块的依赖声明宁多勿少但版本要交给dependencyManagement统一管理。不要在一个子模块里为了省事临时加一个provided依赖它很可能会成为另一个模块的幽灵版本来源。5. 快速定位scope类崩溃的排查清单与工具遇到scope相关的运行期崩溃很多人会走弯路。我整理了一份自己的排查清单按顺序走绝大多数问题能在十几分钟内定位。5.1 四步定位法第一步区分异常类型。ClassNotFoundException代码里显式用Class.forName或ClassLoader.loadClass去加载类但classpath里没有。大概率是scope把依赖排除在运行期外了。NoClassDefFoundError编译期引用的类在运行期解析时加载不到。要么是类缺失要么是类依赖的另一个类缺失。scope问题是高发原因。NoSuchMethodError/NoSuchFieldError类和字段/方法不匹配基本是版本冲突。scope可能参与其中但核心是依赖仲裁。ExceptionInInitializerError类的静态初始化失败有时是上面三类错误导致的次生异常。第二步查看依赖树。mvn dependency:tree -Dverbose配合-Dincludes精确定位某个依赖mvn dependency:tree -Dverbose -DincludesgroupId:artifactId把scope和版本都记下来。第三步检查最终产物。jar tf target/app.jar | grep com/xxx/SomeClass或者解压后在lib目录下查找unzip -l target/app.jar | grep some-lib这一步能验证依赖有没有被打进最终产物是排查provided/runtime被过滤问题最关键的一步。第四步对比三种运行方式。我自己会用三种方式分别启动同一个应用IDE里直接run、mvn spring-boot:run、mvn package后java -jar。如果前两种正常、第三种崩几乎可以断定是打包或scope问题如果IDE里也不正常那更多是classpath本身的问题。5.2 几个实用工具mvn dependency:analyze会分析当前模块的依赖使用情况指出用到了但未显式声明和声明了但未使用的依赖。前者能帮你发现依赖缺失的隐患后者能帮你清理多余的声明。注意它也有误报的时候尤其是反射和SPI场景但作为参考很有价值。IDE里的Maven Helper插件能可视化查看每个依赖的冲突路径右键可以直接排除依赖对多模块项目排查版本冲突极其高效。jdeps命令JDK自带可以分析某个jar的字节码依赖项看它到底引用了哪些类再和运行期classpath做对比。这个工具在排查为什么某些类没有被加载的场景非常有用。5.3 scope陷阱速查表现象可能的scope嫌疑排查重点本地不崩打包后崩依赖声明为provided且运行环境无该类检查产物内是否含对应类服务启动时ClassNotFound依赖声明为runtime但打包插件未纳入检查assembly/shade插件scope过滤规则NoSuchMethodErrorprovided依赖覆盖了正常传递依赖的版本用mvn dependency:tree -Dverbose查仲裁结果单元测试正常主程序崩溃依赖被误写成test scope但主代码在用mvn dependency:analyze检查功能时好时坏偶发崩溃多个依赖传递了不同版本且scope不一致用dependencyManagement统一版本6. 让scope问题不再发生我常用的几条依赖管理铁律排查得多了我慢慢形成了一套自己的依赖管理习惯不一定适合所有团队但至少在这些年的项目里帮我挡住了很多坑。6.1 新项目创建pom时先跑一遍完整构建我每接手一个新项目或新建一个模块一定会做这个动作mvn clean package然后把打出来的可执行产物放在一个干净的裸JVM环境里跑一遍。这个干净环境不是本地开发环境而是一个只装了JDK的容器或多出来的虚拟机。如果这个环境里能正常起来说明scope和依赖打包配置基本没问题。6.2 在父pom统一管理版本所有显式依赖的版本都放到父pom的dependencyManagement里子模块pom只写groupId和artifactId不写版本。这样任何模块传递来的依赖版本冲突都会被统一到父pom的规格上。scope这个东西也一样最好统一在dependencyManagement里规定子模块不要擅自覆盖。6.3 写依赖时的自问三句每次加一个依赖先问自己这个依赖是在编译期、测试期还是运行期用它会不会被运行的容器或中间件提供如果它不在最终产物里运行环境能正常启动吗这三句话往往能直接在写pom的阶段拦截掉90%的scope误配。很多人写provided是抄来的根本没想过第三句。6.4 定期跑依赖分析CI上每周或每个迭代跑一次mvn dependency:analyze把输出里的used undeclared和declared unused两类告警拉出来人工过一遍。前者是隐患后者是债。虽然这工具偶尔误报但每次都能扫出一些值得关注的依赖问题。6.5 最后的一个小技巧如果线上服务已经崩了手头又只有崩溃日志最快速的办法是把崩溃信息里的类名拿出来在本地用Maven下载的本地仓库里搜一下是否存在多个版本find ~/.m2/repository -name *.jar | xargs -I {} sh -c jar tf {} 2/dev/null | grep -l com/xxx/SomeClass echo {}这个命令会列出所有包含目标类的jar包和路径。一旦看到多个不同版本的jar都包含同一个类说明问题几乎必然是scope或依赖仲裁导致的版本错乱然后顺着依赖树去查就行。maven scope这个单词本身很简单但它引起的程序崩溃从来不简单。把这些排查思路和规范沉淀下来至少下次再遇到NoClassDefFoundError的时候你能直接翻出这篇文章按着顺序来问题大概率跑不掉。
返回列表