深入 Spring E07:从 6.2 到 7.x 的兼容矩阵与行为迁移
编译通过以后,响应仍可能改变
一个返回普通 Java Bean 的接口,升级后仍然返回 200,字段值没有变化,JSON 字段顺序却改变了。另一个返回 record 的接口,在同一组版本中保持原来的顺序。把迁移结果概括成“Jackson 3 会把所有 JSON 按字母排序”,无法解释这两个实际响应。
框架升级同时涉及依赖集合、Java API、自动配置与对外协议。依赖树解决的是哪些类型被加载,编译解决的是源码是否接受新 API,HTTP 回归才检查客户端收到什么。任何单层结果都不足以替代其余几层。
这组实验使用两个独立 Maven POM,分别编译同一份契约探针,再启动两个独立 JVM。旧版本不会因为新版本需要某个依赖而被调整,新版本也没有通过手工覆盖 Spring 补丁号拼出一个未经验证的集合。结果只属于下面冻结的历史组合,不是当前生产升级推荐。
先冻结版本集合,再讨论迁移
两组工程都使用 JDK 21.0.11。旧工程继承 Boot 3.5.6,新工程继承 Boot 4.0.0;Framework、Tomcat、Jackson 和 Bean Validation 的实际版本由各自 BOM 解析。JDK 相同,可以减少 Java 运行时变化对本轮比较的干扰。
| 组件 | legacy | modern |
|---|---|---|
| Spring Boot | 3.5.6 | 4.0.0 |
| Spring Framework | 6.2.11 | 7.0.1 |
| Tomcat / Servlet 运行时 | 10.1.46 / 6.0 | 11.0.14 / 6.1 |
| Jackson Databind | 2.19.2 | 3.0.2 |
| Jakarta Validation API | 3.0.2 | 3.1.1 |
| Hibernate Validator | 8.0.3.Final | 9.0.1.Final |
Boot 4.0.0 的固定 gradle.properties 明确列出 Framework 7.0.1、Jackson 3.0.2 和 Tomcat 11.0.14。因此,“Boot 4.0.0 自然对应 Framework 7.0.0”不是这个发布产物的事实。Boot v4.0.0 版本源
Servlet 版本不只是依赖树上的数字。探针从启动后的 ServletContext 读取 major/minor version,旧工程实际报告 6.0,新工程报告 6.1。Servlet 容器、部署目标和相关扩展组件都要满足新的契约,不能只升级 spring-web 而保留不兼容的旧容器。Framework 7.0 发布说明快照
两个 POM 如何避免依赖互相污染
实验目录为 migration-lab/legacy 和 migration-lab/modern。它们各自有 parent、依赖解析结果、target/classes 和 classpath.txt;共享源码只用于保证测试意图相同。运行命令的 classpath 从相应模块生成,没有把两份依赖字符串拼接到同一个进程。
旧工程使用 spring-boot-starter-web,新工程使用拆分后的 spring-boot-starter-webmvc;两个工程都引入各自版本的 validation starter。模块拆分意味着升级后需要检查自动配置类、测试支持和 starter 的归属,不能假定旧的包名与依赖传递结构始终保留。Boot 4 迁移指南快照
探针从每个 ApplicationContext 取得自动配置的 JSON mapper Bean。旧工程检查 com.fasterxml.jackson.databind.ObjectMapper,新工程检查 tools.jackson.databind.json.JsonMapper。检查 Bean 比仅判断 classpath 上存在某个类更接近真实请求使用的配置,但最终仍需要通过 HTTP 序列化断言核对行为。
Jackson 3 的主要包和 Maven group 移到 tools.jackson,jackson-annotations 保持兼容的 com.fasterxml.jackson.annotation 命名空间。不能对所有 import 做一次无差别字符串替换。新 mapper 采用构建器配置模型,旧的运行期修改 ObjectMapper 代码也需要单独迁移。Spring 官方 Jackson 3 说明
API 移除与空值契约是不同问题
探针检查 org.springframework.util.concurrent.ListenableFuture 是否可加载。旧版本存在,新版本不存在,与 Framework 7 移除该 API、推荐 CompletableFuture 的发布说明一致。这条检查验证运行时类型边界;实际业务源码如果仍然导入它,还需要修改方法签名、回调适配与异常传播路径。
只替换类型名可能改变调用者约定。Future 是否可以取消、取消是否传播到底层任务、异常是在哪个线程观察,仍需针对应用执行链建立测试。本实验没有构造一个通用 ListenableFuture 兼容层,也没有把类型存在性当成完整异步迁移验收。
Framework 7 的 JSpecify 空值声明是另一个维度。它表达 API 的静态可空性契约,不会自动替换 HTTP 请求体上的 Bean Validation。即使某个参数被静态标成非空,仍需检查外部请求反序列化、缺少字段和验证失败的运行时路径。实验保留 Jakarta 的 @NotBlank/@Min,并实际发送非法输入。
主线冻结的 Framework 6.2.11 源码可以继续用于解释旧行为;新行为需要新版本源码或独立结果。不能拿旧版 RequestMappingHandlerAdapter 的实现片段,直接证明新版的所有参数处理分支相同。旧版固定源码
JSON 顺序的变化需要区分普通 Bean 与 record
普通 BeanOutput 按 zebra、alpha、nullable 的顺序声明三个 public 字段,没有添加排序注解。旧版本响应为 {"zebra":"Z","alpha":"A","nullable":null},新版本响应为 {"alpha":"A","nullable":null,"zebra":"Z"}。实际断言检查新版本中 alpha 出现在 zebra 之前,旧版本则相反。
相同属性构成的 Output record 在两组版本里都返回 {"zebra":"Z","alpha":"A","nullable":null}。实验明确断言这个 record 的 zebra 仍在 alpha 之前。因此,普通 Bean 的默认排序变化不能不加条件地推广到所有对象类型。
JSON 对象成员顺序通常不应承载业务语义,但签名、字符串快照、缓存键以及遗留客户端可能实际依赖它。迁移时应先确定应用是否把序列化字节作为协议,而不是看到字段顺序变化就直接删除所有快照测试。若顺序确实属于外部约定,应显式配置并验证。
这两个样本的 null 字段在新旧版本中都保留。它只证明当前无定制 inclusion 配置的响应行为,不表示所有空值处理都未变化。原始 JSON null 请求体、Java 字段为 null、缺少属性以及泛型容器元素为 null,是不同输入,应分别建立契约。
实验还发送一个包含额外 extra 属性的合法请求,两代都返回 200。这是 Boot 自动配置后的观测;不能据此推断手动 new 出来的任意 Jackson mapper 也具有完全相同的默认特性。Boot 4 JSON 自动配置源码
输入转换、Bean Validation 与方法验证分别回归
POST /input 接收一个包含 name 和 quantity 的 record。name 标有 @NotBlank,quantity 标有 @Min(1),请求体参数标有 @Valid。合法输入在两组版本中都返回 order:2,空字符串与零数量都返回 400。
另一个 GET /quantity 把查询参数转换成 int,并直接对方法参数声明 @Min(1)。value=2 返回 200,value=0 返回 400,value=bad 也返回 400。最后一个请求失败在类型转换阶段,与数值约束失败不是同一条执行路径;不能因状态相同就只保留一个测试。
JSON 语法错误、请求体为 JSON null、text/plain 输入分别被断言为 400、400、415。它们检查读取失败、必需请求体缺失以及媒体类型匹配。仅测试一个合法 DTO,无法发现升级后转换器集合、验证器安装或错误处理发生变化。
这些状态在冻结对照里保持一致,日志仍分别存放在 legacy 和 modern 下。相同结果也有价值:它说明这一组外部契约在升级后没有改变,而不是证明两代内部异常类型、默认错误体字段和所有验证细节完全相同。
路径、内容协商与序列化异常
GET /payload 正常返回 JSON;GET /payload/ 返回 404;POST /payload 返回 405;Accept: application/xml 返回 406。尾斜杠、HTTP 方法和响应类型是三个独立维度,两个版本都通过相同契约。
服务还返回一个 getter 会抛异常的 Broken 对象。真实 HTTP 请求在两个版本中都得到 500。该对象在写出首个业务字段时失败,当前响应尚能按错误路径处理。这个结果不能推广到已经 flush 部分响应体之后的序列化失败;主线的消息转换实验另行覆盖已提交响应的边界。
异常回归应保留发生位置和响应是否提交,而不只是搜日志中的 exception。若客户端已经收到 200 和部分字节,服务端后续记录异常并不意味着客户端最终能收到完整 500 错误体。迁移涉及 JSON converter 更换时,这一边界尤其需要真实 HTTP 验证。
本章的 HttpClient 不模拟浏览器,也未加载应用的安全链、数据库和业务异常处理器。它验证的是最小 MVC 契约。将这些请求应用到实际工程时,还要把工程自己的 Filter、ControllerAdvice、序列化扩展与网关一起纳入回归范围。
RuntimeHints 源码兼容与生成文件格式
同一份 ProbeHints 实现 RuntimeHintsRegistrar,为输入 record 注册构造器反射访问,并登记 migration-contract.txt 资源模式。它分别在 Framework 6.2.11 和 7.0.1 下编译、执行,断言反射提示中包含构造器调用类别,再由 FileNativeConfigurationWriter 写出配置文件。
旧版本生成 reflect-config.json 与 resource-config.json,新版本生成统一的 reachability-metadata.json。验证脚本读取实际 JSON,要求包含输入类型名与资源模式,并保存文件副本。这样能发现只盯着旧文件名的构建检查脚本在迁移后失效。
这里验证的是 hints API 使用和输出格式。资源模式被登记,不代表名为 migration-contract.txt 的业务资源已经存在;反射元数据被生成,也不代表 native-image 已经链接并运行了应用。实验没有运行 GraalVM,也没有把这个探针叫作完整 process-aot 验收。
完整应用 AOT 还需要验证条件装配、生成初始化代码、AOT JVM 与最终打包环境。主线 42 的边界仍然适用:迁移计划可以复用测试意图,但应为新版本重新生成产物和执行证据,不能沿用旧构建日志。
实际运行与可修改的迁移契约
在系列实验包的 examples/spring-framework-lab/ 目录执行,JAVA_HOME 指向 JDK 21:
1 | |
脚本先独立编译 legacy、记录依赖树并运行,再对 modern 执行同样流程。每组 25 条断言,共 50 条,构建和运行退出码均为 0。证据在 evidence/E07/local-20261002/legacy 与 modern,包含完整进程输出、命令、源码/POM 哈希和生成 hints;顶层 run.txt 只汇总唯一的通过断言。
练习可以在两个版本的启动默认属性中都加入 spring.jackson.default-property-inclusion=non_null。两个 /payload 响应中的 nullable 字段应消失,而请求体校验和路径状态应保持基线。需要有意识地修改 null 输出断言,不能只接受新快照而不检查哪条契约被改变。此变体是预测练习,未计入当前 50 条结果。
另一个迁移策略是只在新版本启用 spring.jackson.use-jackson2-defaults=true,再比较普通 Bean 的字段顺序。该配置旨在接近旧默认值,但不是让全部 Jackson 2 类型和扩展 API 自动恢复;命名空间、构建器以及模块迁移仍需完成。该变体同样没有冒充已运行验收。
系列入口与主线实验见 深入 Spring(00):从手动组装到可验证的容器实验。一项迁移结论应同时带上版本集合、对应输入与可观察结果;“升级后测试通过”只有在这三者明确时,才具有可复用的含义。
参考资料
文档读取日期为 2026-10-02。Wiki 引用保留所读快照标识,固定 tag 源码与本地运行结果用于判断这组版本的具体行为。
