从Fastjson到Jackson:工具类测试实战与踩坑全记录

从Fastjson到Jackson:工具类测试实战与踩坑全记录 1. 项目背景为什么工具类也需要测试1.1 从 Fastjson 迁移到 Jackson 的那次踩坑两三年前我参与的项目里 JSON 处理一直用的是 Fastjson理由很简单API 用起来太顺手了公共模块里一个JSON.toJSONString()、JSON.parseObject()就能解决 90% 的场景团队里几乎没有争议。真正让我下决心换掉的是一次线上事故某个回调接口在解析报文时字段值被异常吞掉而且只在客户生产环境出现本地怎么复现都复现不出来。排了一整天最后定位到是 Fastjson 在特定 JDK 版本上对某个特殊字符的解析行为不一致。加上当时安全扫描已经连续多次给 Fastjson 打高危漏洞告警领导直接拍板全量替换。第二周我做了技术选型对比Jackson 成了明确首选原因有三个。Spring Boot 默认内置 Jacksonstarter-web 一拉进来就带好了依赖不需要额外引入任何包Spring MVC、RestTemplate、Feign、消息队列的序列化行为默认全部基于 Jackson替换成本最低Jackson 的社区活跃度、版本迭代节奏和维护者响应速度在 Java JSON 库里是最稳的。这个决定还带来一个隐性收益Spring 的配置项可以直接复用。比如spring.jackson.date-format、spring.jackson.time-zone在 application.yml 里配好之后框架层和工具类层会同步生效不用像 Fastjson 那样在自定义 HttpMessageConverter 里再单独适配一遍。这个“生态一致性”后来成了我做工具类的第一条设计原则。1.2 工具类测试到底在测什么很多项目的工具类是“写了就行”的状态测试覆盖率根本不覆盖工具包。我过去也这样直到有一次重构序列化逻辑改完一个看似不影响外部行为的内部实现结果线上 Redis 缓存反序列化直接报错排查了一上午才发现工具类行为变了。那次之后我给自己立了一条规矩被多个模块引用的公共工具类必须配测试类测试必须进 CI。JacksonUtilTest 表面上测的是一个 JSON 工具类实际上固定下来的是三件事配置的稳定性、边界行为的明确性、泛型的安全性。配置稳定性意思是任何人改动 ObjectMapper 的序列化特性、白名单或模块注册测试用例都能第一时间感知到边界行为意思是null、空字符串、空对象、特殊字符分别应该返回什么文档里写得再清楚也不如一个测试用例管用泛型安全意思是List、Map、嵌套对象在反序列化之后类型必须正确泛型丢失是这类工具最容易踩的隐藏问题。所以我一直跟团队说这个测试类不是给审计看的是给下一次改代码的人看的。谁改了行为谁就要为测试失败负责。现在我觉得这个测试类的价值远大于 JacksonUtil 本身因为工具类代码稳定在一个文件里不会有太多更新测试类却能持续积累各种真实场景。每遇到一个线上 JSON 解不透的坑我就先写一条会失败的测试再回头改工具类这比直接改代码要安全得多。2. 搭建 JacksonUtil 之前的配置选型2.1 ObjectMapper 单例与直接 new 的区别JacksonUtil 内部最核心的一个对象就是 ObjectMapper它的创建姿势直接决定后续所有序列化行为。很多人图省事每次调用都new ObjectMapper()。这在低并发场景下没问题但 ObjectMapper 内部缓存了大量序列化器、反序列化器、类型信息反复创建不仅浪费内存还丢掉缓存导致性能下降。正确做法是全局维护一个 static final 单例并且把配置集中在 static 块或构建器里一次性完成。同时要控制序列化特性开关默认的 ObjectMapper 有几个行为并不适合业务系统。我最常改的几个配置如下关闭FAIL_ON_UNKNOWN_PROPERTIES接口多返回一个字段反序列化不应该报错。默认是开启的实际对接第三方系统时特别容易出问题。关闭FAIL_ON_EMPTY_BEANS有些场景序列化一个空对象默认会抛 InvalidDefinitionException业务上往往希望它输出{}。设置setSerializationInclusion(JsonInclude.Include.NON_NULL)全局忽略 null 字段既减小报文体积也避免前端拿到一堆 null 还要做判空。关闭WRITE_DATES_AS_TIMESTAMPS默认日期会变成一串数字可读性太差而且不同系统对时间戳的精度理解不一致。注意NON_NULL 是一个全局开关适用于大部分业务但如果你有字段要“显式地输出 null”比如某些接口协议要求保留 null 占位那这个开关就不能全局开。这种情况下建议用JsonInclude(Include.ALWAYS)按字段覆盖。Jackson 2.x 系列还有一个更推荐的写法就是用JsonMapper.builder()而不是直接new ObjectMapper()。构建器模式可以更清晰地叠加底层 JsonFactory 的特性比如字符串长度上限、嵌套深度限制等而且能避免 ObjectMapper 子类构造方法被隐藏的问题。我现在的工具类就是基于 JsonMapper.builder() 构建的后面单独讲 Spring Boot 4.0.0 升级踩到的坑。2.2 Java 8 日期时间序列化WRITE_DATES_AS_TIMESTAMPSJava 8 之后大家普遍开始用 LocalDateTime、LocalDate、Instant不再用 java.util.Date。这道门槛很多老项目没迈过去因为 Jackson 需要额外注册 JavaTimeModule而且默认把 LocalDateTime 序列化成时间戳数组输出结果非常反直觉。所以配置里必须做两件事注册JavaTimeModule同时关闭WRITE_DATES_AS_TIMESTAMPS。我推荐在工具类里固定一套时间格式private static final String DATE_TIME_PATTERN yyyy-MM-dd HH:mm:ss; mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.registerModule(new JavaTimeModule()); mapper.setDateFormat(new SimpleDateFormat(DATE_TIME_PATTERN));这样LocalDateTime序列化输出为2025-06-14 10:30:00反序列化也能自动解析同格式字符串。要注意的是setDateFormat只对 java.util.Date 生效对 Java 8 时间类型要配合 JavaTimeModule 的写入器。如果想彻底控制 LocalDateTime 的格式更稳妥的方式是在字段上使用JsonFormat(pattern yyyy-MM-dd HH:mm:ss)。时间格式这种看似简单的配置测试才是最该覆盖的地方。因为一旦项目从 JDK8 升到 JDK17或者 Jackson 做了一次次版本升级时间序列化行为完全可能悄悄变化。我后面在测试用例里专门为多组日期格式写了参数化测试确保不同输入格式都能被正确解析。2.3 Spring Boot 4.0.0 下的 JsonMapper.builder() 兼容性排查我确实在升级 Spring Boot 4.0.0 时踩过一次JsonMapper$Builder的坑。现象是应用启动正常但第一次调用工具类序列化时直接抛NoClassDefFoundError: com/fasterxml/jackson/databind/json/JsonMapper$Builder。乍一看很诡异因为代码里明明有很多地方用了 JsonMapper。查下来原因是 Spring Boot 4.0.0 里 Jackson 版本发生跳跃内部实现和打包方式有变化某些场景下引用的JsonMapper.builder()返回的是一个优化后的子类构建器而之前代码里缓存、强转或反射访问了JsonMapper.Builder这个具体的内部类升级后类名冲突导致加载失败。定位思路很简单打开异常堆栈看第一行凡是NoClassDefFoundError且类名以$结尾的基本都是内部类引用问题。解决方法是避开对构建器具体类型的任何强依赖只把构建结果作为ObjectMapper返回。我最后的实现是这样的private static final ObjectMapper MAPPER createMapper(); private static ObjectMapper createMapper() { JsonMapper.Builder builder JsonMapper.builder(); // 只对 builder 调用配置方法不对 builder 类型做任何强引用 return builder.build(); }这一步的关键教训是升级框架版本时不要只看功能是否正常还要看公共依赖与内部类的引用方式。Jackson 的构建器内部类设计在跨版本之间做过调整工具类如果封装得越薄迁移成本就越低。我后来专门在测试类里加了一个“启动自检”用例用反射确认 ObjectMapper 能够正常构建并且基本序列化可用这样下次升级时测试会先于线上报错把问题暴露出来。3. JacksonUtil 核心方法实现与测试设计3.1 对外暴露的方法清单我最终保留了七个方法不多不少刚好覆盖业务里出现频率最高的场景方法作用典型场景toJson(Object)对象转 JSON 字符串写日志、Redis 缓存 value 序列化toJsonPretty(Object)对象转格式化 JSON打印报文、调试接口parseObject(String, ClassT)JSON 转普通对象解析 HTTP 入参、MQ 消息体parseObject(String, TypeReferenceT)JSON 转泛型对象解析 ListBean、MapString, BeanparseArray(String, ClassT)JSON 数组转 List批量数据解析toMap(String)JSON 转 Map动态配置解析、协议无关字段读取toJsonNode(String)JSON 转 JsonNode树形操作、不固定结构的报文遍历方法不在多在于语义清晰。我见过很多工具类一上来就十个方法其实不少是重复的比如既提供parseList又提供parseArray内部实现一模一样徒增维护成本。我这里把泛型解析和数组解析分开是因为底层要处理 TypeReference 和 TypeFactory 两套机制分开更直白。3.2 序列化方法的边界处理序列化方法toJson有个容易被忽视的问题入参是 null 时应该返回什么我检查过很多项目有人返回null这个字符串有人返回空字符串有人直接抛异常。我最终统一成入参为 null 时返回 null。理由是这样调用方可以用StringUtils.isNotBlank直接判断避免把null当有效 JSON 传到下游。public static String toJson(Object obj) { if (obj null) { return null; } try { return MAPPER.writeValueAsString(obj); } catch (JsonProcessingException e) { throw new JacksonUtilException(序列化失败: obj.getClass().getName(), e); } }失败时我选择包装成自定义运行时异常而不是返回 null。序列化失败往往是代码 bug比如对象里有无法处理的自定义类型如果吞掉异常然后返回 null调用方大概率会拿到一个空缓存排查起来更痛苦。上抛运行时异常虽然会打断当前流程但至少问题能第一时间暴露日志里也有明确堆栈。toJsonPretty的实现也不复杂用一个独立的 PRETTY_MAPPER只开启SerializationFeature.INDENT_OUTPUT避免和默认 Mapper 的配置耦合。之前试过在同一个 Mapper 上来回开关这个特性并发环境下线程安全没问题但语义上不清晰而且日志打印这种低频操作不差那点性能。3.3 反序列化与泛型丢失问题反序列化方法里最需要注意的是泛型。一个经典错误是直接写MAPPER.readValue(json, List.class)这会导致反序列化得到的 List 元素全部是 LinkedHashMap后续取字段时要么 ClassCastException要么 null。正确做法是通过 TypeReference 或者 TypeFactory 构造完整的 JavaType。public static T T parseObject(String json, TypeReferenceT typeReference) { if (StringUtils.isBlank(json)) { return null; } try { return MAPPER.readValue(json, typeReference); } catch (JsonProcessingException e) { throw new JacksonUtilException(JSON反序列化失败, e); } }调用方这样用ListOrderDTO orders JacksonUtil.parseObject(json, new TypeReferenceListOrderDTO() {});TypeReference 本质上是在编译期把泛型类型记录到一个匿名子类里Jackson 通过反射拿到完整的ListOrderDTO类型信息才能正确创建泛型列表。这里有个细节TypeReference 的匿名内部类必须在大括号里保持泛型参数不能写成任何简化形式否则拿到List?之后依旧是一堆 Map。parseArray方法我单独提供是因为它在代码里调用频率太高每次都写 TypeReference 很啰嗦而且容易手滑丢泛型public static T ListT parseArray(String json, ClassT clazz) { if (StringUtils.isBlank(json)) { return Collections.emptyList(); } JavaType type MAPPER.getTypeFactory().constructCollectionType(List.class, clazz); return MAPPER.readValue(json, type); }边界行为这里我也用测试固定下来空字符串返回空集合而不是 null。这样循环遍历时不需要加额外判空业务代码干净很多。4. JacksonUtilTest 完整测试实战4.1 测试类框架与基础用例测试类我使用的是 JUnit 5配合 AssertJ 做断言。不要小看测试框架选择JUnit 4 时代很多参数化测试要写 Runner非常繁琐JUnit 5 的ParameterizedTest和CsvSource让边界测试写起来轻松很多。AssertJ 的断言链式表达也更直观尤其是断言异常信息的时候。基础用例长这样class JacksonUtilTest { Test void toJson_shouldReturnNull_whenObjectIsNull() { assertThat(JacksonUtil.toJson(null)).isNull(); } Test void toJson_shouldSerializeSimpleObject() { OrderDTO order new OrderDTO(); order.setOrderId(NO20250614001); order.setAmount(new BigDecimal(99.90)); String json JacksonUtil.toJson(order); assertThat(json).contains(\orderId\:\NO20250614001\); assertThat(json).contains(\amount\:99.90); } Test void parseObject_shouldReturnNull_whenJsonIsBlank() { assertThat(JacksonUtil.parseObject( , OrderDTO.class)).isNull(); assertThat(JacksonUtil.parseObject(null, OrderDTO.class)).isNull(); } Test void parseObject_shouldDeserializeSimpleJson() { String json {\orderId\:\NO20250614001\,\amount\:99.90}; OrderDTO order JacksonUtil.parseObject(json, OrderDTO.class); assertThat(order.getOrderId()).isEqualTo(NO20250614001); assertThat(order.getAmount()).isEqualByComparingTo(99.90); } }测试方法命名我统一用方法名_条件_期望结果的格式例如toJson_shouldReturnNull_whenObjectIsNull。刚开始写会觉得啰嗦但跑测试失败时只看方法名就知道哪里行为不符合预期不需要打开代码猜。4.2 核心特性的参数化测试与边界用例日期时间是最值得参数化测试的点。我准备了多组输入格式覆盖标准时间、带毫秒时间、ISO 时间和只有日期的情况ParameterizedTest CsvSource({ 2025-06-14 10:30:00, 2025-06-14T10:30:00, 2025-06-14 10:30:00.123, 2025-06-14 }) void parseObject_shouldParseDateTimeInMultipleFormats(String dateTimeText) { String json String.format({\createTime\:\%s\}, dateTimeText); OrderDTO order JacksonUtil.parseObject(json, OrderDTO.class); assertThat(order.getCreateTime()).isNotNull(); }注意2025-06-14这种纯日期文本虽然 Jackson 有一定的宽松解析能力但要确保 JavaTimeModule 注册到位。如果项目里时间格式五花八门字段上补JsonFormat是最稳的方式。BigDecimal 也是重点。JSON 数字本身没有 BigDecimal 概念用字符串传更安全。我测试里特意验证了amount: 0.1和amount: 0.1两种输入都能还原成 BigDecimal并且数学计算不出精度问题。枚举序列化看起来简单但坑也不少。我测试时会固定枚举的序列化值避免默认输出枚举名后面接口协议改了导致下游无法识别。方案是在枚举字段上用 JsonValue 指定一个稳定编码而不是依赖 name()。特殊字符测试也不能少。比如字符串包含换行符、中文引号、emoji 时序列化后 JSON 必须合法且反序列化后原样还原。我会用小样例测试确保这些不破坏 JSON 结构。4.3 异常场景测试null、空串、非法 JSON、未知字段异常场景往往比主流程更能暴露工具类的设计问题。非法 JSON 应该抛自定义运行时异常而不是让底层 JsonProcessingException 直接冒到业务层。我写了一个断言Test void parseObject_shouldThrowJacksonUtilException_whenJsonIsInvalid() { String invalidJson {\orderId\: 123, }; assertThatThrownBy(() - JacksonUtil.parseObject(invalidJson, OrderDTO.class)) .isInstanceOf(JacksonUtilException.class) .hasMessageContaining(JSON反序列化失败); }未知字段的处理也很典型。接口升级后多给了一个remark字段工具类必须默认忽略而不是抛异常。我测试里构造一个包含未知字段的 JSON断言反序列化不仅不失败已知字段还都能正确赋值。Test void parseObject_shouldIgnoreUnknownProperties() { String json {\orderId\:\NO1\,\unknownField\:\x\}; OrderDTO order JacksonUtil.parseObject(json, OrderDTO.class); assertThat(order.getOrderId()).isEqualTo(NO1); }空数组和空对象两种边界也不能漏。[]转 List 应该得到空集合{}转对象应该得到字段为 null 的对象这两个行为和 null 入参不一样测试必须分开固定。我见过有人把这两种边界混在一起结果后期改代码时其中一个行为悄悄变化测试还全绿就是因为用例覆盖不到位。5. 测试暴露出来的三个高频坑5.1 JsonProperty 字段别名与下划线映射的坑项目里对接第三方接口时报文字段经常是下划线风格比如order_id而 Java 属性是驼峰orderId。我一开始只想着在字段上加JsonProperty(order_id)解决结果测试跑下来发现有些字段能映射有些字段始终是 null。排查后发现原因第三方接口里同时存在orderId和order_id两种写法Jackson 的字段名映射优先级很高一旦一个属性有多个候选就会受MapperFeature.ALLOW_EXPLICIT_PROPERTY_RENAMING等特性影响。最终方案是全局开启SNAKE_CASE命名策略配合JsonAlias支持多别名mapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);这样 Java 属性orderId默认匹配order_id再在字段上用JsonAlias(orderId)兼容另一种写法。这个配置在测试里要有对应用例否则很容易被后续改掉。5.2 BigDecimal 被序列化成科学计数法有个线上场景订单金额是0.00001这样的极小值直接用 BigDecimal 序列化后输出变成了1.0E-5结果下游系统解析 JSON 数字时溢出或精度丢失。这个问题很容易被日志里“看起来正常”的表象掩盖因为大金额很少触发科学计数法。解决办法有两个层面。第一个是在业务字段上强制用字符串JsonSerialize(using ToStringSerializer.class) private BigDecimal amount;这样序列化后输出0.00001前端拿到的始终是字符串不会丢精度。第二个是如果必须保留 JSON 数字类型就用 BigDecimal.toPlainString 配合自定义序列化器但通常没必要。测试里我会加一个极小值用例确保任何金额字段的输出都不含 E 符号。5.3 反序列化时 LocalDateTime 的时区与格式化问题这个坑是最隐蔽的同一个 LocalDateTime 字段使用yyyy-MM-dd HH:mm:ss反序列化没问题但换一个时区的服务器解析结果就差了 8 小时。原因很简单Jackson 处理带时区信息的时间字符串时会调用系统默认时区而容器时区没有统一设置。我现在的做法是在工具类初始化时固定时区mapper.setTimeZone(TimeZone.getTimeZone(Asia/Shanghai));同时在测试里加一个时区敏感性用例先把默认时区改成 UTC再验证反序列化结果仍然按东八区解析。这个测试虽然简单但它能阻止任何人在后续升级中无意间把时区配置去掉。6. Jackson 和 Fastjson 的最终选型建议6.1 性能对比在业务里其实没那么关键网上一搜“jackson和fastjson哪个好”几乎一半的回答都在比谁快。坦白说我做过的压测里Fastjson 在部分简单序列化场景确实有一点优势但差距通常不到 10%而且会随 JDK 版本和数据规模变化。对绝大多数业务系统JSON 解析的瓶颈根本不在序列化器本身而在网络 IO、数据库查询和业务逻辑。纠结那一点点性能远不如选一个生态稳定、维护及时、行为可预期的方案。我之前做过一次单测级压测用同样的订单对象序列化 100 万次Jackson 和 Fastjson 的耗时差距大概在 5% 到 15% 之间但 Jackson 的内存占用更稳定GC 压力也更小。如果系统真的到了 JSON 解析成为瓶颈的程度更合理的方向是减少不必要的 JSON 序列化、使用更紧凑的协议而不是在两个库之间纠结。6.2 Jackson 在 Spring 体系里的生态优势Jackson 最硬核的优势其实是生态整合。Spring 全家桶默认使用 Jackson这意味着你的 DTO 在 Controller 层由框架反序列化在 Feign 层由框架反序列化在消息队列里由框架反序列化如果工具类也用 Jackson所有层面的解析规则完全一致。Fastjson 要做到这一点必须自己实现 HttpMessageConverter、自定义消息转换器还要在多个框架组件里单独配置维护成本明显偏高。另外Jackson 对 JSON Schema、XML、YAML、CBOR 等格式支持很完整同一个 ObjectMapper 可以切换输出格式。这在系统需要同时支持 JSON 和 YAML 配置的场景下特别方便。Fastjson 在这块几乎是空白。社区活跃度也值得放大看Jackson 的 GitHub issue 响应速度明显更快版本发布更频繁安全漏洞修复也更及时。企业系统最怕依赖一个停止维护的库到时候安全漏洞都找不到地方修。6.3 给后来者的配置建议如果你现在正在做技术选型或者准备迁移我的建议是新项目无脑用 Jackson直接基于JsonMapper.builder()创建 ObjectMapper 单例集中配置好日期、时区、未知字段忽略、空值策略然后写一套像 JacksonUtilTest 这样的回归测试固定行为。老项目如果是 Fastjson迁移速度不用太激进但至少要保证新接入的模块直接用 Jackson避免两种 JSON 库混用导致行为不一致。最后再分享一个我在实际维护中发现的小技巧工具类里的 ObjectMapper 配置不要改得太频繁每次改完配置都要先跑一遍 JacksonUtilTest 看有没有破坏行为。配置这种东西可读性很重要但稳定性更重要。代码注释写一百遍“这里别乱改”不如一个测试用例跑出来红灯管用。我在团队里就把这个测试类当成 JSON 行为契约任何改造都先看它过不过几乎没再出现过因为序列化配置被误改导致的上线事故。