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. 自测
- 包名在源代码、字节码、运行时三个层面分别是什么?为什么"改了源码没用"?
- 为什么"编译通过"不能推出"能跑"?三处延迟暴露点分别是什么?
- 迁移的真正成本由什么决定?用什么命令能列出"还引用 javax 的 jar"?
- 为什么冒烟测试必须"把所有功能开关打开"?
@MockBean会让哪类问题永远测不出来?- 什么情况下"原地迁移"本身就是错的?
9. 一句话总结
javax → jakarta 的成本几乎全在依赖图上,而不在替换上:包名同时存在于源码、字节码常量池与类加载路径,所以"改完 import"只是第一步;真正的判据是依赖树里还有多少 jar 引用 javax 且没有新版本可用。三处延迟暴露(启动 / 首次请求 / 特定条件分支)决定了冒烟必须打开全部开关,而 @MockBean 会让这类问题永远测不出来。判据:没有测试覆盖的条件分支,先补测试再迁。