1. 项目背景与问题初现
最近在负责一个老项目的技术栈升级,其中一个核心任务是把项目中使用的 fastjson 从 1.2.83 版本升级到最新的 2.0.9 版本。这个决定背后有多重考量:一方面是 fastjson 1.x 系列爆出的多个高危反序列化漏洞,比如那个著名的 1.2.47 远程命令执行漏洞,让运维同学天天提心吊胆;另一方面,fastjson2 作为阿里官方的下一代序列化库,在性能、安全性和 API 设计上都宣称有巨大改进,长期来看是必然的选择。升级过程本身不算复杂,Maven 依赖一改,重新编译,大部分测试用例也都跑过了。但就在我们以为可以松一口气的时候,线上一个不太起眼的监控告警把我们拉回了现实:有几个接口返回的 JSON 数据中,某些字段名“莫名其妙”地变了,导致前端解析失败,页面直接报错。
具体现象是,一个UserDTO对象,在 1.2.83 下序列化后字段是{"userName":"张三","userId":123},到了 2.0.9 下,竟然变成了{"username":"张三","userid":123}。字段名从驼峰变成了全小写,这直接导致了前后端契约的破坏。这可不是小问题,在微服务架构下,这种序列化不一致就像一颗定时炸弹,可能引发上游服务、消息队列消费者乃至数据存储的一系列连锁反应。我意识到,这次升级远不是改个版本号那么简单,fastjson2 在带来新特性的同时,也引入了一些默认行为的改变,而我们之前对 1.x 的“经验”和“潜规则”理解,在这里可能不再适用。这次踩坑经历,让我对 fastjson 的版本差异和升级策略有了更深刻的认识,也整理出了一套完整的排查和解决方案。
2. 核心问题深度剖析:属性名映射规则之变
为什么字段名会变?这是首先要搞清楚的问题。在 fastjson 1.x 时代,默认的命名策略(PropertyNamingStrategy)是CamelCase,这也是 Java 世界最常用的约定:类中的userName字段,序列化成 JSON 时默认就是userName。然而,fastjson 2.x 在默认行为上做了一个重大的、但文档中并不显眼的调整。
2.1 fastjson 2.x 的默认命名策略
fastjson 2.0 引入了一个新的默认命名策略:PropertyNamingStrategy.CamelCase1x。这个名字有点迷惑性,它其实是为了“模拟” fastjson 1.x 的行为,但又不完全一样。更关键的是,在 2.x 中,还存在着另一个策略叫PropertyNamingStrategy.CamelCase。这两者的区别非常微妙,但正是问题的根源。
经过阅读源码和测试,我发现:
CamelCase1x: 这是 fastjson 2.x默认的策略。它的目标是尽可能兼容 1.x 的行为。对于标准的 Getter/Setter(如getUserName/setUserName),它能正确推导出字段名userName。但是,它的兼容逻辑存在一些边界情况。CamelCase: 这是一个“更标准”或“更严格”的驼峰策略。在某些特定情况下,它的推导逻辑与CamelCase1x不同。
在我们的案例中,问题出在实体类的字段命名和 Lombok 的使用上。我们大量使用了 Lombok 的@Data注解来生成 Getter/Setter。对于字段userName,Lombok 生成的 Getter 是getUserName()。在 fastjson 1.2.83 的CamelCase策略下,它能正确识别并序列化为userName。但在 fastjson 2.0.9 的默认CamelCase1x策略下,其内部推导逻辑可能将这个 Getter 方法名先转换为userName(去掉get并首字母小写),然后可能又经过了一层全小写化的处理(或者是遇到了某些特定字符序列的规则),最终错误地输出了username。这种细微的差异在简单的 POJO 上可能不会出现,但在字段名包含多个大写字母(如userId->userid)或特定缩写时,就容易暴露出来。
2.2 Feature 配置的差异与影响
除了命名策略,fastjson 2.x 在Feature配置上也做了大量重构和新增。Feature是控制序列化/反序列化行为的开关集合。很多在 1.x 中默认开启或关闭的特性,在 2.x 中可能发生了变化。
例如,在 1.x 中,Feature.WriteMapNullValue用于控制是否输出值为null的字段,默认是false。而在 2.x 中,相关的配置可能被整合或重命名。虽然这不是我们当前字段名问题的直接原因,但在升级过程中,如果忽略了Feature的差异,很可能导致 JSON 输出的结构发生变化(比如突然多出一堆null字段,或者原本输出的字段不见了),同样会破坏接口契约。
另一个需要关注的Feature是Feature.IgnoreNoneSerializable。在 1.x 中,如果序列化的对象含有不可序列化的字段(例如一个Thread类型的成员),默认会抛出异常。而在 2.x 中,这个行为的默认值可能不同,或者需要通过其他配置项来控制。如果我们的 DTO 中不小心混入了非序列化对象,升级后可能从“报错”变为“静默忽略”,这反而会掩盖问题,导致数据丢失。
注意:fastjson 2.x 的
Feature枚举类 (com.alibaba.fastjson2.JSONWriter.Feature) 与 1.x (com.alibaba.fastjson.serializer.SerializerFeature) 是完全不同的包和类。不能简单地通过import修改来迁移配置,必须逐一核对每个Feature在 2.x 中的对应项和默认值。
3. 系统性排查与诊断流程
当发现属性转换不一致的问题后,不能头痛医头脚痛医脚,需要一个系统性的排查流程来定位所有潜在的风险点。我总结为“四步诊断法”。
3.1 第一步:全局扫描与差异对比
首先,我们需要知道到底有多少地方受到了影响。手动检查是不可能的,必须借助工具进行自动化扫描。
编写差异对比工具: 我写了一个简单的 Java 工具,利用反射扫描项目中所有标记了
@RestController、@ResponseBody或类似注解的返回值类型,以及所有显式调用JSON.toJSONString()的类。然后,针对每个候选的 POJO 类,分别用 fastjson 1.2.83 和 2.0.9 实例化一个具有典型值的对象并进行序列化,最后比较两个 JSON 字符串。这里的关键是实例化对象时要填充有区分度的数据,比如字段名本身就包含大小写变化(userName,URL,IDCard等)。// 示例对比逻辑片段 Object sampleObj = createSampleInstance(clazz); // 根据类信息构造一个样例对象 String json1 = com.alibaba.fastjson.JSON.toJSONString(sampleObj); // 1.x String json2 = com.alibaba.fastjson2.JSON.toJSONString(sampleObj); // 2.x if (!json1.equals(json2)) { // 记录下这个类名和差异详情 logger.warn("Class {} has serialization difference.", clazz.getName()); // 进一步解析差异,是字段名不同还是值不同? }分析差异类型: 对比结果可能显示几种差异:
- 字段名变化:如
userName->username。这是最需要关注的。 - 字段顺序变化:fastjson 2.x 默认的字段顺序可能与 1.x 不同。虽然 JSON 标准不要求顺序,但某些前端库或测试用例可能依赖顺序。
- null 字段处理:某些字段在 1.x 输出,在 2.x 被忽略,或反之。
- 日期/数字格式:默认的日期格式可能从时间戳变成了字符串。
- 字段名变化:如
3.2 第二步:聚焦问题类,定位根因
对于第一步筛选出的问题类,需要深入分析。我们的问题集中在字段名上,所以重点排查:
- 检查类定义: 查看类的字段名、Getter/Setter 方法名。特别注意 Lombok、MapStruct 等代码生成工具生成的方法是否符合预期。有时候,手写的 Getter 方法(例如
getURL())也可能导致解析差异。 - 检查注解: fastjson 提供了
@JSONField注解来显式指定序列化行为。检查问题类上是否使用了该注解,特别是name属性。确保 1.x 和 2.x 的注解包路径正确(1.x:com.alibaba.fastjson.annotation.JSONField, 2.x:com.alibaba.fastjson2.annotation.JSONField)。如果混用,注解会失效。 - 使用调试工具: 在测试环境中,针对问题接口或方法,分别用两个版本的 fastjson 进行序列化,并调试进入
toJSONString方法内部。观察在CamelCase1x策略下,序列化器是如何从getUserName()方法名推导出最终字段名的。这能最直观地看到逻辑分歧点。
3.3 第三步:审查全局配置与自定义序列化器
很多项目会在启动时(如 Spring Boot 的@Configuration类中)配置全局的 fastjsonSerializeConfig或ParserConfig,或者注册自定义的ObjectSerializer/ObjectDeserializer。
- 全局配置: 检查项目中是否有类似
JSON.DEFAULT_PARSER_FEATURE或SerializeConfig.getGlobalInstance().put(...)的代码。这些全局配置在升级后必须重新评估。fastjson 2.x 的配置入口通常是JSONFactory和JSONReader.Context/JSONWriter.Context。 - 自定义序列化器: 这是高风险区。为特定类(如枚举、
LocalDateTime)编写的自定义序列化器,其接口在 2.x 中可能已经改变。必须逐个检查这些类,确保它们实现了 2.x 对应的接口(如ObjectWriter),并且逻辑兼容。 - Spring HttpMessageConverter 配置: 如果项目使用 Spring MVC 并配置了 FastJsonHttpMessageConverter,需要检查其配置。在 2.x 中,对应的类是
FastJson2HttpMessageConverter。要确保其中设置的Features、SerializeFilters等与 1.x 时期的行为一致。
3.4 第四步:制定并验证修复方案
根据排查结果,制定针对性的修复方案。我们的核心问题是默认命名策略,解决方案有以下几种,需要根据实际情况选择或组合:
方案一:显式使用
@JSONField注解(推荐)这是最彻底、最可控的方式。在每一个需要序列化的字段或其 Getter 方法上,添加@JSONField(name = “xxx”)来显式指定序列化后的字段名。这样无论底层命名策略如何变化,输出都是确定的。- 优点: 行为明确,不受版本升级影响,代码即文档。
- 缺点: 如果 POJO 数量众多,改动量较大。但可以通过 IDE 的批量重构功能部分解决。
- 实操: 确保导入的是
com.alibaba.fastjson2.annotation.JSONField。
public class UserDTO { @JSONField(name = "userName") private String userName; @JSONField(name = "userId") private Long userId; // getters and setters }方案二:调整全局命名策略如果不想大规模修改注解,可以尝试在应用启动时,将 fastjson 2.x 的全局命名策略改回更接近 1.x 行为的模式。但要注意,这可能会影响所有序列化行为,需要充分测试。
- 操作: 在 Spring Boot 主类或配置类中,通过
JSONFactory.setDefaultObjectWriterProvider或配置HttpMessageConverter时传入自定义的JSONWriter.Context来设置命名策略。可以尝试PropertyNamingStrategy.CamelCase而非默认的CamelCase1x,看是否能解决问题。 - 风险: “更接近”不等于“完全相同”。这个方案可能解决了 A 类的问题,却在 B 类上引发了新问题。必须进行全面的回归测试。
- 操作: 在 Spring Boot 主类或配置类中,通过
方案三:使用 Fastjson 2.x 的兼容模式fastjson 2.x 提供了一些旨在兼容 1.x 的 API 和配置。例如,可以使用
com.alibaba.fastjson2.JSON类中那些以toJSONString(Object, JSONWriter.Feature...)形式存在的方法,并传入特定的Feature。但根据我的测试,对于命名策略这种底层行为,仅通过Feature很难完美复现 1.x 的所有细节。- 建议: 不要过度依赖“兼容模式”,它可能只是一个临时方案。明确指定行为(方案一)才是长期稳定的保障。
修复后的验证: 修改完成后,必须重新运行第一步的全局差异对比工具,确保所有不一致都已消除。此外,要跑遍所有的单元测试、集成测试和 API 契约测试(如果有的话)。特别要关注那些依赖 JSON 字段顺序或精确字符串匹配的测试用例。
4. 升级全流程实操指南与避坑要点
基于这次经验,我梳理了一份从 fastjson 1.x 升级到 2.x 的标准操作流程(SOP),涵盖了从准备到上线的全过程。
4.1 升级前准备:风险评估与清单制定
在动手改任何代码之前,先做好以下准备:
- 依赖梳理: 用
mvn dependency:tree或 Gradle 的依赖分析工具,精确找出项目中所有直接和间接依赖 fastjson 1.x 的地方。特别注意那些传递依赖,可能有其他组件引入了老版本。 - API 使用情况扫描: 使用代码搜索工具(如 IDE 的全局搜索、
grep),查找所有import com.alibaba.fastjson的语句。统计JSON.parseObject、JSON.toJSONString、JSONArray、JSONObject、TypeReference、@JSONField等关键 API 的使用点。这能帮你评估工作量。 - 制定回滚方案: 升级必须在可控的环境下进行。确保你有能力快速回滚到 fastjson 1.x 版本。这通常意味着要有清晰的发布流程和备份。
- 建立测试基线: 在升级前,确保当前基于 fastjson 1.x 的测试套件全部通过。这个状态将作为后续对比的“金标准”。
4.2 依赖变更与编译问题解决
修改构建配置: 在
pom.xml或build.gradle中,将 fastjson 依赖替换为 2.x。注意 groupId 从com.alibaba变为了com.alibaba.fastjson2。<!-- Fastjson 2.x --> <dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.9</version> </dependency>同时,要使用
<exclusions>排除所有传递依赖引入的 fastjson 1.x 版本,避免冲突。处理编译错误: 执行编译命令。常见的编译错误包括:
- 包路径错误: 所有
import com.alibaba.fastjson.*需要改为import com.alibaba.fastjson2.*。这是一个机械但量大的工作,可以用 IDE 的批量重构功能。 - API 不兼容: fastjson 2.x 的 API 有大量重构。例如,1.x 的
SerializerFeature和ParseFeature在 2.x 中合并并重组为JSONReader.Feature和JSONWriter.Feature。需要根据编译错误信息,查阅 fastjson2 官方文档 找到对应的新 API 或Feature。 - 工具类缺失: 一些 1.x 中的工具类(如
TypeUtils)在 2.x 中可能被移除或改名。需要寻找替代方案或自己实现。
- 包路径错误: 所有
4.3 运行时行为验证与兼容性测试
编译通过只是第一步,更重要的是保证运行时行为一致。
单元测试修复: 运行所有单元测试。失败的测试用例是宝贵的“行为差异探测器”。针对每个失败用例,分析原因:
- 是因为字段名变了?(采用第3章的方案修复)
- 是因为
null值处理方式变了?(调整Feature,如JSONWriter.Feature.WriteNulls) - 是因为日期格式变了?(使用
@JSONField(format=“...”)或全局配置日期格式) - 是因为自定义序列化器失效了?(重写 2.x 版本的
ObjectWriter)
集成测试与 API 测试:
- 启动本地服务,用 Postman 或 curl 调用关键接口,对比升级前后的响应体。可以使用
diff工具进行精确比对。 - 如果项目有消费者驱动的契约测试(如 Pact),运行这些测试来验证接口契约未被破坏。
- 特别关注涉及金额、身份证号等敏感数据的序列化,确保精度和格式无误。
- 启动本地服务,用 Postman 或 curl 调用关键接口,对比升级前后的响应体。可以使用
性能与内存测试(可选但建议): 在测试环境进行压力测试,对比升级前后的接口响应时间和 GC 情况。fastjson2 号称性能提升显著,但需要在你自己的业务场景下验证。
4.4 上线与监控
- 灰度发布: 不要全量一次性升级。可以先在少数不重要的服务或实例上部署,观察日志和监控。
- 加强监控: 在上线后的一段时间内,加强对相关服务错误日志、序列化异常告警的监控。可以针对性地增加一些健康检查接口,专门测试核心 POJO 的序列化结果是否与预期一致。
- 知识同步: 将本次升级遇到的问题、解决方案和新的配置规范整理成文档,同步给团队所有成员,避免后续开发中再次踩坑。
5. 常见问题排查清单与实战技巧
下面是一个在升级过程中可能遇到的问题速查表,以及我总结的一些实战技巧。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 字段名发生变化(驼峰变小写等) | 1. 默认命名策略差异 (CamelCase1xvsCamelCase)。2. Lombok等工具生成的Getter方法名特殊。 | 1. 使用@JSONField(name="...")显式指定。2. 检查并统一字段命名风格。 3. 尝试调整全局 PropertyNamingStrategy(需全面测试)。 |
字段缺失或新增了null字段 | Feature.WriteMapNullValue等控制字段是否输出的配置默认值改变。 | 1. 检查全局和局部的Feature配置。2. 在 toJSONString方法或HttpMessageConverter中明确指定所需的Feature。 |
| 日期格式序列化结果不一致 | 默认的日期格式 (DateFormat) 改变。 | 1. 在字段上使用@JSONField(format="yyyy-MM-dd HH:mm:ss")。2. 通过 JSONWriter.Context配置全局日期格式。 |
数字(如BigDecimal)精度或格式变化 | 数字序列化的默认行为改变。 | 1. 使用@JSONField(serializeUsing=MyNumberSerializer.class)自定义。2. 通过 Feature.WriteBigDecimalAsPlain等控制输出格式。 |
| 序列化/反序列化循环引用导致栈溢出 | 1.x中默认开启的“循环引用检测”在2.x中可能默认关闭或配置方式不同。 | 1. 检查Feature.DisableCircularReferenceDetect等相关配置。2. 在对象结构上避免循环引用,或使用 $ref表示。 |
| 泛型类型反序列化失败 | TypeReference或Type的处理逻辑有变。 | 1. 确保使用com.alibaba.fastjson2.TypeReference。2. 对于复杂泛型,考虑使用 JSON.parseObject(str, new TypeReference<...>(){})明确指定类型。 |
自定义序列化器(ObjectSerializer)不生效 | 2.x的自定义序列化器接口和注册方式已变。 | 1. 实现2.x的ObjectWriter接口。2. 通过 JSONFactory.getDefaultObjectWriterProvider().register(...)注册。 |
与Spring Boot整合,HttpMessageConverter配置失效 | 未使用2.x对应的FastJson2HttpMessageConverter。 | 1. 移除旧的FastJsonHttpMessageConverter配置。2. 添加并配置 FastJson2HttpMessageConverter,注意设置Features。 |
实战技巧分享:
- “双版本并存”测试法: 在升级初期,可以通过 Maven Shade 插件或自定义类加载器,在测试环境中同时加载 fastjson 1.x 和 2.x 的类(但使用不同的全限定名)。编写一个测试工具,对同一组数据分别用两个版本的 API 进行序列化并对比结果。这能最全面地发现行为差异。
- 善用
JSONWriter.Context和JSONReader.Context: 这是 fastjson 2.x 的核心配置上下文。大部分全局行为(如命名策略、日期格式、自定义序列化器注册、Feature开关)都可以通过配置这两个Context来实现。在 Spring 项目中,通常只需要配置一次FastJson2HttpMessageConverter时传入定制化的Context即可。 - 关注
JSONPath的使用: 如果你的项目使用了 fastjson 的JSONPath功能进行 JSON 数据的查询和修改,需要重点测试。2.x 的JSONPathAPI 可能有变动,且在不同命名策略下,路径表达式可能需要调整。 - 漏洞扫描与 SafeMode: 升级到 2.x 的一个重要目的是修复安全漏洞。确保了解 fastjson 2.x 的
SafeMode特性。在反序列化不可信的 JSON 字符串时,可以通过JSON.parseObject(str, clazz, JSONReader.Feature.SupportAutoType)来禁用自动类型识别(AutoType),这是防御反序列化攻击的关键。但注意,SafeMode可能会影响某些依赖 AutoType 的功能。