源码是新的,运行时却拿了旧 jar
一个知识库列表接口挂了。前端照常请求:
http
GET /api/v1/knowledge?page=0&size=10
后端一把甩回来:
text
java.lang.NoSuchMethodError:
'com.mindflow.application.knowledge.KnowledgePage
com.mindflow.application.knowledge.KnowledgeCrudUseCase.list(int, int, java.lang.String)'
NoSuchMethodError 这个名字有误导性。它不是编译器报的"找不到方法"------源码完全能编译通过。它是 JVM 在运行时发现某个 class 文件的二进制签名和调用方期望的不一致,抛出的一个 Error 子类。Error 意味着这不是业务异常,是 JVM 层面的问题:当前 classpath 上加载到的类,和编译时用的类,不是同一个版本。
刚才改了什么
我们给知识库列表加了一个关键词搜索参数。原来分页接口是两参数:
java
KnowledgePage list(int page, int size);
加完变成三个:
java
KnowledgePage list(int page, int size, String query);
Controller 也同步改成了三参数调用。逻辑很简单,本地测试也过了:
powershell
mvn --settings .mvn/settings.xml -pl mindflow-api -am test
这条命令的意思是:在 Maven 多模块项目里,-pl mindflow-api 指定只在 mindflow-api 这个模块跑测试,-am(also-make)让 Maven 先把它依赖的所有兄弟模块也编译一遍。所以测试时,mindflow-application 等依赖模块都是刚从源码编译出来的最新 class。
但启动的时候,走的不是这条路。
从最浅的地方开始排查
源码对不对?
先确认源码里接口签名和调用方确实都是三参数。打开:
mindflow-application/.../KnowledgeCrudUseCase.java
mindflow-application/.../KnowledgeApplicationService.java
mindflow-api/.../KnowledgeController.java
三个文件都一致。说明问题不在编辑器里。
编译产物对不对?
源码是对的,但也许 IDE 没保存,也许增量编译出了问题。用 javap 直接看 target/classes 目录下的 class 文件------
javap 是 JDK 自带的 class 文件反编译工具,它能直接读出 class 里的方法签名,不需要源码。用法很简单:
powershell
javap -classpath <class文件所在目录> <全限定类名>
跑一下:
powershell
javap -classpath mindflow\backend\mindflow-application\target\classes `
com.mindflow.application.knowledge.KnowledgeCrudUseCase
输出里确实有:
text
public abstract KnowledgePage list(int, int, java.lang.String);
编译产物也是新的。两个最容易想到的方向都排除了。
端口上跑的是谁?
代码和编译都没问题,那就看看运行中的进程。也许上午调试留下的 Java 进程还在,占着端口,请求根本没打到新启动的服务上。
powershell
Get-Process java
netstat -ano | Select-String '8080|18080'
第一行列出所有 Java 进程,第二行查 8080 和 18080 端口被哪些进程占用。
果然有旧进程。停掉:
powershell
Stop-Process -Id <pid> -Force
这一步解决的是一个调试时很容易忽略的噪音源:你改了代码、重新启动了、觉得自己在看新版本,但浏览器连的还是残留进程。本地开发的"幻觉"大半来自这里。
进程清掉了,重启。错误依旧。
那就不是残留进程的问题。得往下挖。
真正致命的裂缝:两套 classpath
这个项目的后端结构是这样的:
text
mindflow-domain
mindflow-application
mindflow-infrastructure
mindflow-ai
mindflow-api ← 启动入口在这里
mindflow-api 是 Spring Boot 入口,依赖上面四个模块。我们的启动命令长这样:
powershell
cd mindflow\backend
mvn --settings .mvn/settings.xml -f mindflow-api/pom.xml spring-boot:run
-f mindflow-api/pom.xml 的意思是只把这个子模块当"单模块项目"来启动,不去碰父 pom。当时这么设计是为了避开父 pom 找不到 main class 的配置问题。
但 spring-boot:run 作为一个 Maven goal,它解析依赖的方式和 mvn test 不一样------它不会自动编译兄弟模块。它只看 mindflow-api 的 pom 里声明依赖的 GAV(groupId、artifactId、version),然后去本地 Maven 仓库找对应的 jar。
而这个项目的 Maven settings 里配置了一个项目级的本地仓库:
xml
<localRepository>${user.dir}/.m2repo</localRepository>
${user.dir} 是执行 Maven 命令时的当前目录,也就是 backend/。于是所有通过 Maven 坐标解析出来的 jar 都来自:
text
mindflow/backend/.m2repo/
mindflow-api 启动时,会去:
text
mindflow/backend/.m2repo/com/mindflow/mindflow-application/0.1.0-SNAPSHOT/mindflow-application-0.1.0-SNAPSHOT.jar
取 mindflow-application。问题来了------这个 jar 是哪来的?是上一次 mvn install 的时候写进去的。如果你只跑了 mvn test 或 mvn compile,编译产物只到了各模块的 target/classes,.m2repo 里的 SNAPSHOT jar 没有任何更新。
用 javap 确认:
powershell
javap -classpath mindflow\backend\.m2repo\com\mindflow\mindflow-application\0.1.0-SNAPSHOT\mindflow-application-0.1.0-SNAPSHOT.jar `
com.mindflow.application.knowledge.KnowledgeCrudUseCase
输出:
text
public abstract KnowledgePage list(int, int);
只有两参数。而 target/classes 和源码里是三参数。
拼图完整了
现在两条加载路径都清楚了:
| 场景 | mindflow-application 从哪来 |
版本 |
|---|---|---|
跑测试 mvn -pl mindflow-api -am test |
reactor 编译产出的 target/classes |
最新 |
启动服务 mvn -f mindflow-api/pom.xml spring-boot:run |
.m2repo 里的 SNAPSHOT jar |
上一次 install 的 |
启动时,KnowledgeController(在 mindflow-api 自身,随启动模块一起编译)是三参数版本;KnowledgeCrudUseCase(在 mindflow-application,从 .m2repo 加载)却是两参数旧版本。
JVM 的类加载没有"重新检查"这一步。Controller 调用 list(page, size, query) 时,它字节码里写死了方法签名。到实际调用点,JVM 去 KnowledgeCrudUseCase 的 class 里找这个签名,找不到,抛出 NoSuchMethodError。
本质上是构建路径和启动路径共用了一个本地仓库,但两个路径对"最新版本"的定义不一样。
修掉它
先清掉还在跑旧版本的进程:
powershell
Stop-Process -Id <pid> -Force
然后从 backend 根目录跑一次 install,把当前源码编译打包,写入 .m2repo:
powershell
cd mindflow\backend
mvn --settings .mvn/settings.xml install -DskipTests '-Dspring-boot.repackage.skip=true'
install 是 Maven 生命周期里比 compile 和 test 更靠后的一个阶段:它会先编译、测试(这里跳过了),然后把 jar 复制到本地仓库。-Dspring-boot.repackage.skip=true 跳过 Spring Boot 的 repackage(打 fat jar),因为这里只需要普通 jar 让依赖解析通过,不需要可执行包。
用 javap 再次验证 .m2repo 里的 jar:
text
public abstract KnowledgePage list(int, int, java.lang.String);
三参数了。启动:
powershell
mvn --settings .mvn/settings.xml -f mindflow-api/pom.xml spring-boot:run
验证:
powershell
Invoke-WebRequest -Uri 'http://localhost:8080/api/v1/knowledge?page=0&size=1&q=Smoke' -UseBasicParsing
返回 200。问题消了。
一条快速排查线
以后遇到 NoSuchMethodError,按这个顺序走几乎不会浪费时间:
- 看源码签名是否一致------排除手误。
javap看target/classes------排除编译问题。- 查端口上的进程------排除"请求打到了旧服务"。
- 如果是 Maven 多模块、项目级本地仓库,
javap看.m2repo里 SNAPSHOT jar 的签名。 - 确认 jar 是旧的,
mvn install更新,重启。
核心认知是:NoSuchMethodError 从来不怪源码。它告诉你的是,运行时 classpath 上同名的类,有两个不同的版本在共存。
给这个项目定个习惯
以后只要改了 mindflow-application、mindflow-domain、mindflow-infrastructure 这类被 mindflow-api 依赖的模块,启动前先跑:
powershell
cd mindflow\backend
mvn --settings .mvn/settings.xml install -DskipTests '-Dspring-boot.repackage.skip=true'
如果只是跑测试,仍然可以:
powershell
mvn --settings .mvn/settings.xml -pl mindflow-api -am test
两套路径的差异这次是 bug,下次就是常识。