第二十章. common-json 工具类:JsonUtils 的静态封装与泛型实战

第二十章. 脱离 MVC 的舒适区:JsonUtils 手动处理与泛型避坑

摘要:在 Controller 层,Spring MVC 帮我们自动完成了 JSON 与对象的转换。但在 Service 层、定时任务、MQ 消费者或工具类中,我们经常需要手动处理 JSON。本章将跳出源码罗列,通过 4 个真实的业务场景,教你如何使用 RVP 提供·JsonUtils 解决手动序列化、数据库 JSON 字段解析、复杂泛型转换以及动态结构处理难题。

本章学习路径

  1. 认知重构:理解为什么不能直接 new ObjectMapper(),以及 JsonUtils 如何继承全局配置。
  2. 业务场景一:在 日志与缓存 场景中,如何安全地进行序列化(Object -> JSON)。
  3. 业务场景二:在 数据库配置 场景中,如何解析存储在 String 字段里的 JSON(JSON -> Object)。
  4. 业务场景三:在 第三方 API 对接 场景中,如何解决 List<User> 泛型擦除导致的 ClassCastException
  5. 业务场景四:在 Webhook 回调 场景中,如何处理结构未知的动态 JSON。

20.1. 为什么我们需要 JsonUtils?

在 Spring Boot Web 项目中,我们 99% 的时间都在写 Controller:

1
2
3
4
5
// Spring MVC 自动把入参 json 转为 User 对象,把返回值 User 转为 json
@PostMapping("/save")
public User save(@RequestBody User user) {
return user;
}

这让我们产生了一种错觉:JSON 处理是全自动的。

但是,当你遇到以下场景时,Spring MVC 的魔法就消失了:

  1. Redis 缓存:你需要把对象存入 Redis 字符串类型,必须手动转 JSON。
  2. 消息队列(MQ):发送消息时,对象需要序列化;监听消息时,收到的只是 String 或 byte []。
  3. 数据库 JSON 字段:MySQL 表里有一个 extra_config 字段存了 JSON,实体类里却是 String,读取后需要手动转为对象。
  4. 调用第三方 API:使用 HttpClient 请求外部接口,拿到的是一坨 String,你需要把它变成对象。

20.1.1. 严禁手动 new ObjectMapper

当需要手动处理时,新手最容易犯的错误是:

1
2
3
// ❌ 绝对禁止的写法
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user);

为什么禁止?
因为你手动 new 出来的 mapper 是“原厂设置”,它丢失了我们在第 19 章辛辛苦苦配置的所有全局规则

  • 你的 Long 类型 ID 可能会再次出现精度丢失(变 0)。
  • 你的 LocalDateTime 又会变成奇怪的数组格式。

20.1.2. JsonUtils 的核心价值

RVP 的 JsonUtils 采用了 静态代理模式,它持有的底层 OBJECT_MAPPER 正是 Spring 容器中那个 已经配置完美 的 Bean。

1
2
3
4
5
6
7
8
9
10
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class JsonUtils {
// 从Spring容器中取出已经实例化好的ObjectMapper
private static final ObjectMapper OBJECT_MAPPER = SpringUtils.getBean(ObjectMapper.class);

public static ObjectMapper getObjectMapper() {
// 提供对外的访问方法(适合底层二开的时候调用)
return OBJECT_MAPPER;
}
}

结论:无论在项目的任何角落(哪怕是静态方法里),只要涉及 JSON 转换,必须且只能使用 JsonUtils,以保证全局数据格式的一致性。


20.2. 场景一:日志记录与 Redis 缓存(序列化)

业务需求:我们需要在 Service 层记录一条操作日志,或者将用户信息存入 Redis 缓存。

20.2.1. 痛点:繁琐的异常处理

如果直接使用原生 Jackson:

1
2
3
4
5
6
7
try {
String json = objectMapper.writeValueAsString(user);
redisTemplate.opsForValue().set("user:1", json);
} catch (JsonProcessingException e) {
// 每次写代码都要 try-catch,非常痛苦
e.printStackTrace();
}

20.2.2. JsonUtils 解决方案:toJsonString

JsonUtils 帮我们将受检异常(Checked Exception)转化为运行时异常,并自动处理了 null 值。

1
2
3
4
5
6
7
8
// ✅ 优雅写法
// 1. 序列化对象
String json = JsonUtils.toJsonString(user);

// 2. 存入 Redis(模拟)
if (json != null) {
redisUtils.set("user:1", json);
}

关键细节:如果传入的对象是 nulltoJsonString 会返回 null,而不是字符串 "null",这对于数据库存储和 Redis 判空非常友好。


20.3. 场景二:数据库中的 JSON 配置解析(反序列化)

业务需求
sys_user 表中有一个 config 字段(String 类型),存储了用户的个性化配置(如 {"theme":"dark", "notification":true})。我们需要在 Java 代码中读取并解析它。

20.3.1. 基础解析:parseObject

代码演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 假设从数据库读取到的 String
String configJson = "{\"theme\":\"dark\", \"notification\":true}";

// 1. 定义配置类 VO
@Data
public class UserConfigVo {
private String theme;
private Boolean notification;
}

// 2. 使用 JsonUtils 解析
UserConfigVo config = JsonUtils.parseObject(configJson, UserConfigVo.class);

System.out.println("用户主题:" + config.getTheme());

常见错误:如果数据库字段为空字符串 ""nullparseObject 会直接返回 null。在调用 config.getTheme() 之前 务必判空


20.4. 场景三:第三方 API 与泛型擦除(核心难点)

这是二开过程中 最容易翻车 的地方。

业务需求:我们需要调用一个外部的用户中心 API,它返回的数据结构是统一响应体 R<List<UserDto>>

20.4.1. 灾难现场:ClassCastException

新手通常会这样写:

1
2
3
4
5
6
7
8
9
10
11
String responseBody = HttpUtil.get("https://api.example.com/users");

// ❌ 错误写法:试图直接传入 List.class
// 此时 Java 编译器不知道 List 里面装的是 UserDto,它会擦除泛型
R<List<UserDto>> result = JsonUtils.parseObject(responseBody, R.class);

// 代码运行到这里不会报错,但是...
List<UserDto> list = result.getData();
// 💣 崩溃点:当你试图取出元素时
UserDto user = list.get(0);
// 报错:java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to UserDto

为什么?
因为 Java 的泛型是 伪泛型。在运行时,Jackson 根本不知道 R 里面包的是 List,更不知道 List 里面是 UserDto。它只能把 JSON 解析成最通用的 LinkedHashMap

20.4.2. 解决方案:TypeReference

要解决这个问题,我们需要告诉 Jackson 完整的类型结构。JsonUtils 支持 TypeReference 传参。

正确代码

1
2
3
4
5
6
7
8
// ✅ 正确写法:使用匿名内部类保留泛型信息
R<List<UserDto>> result = JsonUtils.parseObject(responseBody,
new TypeReference<R<List<UserDto>>>() { } // 注意这里的一对大括号!
);

// 现在可以安全操作了
UserDto user = result.getData().get(0);
System.out.println(user.getUsername());

原理通俗解
new TypeReference<T>() {} 创建了一个 匿名子类。虽然 Java 会擦除对象的泛型,但会把 类的泛型定义 保留在字节码中。Jackson 通过读取这个子类的签名,终于看清了 R<List<UserDto>> 的全貌。


20.5. 场景四:Webhook 回调与动态 Dict

业务需求:我们系统接收 GitHub 或 微信支付 的 Webhook 回调。这些回调的 JSON 结构非常复杂且不固定,我们懒得为每一个回调事件去写对应的 Java Bean(DTO)。

20.5.1. 痛点:DTO 爆炸

如果为每个可能的 JSON 结构都定义一个类,你的 entity 包会迅速膨胀,且难以维护。

20.5.2. 解决方案:parseMap (Hutool Dict)

RVP 集成了 Hutool 的 Dict(字典类),它比原生的 Map<String, Object> 更强大,支持类型自动转换。

代码演示

1
2
3
4
5
6
7
8
9
10
11
// 假设这是 Webhook 收到的 payload
String payload = "{\"event\":\"push\", \"repository\":{\"name\":\"ruoyi-vue-plus\", \"stars\": 5000}}";

// 1. 直接解析为 Dict
Dict dict = JsonUtils.parseMap(payload);

// 2. 只有你想不到,没有它拿不到
String event = dict.getStr("event"); // 自动转 String
Integer stars = dict.getByPath("repository.stars", Integer.class); // ⭐ 级联获取!

System.out.println("事件:" + event + ", Star数:" + stars);

适用场景

  • 快速原型开发。
  • 解析结构未知的 JSON。
  • 只需要提取 JSON 中某这一两个字段,不想定义整个 DTO。

20.6. 进阶技巧:从复杂 JSON 中只取一个值

有时我们调用第三方接口,对方返回了几千行的 JSON,但我只需要其中深层嵌套的一个 token 字段。定义 DTO 太浪费,用 Map 太麻烦。

解决方案:使用 JsonNode (树模型)。

JsonUtils 虽然主要是对象映射,但也可以暴露底层的 readTree 能力(通过 getObjectMapper())。

1
2
3
4
5
6
7
8
9
String json = "...(几千行的JSON)...";

// 1. 解析为树节点
JsonNode root = JsonUtils.getObjectMapper().readTree(json);

// 2. 像翻文件目录一样找节点
String token = root.path("data").path("auth").path("token").asText();

// 3. 如果节点不存在,asText() 会返回空字符串,不会报空指针

20.7. 本节小结

在这一章,我们跳出了 MVC 的保护圈,学习了手动“操纵”数据的能力。

  1. 统一战线:严禁 new ObjectMapper(),必须用 JsonUtils 以复用全局配置(时区、ID 精度)。
  2. 泛型救星:遇到 List<User>R<Data> 转换报错,请立刻想起 TypeReference 和那对大括号 {}
  3. 动态应对:不想写 DTO 时,DictparseMap 是你的偷懒神器。
  4. 空值安全JsonUtils 的所有方法都对 null 做了防御,不会报空指针,但返回值可能为 null,业务逻辑中要注意检查。