2206 字
约 7 分钟
13
S07-A27 · `javax` 到 `jakarta`:为什么"改个包名"要动整条链路

Spring Boot 3 升级的第一步是 javax.servlet 改成 jakarta.servlet。看起来是全局替换,实际动起来会撞上三件事:自己的代码改了但依赖没改、依赖改了但某个传递依赖还停在旧包、以及编译全过、启动就 NoClassDefFoundError。

原因在于包名不只是一个字符串:它是字节码常量池里的符号引用、是类加载器查找的路径、也是第三方库兼容矩阵的分界线。所以这次迁移的成本几乎全在"依赖图"上,而不在"替换"上。


1. 包名在三个层面存在

层 表现形式 替换方式
源代码 import javax.servlet.* IDE 全局替换(简单)
字节码 常量池里的 javax/servlet/HttpServlet 只能重新编译
运行时 类加载器按全限定名查找 必须真的存在 jakarta.* 的实现

第二行是"改了源码没用"的原因:如果某个 jar 是别人编译好的,它内部仍是 javax 引用,你改不了。第三行则是"编译过了却起不来"的原因:编译期只要 classpath 上有对应符号就通过,运行期才真正去加载。

// 一次典型的迁移失败路径
1 全局替换 import → 自己的代码编译通过
2 依赖里 spring-boot-starter-web 已升到 3.x(内部是 jakarta)
3 但某个老 jar(比如旧版 swagger)内部仍是 javax.servlet.*
4 编译通过(classpath 上两个包都在)
5 启动时加载该 jar 的 Filter → NoClassDefFoundError: javax/servlet/Filter
// 关键:错误发生在"运行到那个类"时才暴露,不是启动即失败

2. 兼容矩阵才是真正的工程量

迁移的判据不是"我的代码改完没有",而是"依赖树里还有谁在引用 javax"。

依赖类型 迁移难度 判断方法
Spring 官方 starter 低(3.x 已切) 版本号直接对上
常用三方库(Jackson、SLF4J) 低 无 servlet 依赖
Servlet 相关(swagger、旧 filter) 高 查是否引用 javax.servlet
字节码增强(旧版 lombok/asm) 中 看是否支持新 class 版本
内部私有 jar 最高 只能自己重编译

第五行最容易被低估:公司内部的公共 jar 往往没人维护,而它可能依赖 javax.servlet。这类依赖没有"新版本可用",只能自己 fork 重编。

// 找出依赖树里所有还引用 javax.servlet 的 jar
$ mvn dependency:tree -Dincludes=javax.servlet:*,javax.servlet.jsp:* 
$ for j in $(find ~/.m2 -name '*.jar' -newer pom.xml); do
    unzip -p "$j" '*.class' 2>/dev/null | strings | grep -q 'javax/servlet' \
      && echo "STILL_JAVAX: $j"
  done
// 第二步很笨,但它能抓出"没有新版本可用"的那些

3. 编译通过不等于能跑:三处延迟暴露

暴露点 表现 为什么会延迟
启动时 NoClassDefFoundError 只有被扫描到的类才加载
首次请求 ClassNotFoundException 懒加载的 Filter/Servlet
特定路径 某个功能报错 条件装配的分支没走到

第三行最阴:应用启动正常、主流程正常,只有"某个特定条件"触发的分支才会加载那个旧类——于是它在上线三天后才被发现。

// 一个只在"文件上传"路径暴露的例子
启动:      正常(FileUpload 相关 Bean 未被扫描)
普通请求:  正常(不走 multipart 解析)
上传文件:  NoClassDefFoundError: javax/servlet/http/Part
// 结论:迁移后的验证必须覆盖"被条件装配保护的分支"

4. 迁移顺序:先依赖,再代码

步骤 动作 判据
1 升 BOM(spring-boot-dependencies 3.x) 版本对齐,先不动代码
2 跑依赖树,列出 javax 残留 清单为空才继续
3 替换 import,编译 编译通过不算完
4 启动 + 全量接口冒烟 覆盖条件分支
5 灰度,观察 NoClassDefFoundError 日志关键字告警
// 一次 12 模块迁移的实际耗时分布
升 BOM + 修编译       2.0 人日   18%
扫依赖树、找替代       3.0 人日   27%   ← 真正的大头
替换 import           0.5 人日    5%
冒烟 + 条件分支覆盖    4.0 人日   36%
灰度观察              1.5 人日   14%
// "替换"只占 5%,"找替代"与"覆盖分支"合计 63%

第四步的"全量"是关键:那些被 @ConditionalOnProperty 关掉的 Bean,只有打开对应开关才会加载。所以冒烟必须把所有功能开关都打开跑一遍,否则第 3 节的"特定路径"必然漏。

// 迁移期的三条临时护栏(上线后可以撤)
migration_guard:
  - log_keyword_alert: ["NoClassDefFoundError: javax", "ClassNotFound: javax"]
  - feature_flags_all_on_in_staging: true      // 冒烟要覆盖全部开关
  - canary_traffic: 5%                          // 首批只放 5%
  // 关键:第三条不是"稳妥",而是"把延迟暴露的问题限制在小流量里"

5. 什么时候不必迁

场景 为什么不必 说明
停留在 Boot 2.x 且无升级需求 迁移成本 > 收益 但要记录技术债
只用纯库(非 web) 无 servlet 依赖 改造成本极低
内部 jar 无法重编 迁移会被阻塞 先解决所有权问题
强依赖的中间件无 3.x 支持 迁移等于换中间件 单独立项

判断口径一句话:这次迁移的成本 = 依赖树里"引用 javax 且没有新版本可用"的 jar 数,而不是"代码里 import 的行数"。前者决定要不要立项,后者只是执行细节。


6. 排错:五种"看起来迁完了"

现象 八成原因 处理方向
启动 NoClassDefFoundError: javax 某依赖未升级 按第 2 节扫依赖树
编译报"找不到符号 javax" 依赖已升 3.x,代码没改 全局替换 import
启动正常、上传报错 条件分支未覆盖 打开全部开关冒烟
灰度后零星报错 某实例缓存了旧 jar 清构建缓存重打镜像
测试全绿、生产失败 测试用 mock,未加载真实类 加一条真容器集成测试

第五行是最后一类陷阱:单元测试里 @MockBean 把那个旧 Filter 替换掉了,于是测试永远不会加载它。要抓这类问题,必须有一条"不 mock、真启动"的集成测试。


7. 边界:什么情况该直接换实现

场景 为什么不该原地迁 做法
依赖的是已废弃框架(旧版 Jersey/Struts) 没有 3.x 版本 换实现或重写模块
内部 jar 已无人维护 重编了也没人担保 先决定"谁维护"
迁移面覆盖 80 % 模块 一次性成本过高 拆成"边缘模块先迁"
关键路径无测试 迁移风险不可控 先补测试再迁

判断口径的另一半:迁移不是"能否编译"的问题,而是"谁来承担未覆盖分支的风险"——如果没有测试覆盖那些条件分支,迁移的正确做法是"先补测试",而不是"先迁再修"。


8. 自测

  1. 包名在源代码、字节码、运行时三个层面分别是什么?为什么"改了源码没用"?
  2. 为什么"编译通过"不能推出"能跑"?三处延迟暴露点分别是什么?
  3. 迁移的真正成本由什么决定?用什么命令能列出"还引用 javax 的 jar"?
  4. 为什么冒烟测试必须"把所有功能开关打开"?
  5. @MockBean 会让哪类问题永远测不出来?
  6. 什么情况下"原地迁移"本身就是错的?

9. 一句话总结

javax → jakarta 的成本几乎全在依赖图上,而不在替换上:包名同时存在于源码、字节码常量池与类加载路径,所以"改完 import"只是第一步;真正的判据是依赖树里还有多少 jar 引用 javax 且没有新版本可用。三处延迟暴露(启动 / 首次请求 / 特定条件分支)决定了冒烟必须打开全部开关,而 @MockBean 会让这类问题永远测不出来。判据:没有测试覆盖的条件分支,先补测试再迁。

S07-A27 · `javax` 到 `jakarta`:为什么"改个包名"要动整条链路
https://www.clxhxhhr.top/posts/6740/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。