第十九章. common-json 模块:HTTP 交互与 Jackson 序列化深度解析

#第十九章. common-json 模块:HTTP 交互与 Jackson 序列化深度解析

摘要:本章将深入 ruoyi-common-json 模块,剖析前后端数据交互的核心——JSON 序列化与反序列化。我们将从 HTTP 协议入手,解读 RVP 如何通过自定义 Jackson 配置解决“雪花算法 ID 精度丢失”、“日期格式混乱”等经典问题,并掌握 BigNumberSerializer 等核心组件的源码实现。

本章学习路径

  1. 交互原理:理解 HTTP 请求头与 JSON 数据流的转换机制。
  2. 架构剖析:掌握 JacksonConfig 如何接管 Spring Boot 默认配置。
  3. 精度攻坚:彻底解决 Java Long 类型在前端 JavaScript 中的精度丢失问题。
  4. 全局规范:统一全系统的日期格式与类型转换策略。
  5. 实战应用:熟练使用 Jackson 注解控制字段的序列化行为。

19.1. HTTP 交互基础与数据载体

在深入源码之前,我们需要先明确前后端交互的数据形态。在 RVP 的前后端分离架构中,后端不再返回 HTML 页面,而是返回结构化的数据,JSON(JavaScript Object Notation)因其轻量级和良好的可读性,成为了事实上的标准数据载体。

19.1.1. 浏览器开发者工具(DevTools)中的网络请求分析

我们可以通过浏览器的开发者工具(F12)直观地观察这一交互过程。在“网络(Network)”面板中,点击任意一个业务请求(如登录或获取用户列表),重点关注“标头(Headers)”部分。

请求标头(Request Headers)

客户端(前端)通过 Accept 标头告诉服务器:“我希望接收什么格式的数据”。

  • Accept: application/json, text/plain, */*

这意味着前端期望后端返回 JSON 格式的数据。

响应标头(Response Headers)

服务器(后端)通过 Content-Type 标头告诉客户端:“我实际返回的是什么格式的数据”。

  • Content-Type: application/json;charset=UTF-8

这表明后端响应的是 JSON 格式的数据,编码为 UTF-8。

请求负载与响应预览

  • GET 请求:通常没有请求体(Body),参数直接拼接在 URL 后面(如 ?pageNum=1&pageSize=10)。
  • POST/PUT 请求:参数通常包含在请求体中。在“载荷(Payload)”选项卡中,我们可以看到发送给后端的 JSON 字符串。
  • 响应预览:在“预览(Preview)”选项卡中,浏览器会自动将后端返回的 JSON 字符串格式化为可折叠的对象结构,方便查看。

19.1.2. 序列化与反序列化的技术定义

在 RVP 系统中,数据流转涉及两个核心过程,这两个过程由 Jackson 库在底层自动完成:

1. 反序列化(Deserialization)

  • 方向:前端 -> 后端
  • 定义:将 HTTP 请求体中的 JSON 字符串 转换为 Java 中的 对象(如 LoginBodySysUser)。
  • 场景:当我们在 Controller 方法参数上使用 @RequestBody 注解时,SpringMVC 会调用 Jackson 将前端传来的 JSON 串解析并填充到 Java Bean 中。

2. 序列化(Serialization)

  • 方向:后端 -> 前端
  • 定义:将 Java 中的 对象 转换为 JSON 字符串
  • 场景:当 Controller 方法返回 R<T>TableDataInfo 对象时,SpringMVC 会调用 Jackson 将这些内存中的对象转换为 JSON 字符串写入 HTTP 响应流。

19.1.3. 本节小结

  • 核心概念:前端通过 Accept 请求 JSON,后端通过 Content-Type 响应 JSON。
  • 关键动作:输入是反序列化(JSON -> Object),输出是序列化(Object -> JSON)。
  • 调试技巧:利用浏览器 DevTools 的 Network 面板可以完整追踪数据的传输形态。

19.2. ruoyi-common-json 模块的配置架构

在上一节中,我们了解了数据交互的基础。但在实际的企业级开发中,Spring Boot 默认的 Jackson 配置往往无法满足复杂的需求(如时区处理、大数精度等)。因此,RVP 独立封装了 ruoyi-common-json 模块,通过自定义配置接管了 Jackson 的行为。

19.2.1. 模块定位与依赖分析

ruoyi-common-json 模块的设计目标是提供一套“开箱即用”且“高度定制”的序列化方案。

核心依赖

该模块在 pom.xml 中引入了 Jackson 的核心组件:

  • jackson-databind:Jackson 的核心库,负责数据绑定。
  • jackson-datatype-jsr310:专门用于支持 Java 8 的新日期时间 API(如 LocalDateTime),这是现代 Java 开发的标准。

19.2.2. JacksonConfig 类的自动装配原理

RVP 通过 JacksonConfig 类实现了对 Jackson 的全局配置。为了确保这些配置生效,RVP 采用了一种“覆盖式”的加载策略。

文件路径ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java

我们来看其类定义与自动装配注解:

1
2
3
4
5
6
7
8
9
10
11
12
package org.dromara.common.json.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;

@Slf4j
@AutoConfiguration(before = JacksonAutoConfiguration.class) // 关键点:在官方配置之前加载
public class JacksonConfig {
// ... Bean 定义 ...
}

代码解析

  • @AutoConfiguration:这是 Spring Boot 2.7+ (对应 RVP 5.x) 的自动配置注解,等同于旧版本的 @Configuration 配合 spring.factories
  • before = JacksonAutoConfiguration.class:这是最关键的配置。它告诉 Spring 容器:“请在加载 Spring 官方的 Jackson 配置 之前,先加载本类的配置”。
  • 设计意图:这确保了 RVP 定义的 ModuleCustomizer 能够被优先注册,从而主导 Jackson 的初始化过程,防止被官方默认配置覆盖或忽略。

19.2.3. 时区配置策略

在跨国或跨时区应用中,时间的序列化非常容易出错。RVP 在 JacksonConfig 中进行了统一的时区设置。

1
2
3
4
5
6
7
8
9
@Bean
public Jackson2ObjectMapperBuilderCustomizer customizer() {
return builder -> {
// 使用系统默认时区
builder.timeZone(TimeZone.getDefault());
log.info("初始化 jackson 配置");
};
}

代码解析

  • Jackson2ObjectMapperBuilderCustomizer:这是一个函数式接口,用于回调自定义 Jackson 构建器。
  • builder.timeZone(TimeZone.getDefault()):将 JSON 处理的时区设置为服务器操作系统的默认时区。
  • 注意:这意味着如果你的服务器部署在 UTC+8 时区,Jackson 在处理 Date 类型时会默认基于该时区进行转换。这保证了服务器日志时间与接口返回时间的一致性。

19.2.4. 本节小结

  • 加载机制:使用 @AutoConfiguration(before = ...) 确保 RVP 的配置优先级高于 Spring Boot 默认配置。
  • 时区策略:默认跟随服务器系统时区,避免了因为时区不一致导致的时间偏差。
  • 模块化:通过独立模块管理 JSON 配置,实现了业务逻辑与基础架构的解耦。

19.3. 数值精度丢失问题与 BigNumberSerializer 实现

在上一节中,我们建立了 Jackson 的基础配置环境。但在实际开发中,开发者经常会遇到一个诡异的现象:数据库里的 ID 是 173489...123,但前端接收到的 ID 却变成了 173489...100,最后几位莫名其妙变成了 0。本节将深入探讨这一问题的根源及其在 RVP 中的解决方案。

19.3.1. 问题根源:Java 与 JavaScript 的精度差距

这个问题的本质在于 Java 和 JavaScript 对数字存储格式的差异:

Java (Long)

  • 使用 64 位有符号整数。
  • 最大值为 2^{63}-1,约为 9.22 \times 10^{18}。
  • 雪花算法生成的 ID 通常在 10^{18} 到 10^{19} 之间。

JavaScript (Number)

  • 遵循 IEEE 754 标准,使用 64 位双精度浮点数。
  • 最大安全整数(MAX_SAFE_INTEGER) 为 2^{53}-1,即 9007199254740991
  • 一旦数值超过这个安全范围,JavaScript 就会发生精度丢失,表现为低位数字变为 0。

结论:当后端的 Long 类型 ID 超过 JS 的安全范围时,必须将其转换为 字符串(String) 传输给前端,否则 ID 将不可用。

19.3.2. 源码解析:BigNumberSerializer

为了解决这个问题,RVP 自定义了一个序列化器 BigNumberSerializer,它能够智能判断数值是否越界,并仅在必要时将其转换为字符串。

文件路径ruoyi-common-json/src/main/java/org/dromara/common/json/handler/BigNumberSerializer.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@JacksonStdImpl
public class BigNumberSerializer extends NumberSerializer {

/**
* JS 最大安全整数:9007199254740991L
*/
private static final long MAX_SAFE_INTEGER = 9007199254740991L;
/**
* JS 最小安全整数:-9007199254740991L
*/
private static final long MIN_SAFE_INTEGER = -9007199254740991L;

public static final BigNumberSerializer INSTANCE = new BigNumberSerializer(Number.class);

public BigNumberSerializer(Class<? extends Number> rawType) {
super(rawType);
}

@Override
public void serialize(Number value, JsonGenerator gen, SerializerProvider provider) throws IOException {
// 核心逻辑:判断是否超出 JS 安全范围
if (value.longValue() > MIN_SAFE_INTEGER && value.longValue() < MAX_SAFE_INTEGER) {
// 安全范围内,按数字输出(不带双引号)
super.serialize(value, gen, provider);
} else {
// 超出范围,序列化为字符串(带双引号)
gen.writeString(value.toString());
}
}
}

代码解析

  1. 阈值定义:直接硬编码了 JS 的安全整数范围 ±9007199254740991L
  2. 智能判断serialize 方法在每次序列化数字时被调用。它检查当前数值是否在安全区间内。
  3. 降级处理
  • 在范围内:调用 super.serialize,输出标准数字 JSON(例如 123)。
  • 超出范围:调用 gen.writeString,输出字符串 JSON(例如 "173489...123")。

这种设计非常精妙,它既解决了精度问题,又避免了将所有数字都变成字符串,保持了数据类型的语义准确性。

19.3.3. 全局注册策略

有了序列化器,还需要将其注册到 Jackson 中,使其对所有 LongBigInteger 类型生效。我们回到 JacksonConfig 类。

文件路径ruoyi-common-json/src/main/java/org/dromara/common/json/config/JacksonConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public Module registerJavaTimeModule() {
JavaTimeModule javaTimeModule = new JavaTimeModule();

// 注册 Long 类型的序列化器(对象类型)
javaTimeModule.addSerializer(Long.class, BigNumberSerializer.INSTANCE);
// 注册 long 类型的序列化器(基本类型)
javaTimeModule.addSerializer(Long.TYPE, BigNumberSerializer.INSTANCE);
// 注册 BigInteger 类型的序列化器
javaTimeModule.addSerializer(BigInteger.class, BigNumberSerializer.INSTANCE);

// ... 其他配置 ...
return javaTimeModule;
}

配置说明

  • addSerializer:该方法将我们的 BigNumberSerializer 绑定到特定的 Java 类型上。
  • 覆盖范围:不仅覆盖了包装类 LongBigInteger,还显式覆盖了基本类型 long (Long.TYPE)。这意味着项目中任何位置出现的长整数,只要经过 Jackson 序列化,都会受到保护,彻底杜绝了前端精度丢失的问题。

19.3.4. 本节小结

  • 核心痛点:前端 JavaScript 无法精确表示超过 53 位的整数。
  • 解决方案:后端识别大数并自动转换为字符串。
  • 实现组件BigNumberSerializer 负责范围判断与类型转换,JacksonConfig 负责全局注册。
  • 开发者收益:无需在每个 ID 字段上添加注解,框架层面自动兜底,极大减少了 Bug 产生的可能性。

19.4. 全局类型转换与格式化配置

在上一节中,我们通过自定义序列化器完美解决了 Long 类型在前端的精度丢失问题。但在实际的企业级开发中,除了 ID 之外,金融系统中的 金额BigDecimal)和业务系统中的 时间LocalDateTime)也是数据格式混乱的“重灾区”。

本节我们将深入 JacksonConfig,学习如何通过全局配置实现这两类核心数据的标准化处理,彻底消除前后端对接时的格式摩擦。

19.4.1. BigDecimal 的全精度保留

在涉及金额计算的场景中,我们必须遵循一个铁律:后端存储和计算必须使用 BigDecimal,绝不允许使用 Double。然而,即便后端守住了底线,数据传输到前端 JavaScript 环境时,依然面临经典的浮点数陷阱(如 0.1 + 0.2 != 0.3)。

为了保证金额在展示层面的绝对精确,RVP 采取了“一刀切”的策略:将所有 BigDecimal 统一序列化为字符串

源码实现

回到 JacksonConfig.javaregisterJavaTimeModule 方法中:

1
2
// 注册 BigDecimal 类型的序列化器:将数值转为 String 输出
javaTimeModule.addSerializer(BigDecimal.class, ToStringSerializer.instance);

为什么这样设计?

  1. 规避计算误差:前端接收到字符串 "100.05" 后,如果仅做展示,没有任何精度风险。如果前端需要进行金额计算,可以使用 decimal.jsbig.js 等库将字符串转换为高精度对象,从而避开原生 JS 的浮点数缺陷。
  2. 保持语义格式:数据库中存储的 100.00 代表了精度为 2 的金额。如果直接转为数字类型,前端可能会自动抹除末尾的零显示为 100,导致丢失了“精确到分”的业务语义。字符串化则能原样保留这种格式。

19.4.2. LocalDateTime 与 Date 的统一格式化

Java 8 引入的 LocalDateTime 是目前推荐的时间类型。但在默认情况下,Jackson 可能会将其序列化为复杂的数组格式(如 [2023, 12, 14, 10, 0, 0]),这对于前端解析极其不友好。我们需要将全系统的时间格式统一收敛为标准字符串 yyyy-MM-dd HH:mm:ss

源码实现

1
2
3
4
5
6
7
8
9
10
11
// 1. 定义标准时间格式
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

// 2. 配置 LocalDateTime 的序列化(后端 -> 前端)
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(formatter));

// 3. 配置 LocalDateTime 的反序列化(前端 -> 后端)
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(formatter));

// 4. 兼容旧版 Date 类型的反序列化
javaTimeModule.addDeserializer(Date.class, new CustomDateDeserializer());

关键点解析

  • 双向绑定:我们不仅配置了 Serializer(出参格式化),也配置了 Deserializer(入参解析)。这意味着前端在调用接口传参时,必须严格遵守 yyyy-MM-dd HH:mm:ss 格式,否则后端将无法解析并抛出异常。
  • 历史兼容:虽然我们强烈推荐使用 LocalDateTime,但为了兼容部分遗留代码或第三方库中可能存在的 java.util.Date,RVP 贴心地注册了 CustomDateDeserializer 作为兜底方案。

19.4.3. 本节小结

  • 金额防坑BigDecimal 强制转为字符串,确保金融数据的展示精度,避免 JS 浮点计算误差。
  • 时间规范LocalDateTime 强制统一为 yyyy-MM-dd HH:mm:ss,杜绝时间戳与格式化字符串混用的乱象。
  • 零侵入性:这些配置在 Spring 容器启动时自动加载,对所有 Controller 接口生效,开发者无需手动处理。

19.5. application.yml 中的 Jackson 属性详解

在上一节中,我们通过 Java 代码解决了特定类型的映射问题。但在实际运行中,Jackson 作为一个通用的 JSON 处理库,还有许多行为特性需要控制。RVP 在 application.yml 中配置了一些关键属性,用于平衡系统的 性能容错性

19.5.1. 配置文件解析

让我们打开 ruoyi-admin/src/main/resources/application.yml,定位到 spring.jackson 节点:

1
2
3
4
5
6
7
8
9
10
11
12
spring:
jackson:
# 日期格式化(主要作为 Date 类型的兜底格式)
date-format: yyyy-MM-dd HH:mm:ss
serialization:
# 格式化输出:关闭(减少传输体积)
indent_output: false
# 忽略无法转换的对象:关闭(允许空 Bean)
fail_on_empty_beans: false
deserialization:
# 允许对象忽略 json 中不存在的属性:关闭(核心兼容性配置)
fail_on_unknown_properties: false

19.5.2. 核心属性详解

1. serialization.indent_output: false

  • 含义:是否对输出的 JSON 进行缩进和换行(即“美化”)。
  • 配置原因设置为 false。虽然美化后的 JSON 易于人类阅读,但在生产环境中,多余的空格和换行符会显著增加网络传输的流量(通常增加 20%-30% 的体积)。我们应当依赖浏览器的 DevTools 或 Postman 来自动格式化查看,而不需要后端消耗带宽来传输这些格式字符。

2. serialization.fail_on_empty_beans: false

  • 含义:当序列化一个没有公共字段(public fields)或 Getter 方法的空对象时,是否抛出异常。
  • 配置原因设置为 false。在某些动态业务场景或反射调用中,我们可能会临时返回一个 new Object() 或者尚未定义字段的 VO 对象。开启此配置可以避免系统因此直接报错 JsonMappingException,而是优雅地返回一个空的 JSON 对象 {},保证接口不崩溃。

3. deserialization.fail_on_unknown_properties: false

  • 含义:当反序列化时,如果 JSON 中包含了 Java 对象中不存在的属性,是否抛出异常。
  • 配置原因设置为 false。这是保障系统健壮性的 核心配置。在前后端分离架构中,前端的发版频率通常快于后端。如果前端在请求体中携带了一些新增加的字段(例如为了下个版本预埋的参数),而后端实体类尚未更新,若开启此选项会导致接口直接报错 HTTP 500。关闭此选项后,Jackson 会默默忽略掉那些多余的字段,极大提升了新旧版本的兼容性。

19.5.3. 本节小结

  • 性能优先:关闭缩进输出 (indent_output),节省宝贵的网络带宽。
  • 鲁棒性优先:允许空对象序列化,允许未知属性反序列化,增强系统的容错能力和版本兼容性。
  • 体系互补:YAML 配置主要控制 Jackson 的 行为特性,而 JacksonConfig 主要控制 类型映射,两者共同构成了 RVP 稳固的 JSON 处理体系。

19.6. 常用 Jackson 注解与字段控制

尽管全局配置解决了 90% 的通用问题,但在具体的业务场景中,我们经常需要对某个特定字段进行特殊处理(如重命名、隐藏、脱敏等)。Jackson 提供了一套强大的注解体系来实现这种细粒度的控制。

19.6.1. 属性重命名:@JsonProperty

场景:你需要对接一个第三方 API,对方返回的 JSON 字段是蛇形命名(下划线,如 access_token),而 RVP 的代码规范严格要求 Java 属性必须是驼峰命名(accessToken)。

解决方案:使用 @JsonProperty 建立映射关系。

1
2
3
4
5
6
7
8
9
@Data
public class TokenVo {
// 序列化时输出为 "access_token"
// 反序列化时接收 "access_token"
@JsonProperty("access_token")
private String accessToken;

// ...
}

作用:该注解的优先级高于全局配置,能够打破默认的命名规则,实现字段名的自定义映射。

19.6.2. 字段忽略:@JsonIgnore

场景:用户实体类 SysUser 中包含 password 字段。在“查询用户信息”接口中,如果直接返回 SysUser 对象,加密后的密码哈希值会被一并序列化传输给前端,这会造成极大的安全隐患。

解决方案:在敏感字段上添加 @JsonIgnore

1
2
3
4
5
6
7
public class SysUser extends BaseEntity {

@JsonIgnore // 核心:序列化和反序列化时均忽略此字段
private String password;

// ...
}

进阶技巧@JsonIgnore 是双向忽略。如果你的业务需求是“只进不出”(例如:允许用户在注册时提交密码,但在查询个人信息时绝不返回密码),请使用 @JsonProperty(access = Access.WRITE_ONLY)

19.6.3. 空值处理:@JsonInclude

场景:某个统计对象包含 50 个字段,但在某些特定的查询接口中,只有 3 个字段有值,其余 47 个字段都是 null。如果全部返回,JSON 体积会非常臃肿,且前端处理起来也充满干扰。

解决方案:使用 @JsonInclude 过滤空值。

1
2
3
4
5
6
7
8
@Data
@JsonInclude(JsonInclude.Include.NON_NULL) // 策略:仅当字段不为 null 时才序列化
public class SimpleUserVo {
private String username;

// 如果 email 为 null,生成的 JSON 中将完全不包含 "email" 这个 key
private String email;
}

收益:有效减少了网络传输的数据量,同时让前端接收到的数据更加清爽专注。

19.6.4. 自定义序列化:@JsonSerialize

场景:回顾第 18 章中提到的数据脱敏需求。我们需要在序列化时将手机号中间四位替换为星号(如 138****1234),这种复杂的逻辑无法通过简单的配置实现。

解决方案:指定自定义的序列化器类。

1
2
3
// 使用自定义逻辑 PhoneSerialize 处理该字段
@JsonSerialize(using = PhoneSerialize.class)
private String phonenumber;

原理:Jackson 在处理该字段时,会跳过默认逻辑,实例化 PhoneSerialize 类并调用其中的 serialize 方法,从而实现任意复杂的输出逻辑。


19.6.5. 本节小结

  • @JsonProperty:解决字段名不一致的问题,是 对接外部系统 的神器。
  • @JsonIgnore:保障敏感数据不泄露,构建系统的 安全防线
  • @JsonInclude:剔除无效数据,为 JSON 包 瘦身,优化性能。
  • @JsonSerialize:接管序列化逻辑,实现脱敏或特殊格式转换,提供 深度定制 能力。

19.7. 本章总结与 Jackson 速查

19.7.1. 场景化代码模版

遇到以下 3 种 Jackson 处理场景时,请直接 Copy 下方的标准代码模版:

1. 场景一:字段名不匹配(对接第三方)

  • 需求:Java 属性为 userName,但第三方接口要求传 user_name
  • 方案:使用 @JsonProperty
  • 代码
1
2
@JsonProperty("user_name")
private String userName;

2. 场景二:敏感字段保护(密码/盐值)

  • 需求:允许前端传入密码修改,但查询用户信息时绝不能返回密码。
  • 方案:使用 @JsonProperty(access = Access.WRITE_ONLY)
  • 代码
1
2
@JsonProperty(access = Access.WRITE_ONLY)
private String password;

3. 场景三:大对象瘦身(去除 Null 值)

  • 需求:VO 对象字段众多,希望只返回有值的字段,节省流量。
  • 方案:类级别添加 @JsonInclude
  • 代码
1
2
@JsonInclude(JsonInclude.Include.NON_NULL)
public class BigDataVo { ... }

19.7.2. 核心避坑指南

  1. 现象:前端金额显示为 100 而不是 100.00

    • 原因BigDecimal 被转为了数字类型,小数位末尾的 0 被自动抹除。
    • 对策:检查 JacksonConfig 是否正确配置了 ToStringSerializer
  2. 现象:接口报错 Unrecognized field "xxx"

    • 原因:前端传递了后端实体类中不存在的字段,且 YAML 中未关闭检测。
    • 对策:确保 spring.jackson.deserialization.fail_on_unknown_properties 设置为 false
  3. 现象:时间显示为 [2023, 10, 1] 数组格式。

    • 原因:使用了 LocalDateTime 但未注册 JavaTimeModule 或未配置全局 Formatter。
    • 对策:检查全局配置类是否生效,或字段上是否漏加了 @JsonFormat