SpringBoot fastjson 1.x → fastjson2 2.0.63 迁移执行手册
SpringBoot fastjson 1.x → fastjson2 2.0.63 迁移执行手册
适用:把com.alibaba:fastjson(1.x)迁移到com.alibaba.fastjson2:fastjson2(2.0.63)。
这份文档是可执行清单,不是背景介绍。按 §1 → §7 顺序做,每步都有检测命令和确定的改法。
三条铁律(违反其中任何一条,迁移就是白做或留下静默故障):
- 坐标必须换成
com.alibaba.fastjson2:fastjson2。安全扫描按groupId:artifactId匹配 CVE,只升版本不换坐标,告警不会消失。 - 必须加 §4 的全局兼容配置。缺了它,两个默认行为变化会造成不抛异常、结果悄悄变错的线上事故。
- 遇到兼容问题就地解决,不要退版本。目标版本恒定 2.0.63。
§1 依赖改造
1.1 先统一,再迁移
多模块项目的旧版本号通常散落在各模块 pom 里(多个 1.x 版本并存)。先收敛到父 pom 一处,否则改完主线仍有模块在用旧包。
<!-- 父 pom:唯一版本来源 --><properties><fastjson.version>2.0.63</fastjson.version></properties><dependencyManagement><dependencies><dependency><groupId>com.alibaba.fastjson2</groupId><artifactId>fastjson2</artifactId><version>${fastjson.version}</version></dependency></dependencies></dependencyManagement>子模块只声明坐标、不写 version。有独立父 pom 的模块(不继承主父 pom)要单独同步。
1.2 检测并处理
# 旧坐标(含第三方传递依赖 —— 有的话你换了自己的也白搭)mvn dependency:tree-Dincludes='com.alibaba:fastjson*'# 各模块 pom 里的旧坐标声明grep-rn"<artifactId>fastjson</artifactId>"--include="pom.xml".| 情况 | 动作 |
|---|---|
| 模块源码有 fastjson 调用 | 换坐标,version 交给父 pom |
| 模块源码零调用(grep 包名无命中) | 直接删掉依赖,不要只升版本 |
| 第三方库传递带入旧坐标 | 单独评估:升级该第三方,或<exclusions>排除 |
§2 包名与 API 替换
2.1 包名映射
| 原 | 新 |
|---|---|
com.alibaba.fastjson.JSON/JSONObject/JSONArray/JSONException | com.alibaba.fastjson2.* |
com.alibaba.fastjson.TypeReference | com.alibaba.fastjson2.TypeReference |
com.alibaba.fastjson.annotation.JSONField/JSONType | com.alibaba.fastjson2.annotation.* |
com.alibaba.fastjson.serializer.SimplePropertyPreFilter | com.alibaba.fastjson2.filter.SimplePropertyPreFilter |
⛔不要用sed 's/com\.alibaba\.fastjson\./com.alibaba.fastjson2./g'一把梭—— 会漏掉serializer→filter的子包改名,报错是「找不到符号」,容易误判成缺依赖。
2.2 API 替换(编译期会报错的)
| 原写法 | 改成 |
|---|---|
JSONObject.toJavaObject(json, Xxx.class)(静态形式) | JSON.parseObject(str, Xxx.class) |
JSONObject.toJSON(obj) | JSON.toJSON(obj) |
JSONObject.parseArray(str) | JSON.parseArray(str) |
JSON.toJSONString(obj, true) | JSON.toJSONString(obj, JSONWriter.Feature.PrettyFormat) |
JSONObject.toJSONString(obj, filter) | JSON.toJSONString(obj, filter) |
@JSONType(serializeEnumAsJavaBean = true) | @JSONType(writeEnumAsJavaBean = true) |
2.3 🔴JSONObject.parse()→JSON.parse()(编译期不报错,运行期炸)
这是全流程里最容易漏的一条,优先处理。
// ❌ 危险:v1 里 parse 是从 JSON 继承来的静态方法,返回 Object;// v2 里被重新声明为返回 JSONObject,传入数组直接抛 JSONExceptionObjectresult=JSONObject.parse(text);// ✅ 正确:JSON.parse 保留返回 Object 的多态语义Objectresult=JSON.parse(text);把JSONObject赋给Object是类型放宽,编译器绝不会报错。运行时传入[开头的数组才炸:JSONException: offset 1, character [, line 1, column 1。
grep-rn"JSONObject\.parse("--include="*.java".# 命中即改2.4 立个规矩:统一只用JSON.作为静态入口
JSONObject/JSONArray只当类型用,不当工具类用。
v1 里JSONObject继承JSON,所以JSONObject.parseObject(...)这类写法能编译,看起来和JSON.xxx()等价 —— 但 v2 里有的被删、有的被收窄(§2.3)。统一入口能一次性消灭这整类陷阱,且这批改动语义完全等价、零风险。
grep-rnE"JSON(Object|Array)\.(parseObject|parseArray|toJSONString|toJavaObject|toJSON|parse)\("--include="*.java".§3 🔴🔴 两个静默行为变化(唯一会造成线上事故的)
不抛异常、不打日志,只是结果悄悄变了。编译和普通单元测试都发现不了。
处理方式是 §4 的全局配置 —— 一行覆盖全部实体,不要指望逐个补注解。
3.1 智能字段匹配默认关闭
| fastjson1 | fastjson2 原生 | |
|---|---|---|
| 大小写不敏感的 key→字段匹配 | 开启 | 关闭 |
对接外部系统时,返回的 JSON key 常是全小写或全大写(msgid、roomid、tolist、ACTVNM),而实体字段是驼峰且历史代码普遍没加@JSONField(name=...)—— 因为 v1 靠智能匹配自动对上了。
v2 原生下这些字段全部静默变null,后果还会二次放大:
key 匹配不上 → 实体业务主键为 null → 落库时唯一索引冲突 → 第一条侥幸写入,后续全部失败 → 日志只有「已收到」,没有任何异常排查时看到的是「数据少了、字段空了」,离根因隔三层,极费时。
3.2 Date 默认序列化格式改变
| fastjson1 | fastjson2 原生 | |
|---|---|---|
java.util.Date | 毫秒时间戳1784685600000 | 字符串"2026-07-21 10:00:00" |
发往外部系统的报文时间格式全变。自己系统察觉不到(反序列化时两种格式 v2 都能读),只有对端解析失败 —— 往往等对方投诉才知道。
§4 必须添加的全局兼容配置
直接复制。三个语句的顺序不能变。
importcom.alibaba.fastjson2.JSON;importcom.alibaba.fastjson2.JSONReader;/** * fastjson2 原生模式全局兼容配置 —— 恢复 fastjson1 的默认行为。 * 缺少本配置会造成「不抛异常、结果悄悄变错」的静默故障,禁止删除。 */publicclassFastjson2CompatConfig{static{applyV1CompatibleDefaults();}publicstaticvoidapplyV1CompatibleDefaults(){// [1] 必须最先执行,且必须早于任何 fastjson2 调用:强制初始化 MethodHandles.Lookup 类。// JDK 8 上 fastjson2 的 JDKUtils 静态块用 Unsafe 读 Lookup.IMPL_LOOKUP 拿 TRUSTED lookup,// 而 Unsafe 读静态字段不触发类初始化 —— 若 Lookup 尚未初始化则读到 null,// fastjson2 退化为无 PRIVATE 权限的 lookup,其 lambda 访问器随即抛// LambdaConversionException: Invalid caller: <Bean类>,// 后果是该 JVM 内所有 Bean 反序列化全部失败。// 返回值无需使用,要的只是「类被初始化」这个副作用。禁止删除本行。java.lang.invoke.MethodHandles.lookup();// [2] 恢复大小写不敏感的智能字段匹配(见 §3.1)JSON.config(JSONReader.Feature.SupportSmartMatch);// [3] 恢复 Date -> 毫秒时间戳(见 §3.2)// ⛔ 不要用 JSONWriter.Feature.WriterUtilDateAsMillis:它是硬覆盖,// 会让字段上的 @JSONField(format=...) 全部失效。// ✅ configWriterDateFormat 设的是「默认格式」,字段级注解仍可覆盖,与 v1 语义一致。JSON.configWriterDateFormat("millis");// 加一行启动日志,测试/运维可据此在启动日志确认配置生效// log.info("fastjson2 兼容配置已应用: SupportSmartMatch + writerDateFormat=millis");}}4.1 放在哪里
| 情况 | 落点 | 理由 |
|---|---|---|
| 有共享基础库,所有服务都依赖 | 放共享库,注册为自动配置(Spring Boot 2.x:META-INF/spring.factories;3.x:META-INF/spring/...AutoConfiguration.imports) | 自动配置不受各服务@ComponentScan的excludeFilters影响 |
| 某服务显式排除了共享库的包扫描 | 在该服务包内再独立放一份 | 别赌「自动配置一定不受影响」这个假设 |
非 Spring 应用 / 独立main() | 入口第一行显式调用applyV1CompatibleDefaults() | 没有容器帮你加载配置类 |
⚠️用静态代码块,不要只靠@Bean方法—— 这样类被任何方式加载时配置都会生效,不依赖 Spring 是否真的实例化了它。
4.2 JDK 8 的LambdaConversionException(配置里第 [1] 行解决的问题)
现象:
RuntimeException: Failed to create lambda for method: public void XxxDTO.setYyy(java.lang.String) Caused by: java.lang.invoke.LambdaConversionException: Invalid caller: com.example.XxxDTO一旦出现,该 JVM 内所有 Bean 反序列化全部失败(不只是报错那个类)。
为什么表现为「时好时坏」:只要在 fastjson2 初始化之前有任何代码碰过MethodHandles.Lookup,就一切正常。而门槛极低 ——任何一个 lambda / 方法引用在invokedynamic引导时都必须用到Lookup,一个Runnable r = () -> {};就够。
| 场景 | 是否中招 |
|---|---|
| Spring Boot 服务正常启动 | ❌ 不中招(框架启动过程满是 lambda) |
| JUnit / surefire 跑测试 | ❌ 不中招(同理) |
裸public static void main()调试类 | ✅必中招 |
所以典型症状是:线上和测试环境都正常,只有开发在 IDE 里跑main()报错—— 极易被当成本地环境问题忽略。
配套动作:
# 确认没有类在静态初始化里调用 fastjson2(那种类可能比配置类先加载,预热就晚了)grep-rnE"static[[:space:]]+(final[[:space:]]+)?[A-Za-z<>,\[\][:space:]]+=[[:space:]]*(JSON|JSONObject|JSONArray)\."--include="*.java".# 生产代码里残留的调试用 main():这些不走 Spring 容器、加载不到配置类,必然中招。建议删除grep-rn"public static void main"--include="*.java"src/main/⚠️判定要点:如果生产环境出现的是「字段为 null」而不是这个异常,说明 reader创建成功了(只是 key 没匹配上),那是 §3.1 的问题,不是这里的问题。两者现象完全不同,别搞混。
副作用(要如实记录,别当无害空调用):预热成功后 fastjson2 拿到的是 TRUSTED 级 lookup(对任意类都有 private 访问权)。可接受,因为 fastjson2 既然在 classpath 上,任意一个 lambda 就能让它拿到,这一行没有给它原本拿不到的东西;且 fastjson 系列的历史漏洞是 autoType 反序列化 gadget,与 lookup 权限无关。
§5 验证
5.1 差分测试:以 fastjson1 的真实 jar 为基线逐项比对
这是唯一能发现「静默行为变化」的手段。两套 classpath(一边挂 1.x jar、一边挂 2.0.63),跑同一组用例,逐字节比对输出。不一致的每一项都要能解释清楚,不能一句「应该没影响」带过。
覆盖清单(可直接用作用例模板):
| 类别 | 覆盖内容 |
|---|---|
| 取值 API | getString/getInteger/getLong/getBoolean/getDate/getObject/getJSONObject/getJSONArray/toJavaList |
| 边界输入 | key 不存在、空字符串、null入参、类型不匹配 |
| 结构 | 嵌套泛型TypeReference、JSONArray下标访问、值本身是 JSON 字符串时调getJSONArray是否自动二次解析 |
| 序列化 | transient是否跳过、SimplePropertyPreFilter父子类作用域、@JSONField(serialize=false/deserialize=false) |
| 注解组合 | @JSONField(name)+@JSONField(format)与全局配置的优先级 |
| 日期 | Date/LocalDateTime的序列化与反序列化格式 |
已知的唯一可接受差异:同一对象被重复引用时,v1 输出 fastjson 私有的$ref标记,v2 直接把对象写两遍。若无代码依赖$ref,v2 的输出对外部消费者反而更安全。
5.2 留一组「必须失败」的回归用例
针对 §3 的静默行为写死断言,注明禁止删除:
| 用例 | 守住的东西 |
|---|---|
| 全小写 key 能映射到驼峰字段(断言字段非 null) | SupportSmartMatch |
Date序列化结果精确等于毫秒时间戳(如{"createTime":1784685600000}) | 日期格式 |
带@JSONField(format)的字段仍按注解格式输出 | 没误用WriterUtilDateAsMillis |
JSON.parse("[...]")返回JSONArray、JSON.parse("{...}")返回JSONObject | §2.3 的返回类型语义 |
5.3 单元测试的两个盲区(吃过亏)
- 测试 Bean 必须用独立顶层类,不要用测试类的静态内部类。
Lookup.in()的规则是「同一 top-level 类内部才保留 PRIVATE 权限」,嵌套类恰好绕过 §4.2 的缺陷,把问题完全掩盖。 - 依赖类初始化顺序的缺陷,单元测试天生测不出。JUnit/surefire 启动本身就会初始化
MethodHandles.Lookup,所以 §4.2 那个缺陷在测试里永远是通过。这类问题只能靠独立 JVM 差分(每个场景一个全新进程)。
写用例前先自问:这个用例在缺陷存在时会失败吗?答案是「不一定」,它就不是防线,只是装饰。
§6 收尾审计(应全部为空,或人工逐条确认)
# 1. 残留旧包名(注意末尾的点)grep-rn"com\.alibaba\.fastjson\."--include="*.java".# 2. 残留旧坐标(含传递依赖)grep-rn"<artifactId>fastjson</artifactId>"--include="pom.xml".mvn dependency:tree-Dincludes='com.alibaba:fastjson*'# 3. 🔴 返回类型被收窄的调用grep-rn"JSONObject\.parse("--include="*.java".# 4. 应统一为 JSON. 入口的静态调用grep-rnE"JSON(Object|Array)\.(parseObject|parseArray|toJSONString|toJavaObject|toJSON|parse)\("--include="*.java".# 5. 注解属性改名grep-rn"serializeEnumAsJavaBean"--include="*.java".# 6. prettyFormat 布尔重载grep-rnE"toJSONString\([^)]+,[[:space:]]*(true|false)[[:space:]]*\)"--include="*.java".# 7. 强转 JSON.toJSON() 结果的地方(确认入参是对象而非 JSON 字符串,见 §7.3)grep-rn"JSON\.toJSON("--include="*.java".# 8. 静态初始化里调用 fastjson2(见 §4.2)grep-rnE"static[[:space:]]+(final[[:space:]]+)?[A-Za-z<>,\[\][:space:]]+=[[:space:]]*(JSON|JSONObject|JSONArray)\."--include="*.java".# 9. 生产代码里的调试 main()(见 §4.2)grep-rn"public static void main"--include="*.java"src/main/无法机械检测、必须人工梳理的一项:所有「接收外部系统 JSON 的实体」清单 —— 靠 §4 的全局配置兜住,再逐步补@JSONField(name=...)。
6.1 发版顺序(顺序错会导致服务仍在用旧包)
- 先发共享基础库(含全局兼容配置的那个),推私服
- 再发直接依赖 fastjson 的服务
- 最后发只通过共享库间接依赖的服务
验收项:
- 各服务依赖树里已无旧坐标
- 各服务启动日志能看到兼容配置那行 log(看不到就说明配置没加载,§3 两个坑必然复现)
- 重跑安全扫描,确认告警确实消除(这是整件事的目标,别忘了验收)
- 按各服务 fastjson 调用文件数排测试优先级,用量最大的优先深测
§7 排查时会误导你的信号
7.1 不要用JSON.VERSION判断版本
上游有时忘了同步这个常量,它可能与实际 jar 版本不符,异常堆栈里打的fastjson-version也来自它。会让人误判成「IDE classpath 陈旧」,白花时间清缓存、重导项目。
unzip-pfastjson2-*.jar META-INF/maven/com.alibaba.fastjson2/fastjson2/pom.properties# 权威shasum fastjson2-*.jar&&catfastjson2-*.jar.sha1# 校验 jar 完整性mvn dependency:tree-Dincludes='com.alibaba*:fastjson*'# 实际生效版本7.2 共享库改完必须重新 install
否则下游模块解析到本地仓库里的旧 POM,表现为「明明改完了,依赖树里 fastjson 还在」。
mvninstall-pl<共享库模块>-am-DskipTestsIDE 也要手动 Reload Maven Project,否则 IDE 内的运行/调试仍用旧 classpath。
7.3 别把所有异常都归因到升级
迁移期间任何报错都会被当成升级引起。先用 fastjson1 的 jar 复现一遍再下结论。
高频误判:(JSONObject) JSON.toJSON(jsonString)抛ClassCastException。toJSON()的职责是「Java 对象→ JSON 结构」,传字符串进去它原样返回 String ——v1 行为完全相同,是一直存在的错误用法,与迁移无关。
| 目的 | 正确 API |
|---|---|
JSON 字符串 →JSONObject | JSON.parseObject(str) |
| JSON 字符串 → 未知结构(可能是数组) | JSON.parse(str) |
Java 对象 →JSONObject | JSON.toJSON(obj) |
| Java 对象 → JSON 字符串 | JSON.toJSONString(obj) |
三句话总结
编译通过 ≠ 行为一致。唯一造成线上事故的两个坑(§3 智能匹配、日期格式)编译期和单元测试都不报错。精力放在差分测试上。- 审计 API 要看返回类型和语义,不能只看方法是否存在。返回类型收窄 + 赋值给
Object,能完美骗过编译器(§2.3)。 - 写回归用例前先问:这个用例在缺陷存在时会失败吗?用嵌套静态类当测试 Bean、或测一个依赖类初始化顺序的缺陷,用例永远是绿的。