1. 项目背景:为什么工具类也需要测试
1.1 从 Fastjson 迁移到 Jackson 的那次踩坑
两三年前,我参与的项目里 JSON 处理一直用的是 Fastjson,理由很简单:API 用起来太顺手了,公共模块里一个JSON.toJSONString()、JSON.parseObject()就能解决 90% 的场景,团队里几乎没有争议。真正让我下决心换掉的是一次线上事故:某个回调接口在解析报文时,字段值被异常吞掉,而且只在客户生产环境出现,本地怎么复现都复现不出来。排了一整天,最后定位到是 Fastjson 在特定 JDK 版本上对某个特殊字符的解析行为不一致。加上当时安全扫描已经连续多次给 Fastjson 打高危漏洞告警,领导直接拍板全量替换。第二周我做了技术选型对比,Jackson 成了明确首选,原因有三个。
- Spring Boot 默认内置 Jackson,starter-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_TIMESTAMPS
Java 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, Class<T>) | JSON 转普通对象 | 解析 HTTP 入参、MQ 消息体 |
parseObject(String, TypeReference<T>) | JSON 转泛型对象 | 解析 List<Bean>、Map<String, Bean> |
parseArray(String, Class<T>) | 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, TypeReference<T> typeReference) { if (StringUtils.isBlank(json)) { return null; } try { return MAPPER.readValue(json, typeReference); } catch (JsonProcessingException e) { throw new JacksonUtilException("JSON反序列化失败", e); } }调用方这样用:
List<OrderDTO> orders = JacksonUtil.parseObject(json, new TypeReference<List<OrderDTO>>() {});TypeReference 本质上是在编译期把泛型类型记录到一个匿名子类里,Jackson 通过反射拿到完整的List<OrderDTO>类型信息,才能正确创建泛型列表。这里有个细节:TypeReference 的匿名内部类必须在大括号里保持泛型参数,不能写成任何简化形式,否则拿到List<?>之后依旧是一堆 Map。
parseArray方法我单独提供,是因为它在代码里调用频率太高,每次都写 TypeReference 很啰嗦,而且容易手滑丢泛型:
public static <T> List<T> parseArray(String json, Class<T> 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 行为契约,任何改造都先看它过不过,几乎没再出现过因为序列化配置被误改导致的上线事故。