Jackson @JsonSerialize 注解详解:自定义序列化实战指南
1. 项目概述:为什么我们需要关注 @JsonSerialize
在 Java 后端开发中,尤其是构建 RESTful API 时,对象与 JSON 之间的序列化与反序列化是日常操作。Jackson 作为事实上的标准库,其默认行为已经足够智能,能处理大部分常见场景。但总有那么一些“特殊”需求,让默认行为显得力不从心。比如,你需要将一个BigDecimal类型的金额字段,在序列化为 JSON 时,自动格式化为保留两位小数的字符串;或者,你需要将一个Date类型的日期,输出为特定的“yyyy-MM-dd HH:mm:ss”格式,而不是默认的长整型时间戳;又或者,你希望某个枚举类型字段,序列化时输出的是其description属性,而不是name()。
这些需求,就是@JsonSerialize注解的用武之地。它不是一个你每天都会用到的注解,但一旦遇到上述场景,它就是那个能让你优雅、精准地控制序列化行为的“手术刀”。很多开发者对它的理解停留在“用来格式化日期”,这大大低估了它的能力。实际上,它是一个通往 Jackson 强大自定义序列化能力的入口。通过它,你可以告诉 Jackson:“这个字段,别用你默认的那套逻辑,按我写的这个‘转换器’来序列化。” 这种声明式的配置方式,将序列化逻辑与业务模型紧密绑定,使得代码意图清晰,且易于维护。
2. @JsonSerialize 注解的核心参数与使用场景
@JsonSerialize注解主要作用于类的字段(Field)或 Getter 方法上。它的核心价值在于通过指定一个自定义的序列化器(JsonSerializer的子类),来完全覆盖 Jackson 对该字段的默认序列化逻辑。我们先来拆解它的几个关键参数,理解每个参数背后的设计意图。
2.1 核心参数详解
@JsonSerialize提供了多个参数,但最常用、最核心的是using和as。
1.using:指定自定义序列化器这是@JsonSerialize的灵魂参数。它的值是一个Class<? extends JsonSerializer>类型,即你需要传入一个自定义序列化器的类。
@JsonSerialize(using = CustomBigDecimalSerializer.class) private BigDecimal amount;当你这样声明时,Jackson 在序列化amount字段时,会完全忽略其BigDecimal类型自带的序列化逻辑,转而实例化你提供的CustomBigDecimalSerializer,并调用它的serialize方法。这给了你最大的自由度,你可以在序列化器里写任何逻辑:数据转换、格式调整、甚至根据条件决定是否序列化该字段。
2.as:指定序列化时的目标类型这个参数用于进行简单的类型转换。它告诉 Jackson:“请把这个字段当作as指定的类型来序列化。” Jackson 会尝试找到或使用该目标类型的标准序列化器。
@JsonSerialize(as = String.class) private Object polymorphicField;在上面的例子中,无论polymorphicField在运行时是何种复杂对象,Jackson 都会尝试调用其toString()方法,将其序列化为字符串。这适用于一些简单的、已知的转换,比如将枚举序列化为字符串(虽然枚举默认就是如此),或者强制将某个对象以字符串形式输出。它的能力比using弱,但配置更简单。
3.contentUsing与keyUsing:处理容器内部元素这两个参数专门用于处理Map和集合(List,Set等)类型。
contentUsing: 指定集合中值(value)元素的序列化器。keyUsing: 指定Map中键(key)的序列化器。
// 一个Map,其键是自定义的KeyObject,值是一个BigDecimal列表 @JsonSerialize(keyUsing = CustomKeySerializer.class, contentUsing = CustomBigDecimalSerializer.class) private Map<KeyObject, List<BigDecimal>> complexMap;这个功能非常强大。想象一下,你有一个Map<LocalDateTime, BigDecimal>,你想把键LocalDateTime格式化为“HH:mm”字符串,同时把值BigDecimal格式化为百分比字符串。通过组合keyUsing和contentUsing,你可以分别对键和值应用不同的自定义序列化逻辑,而无需将整个Map转换成一个中间对象。
4.nullsUsing:自定义 null 值的序列化行为默认情况下,Jackson 对于值为null的字段,在序列化时会直接忽略(取决于全局配置)。但有时你可能希望将null序列化为一个特定的值,比如空字符串""、数字0或者一个特殊的标记{"value": null}。
@JsonSerialize(nullsUsing = NullToEmptyStringSerializer.class) private String optionalField;通过nullsUsing,你可以为null值指定一个专门的序列化器,实现更精细的空值处理策略。
2.2 典型应用场景对比
为了更直观地理解,我们通过一个表格来对比不同场景下,使用@JsonSerialize与不使用(或使用其他方式)的差异:
| 场景描述 | 不使用 @JsonSerialize(或使用其他方式) | 使用 @JsonSerialize 方案 | 优势分析 |
|---|---|---|---|
金额格式化:BigDecimal amount = 100.5,需输出"100.50"。 | 1. 在 DTO 中定义String类型的amountStr字段,在业务代码中手动格式化并赋值。2. 在 Getter 方法内进行格式化。 | @JsonSerialize(using=MoneySerializer.class),在MoneySerializer中统一格式化逻辑。 | 逻辑内聚:格式化规则与字段定义在一起,清晰且易于复用。避免了业务代码污染和 Getter 方法职责过重。 |
复杂对象简化:一个User对象,序列化时只输出id和name。 | 1. 定义一个专用的UserSimpleVO。2. 使用 @JsonIgnore忽略其他字段,但需忽略的字段多时配置繁琐。 | @JsonSerialize(using=UserSimpleSerializer.class),在序列化器中手动构造只包含id和name的 JSON 节点。 | 灵活精准:对于一次性或非常规的简化需求,无需创建大量 VO 类。序列化器可以访问原对象的所有属性,按需组装。 |
枚举自定义输出:StatusEnum有code和desc属性,希望输出desc。 | 在枚举类上实现自定义的序列化/反序列化逻辑,或使用@JsonValue。 | @JsonSerialize(using=EnumDescSerializer.class) | 解耦与复用:序列化逻辑与枚举定义解耦。同一个枚举,在不同 API 中可以通过不同的序列化器输出不同内容(如一个接口输出code,另一个输出desc)。 |
处理容器内元素:List<BigDecimal>需统一格式化为百分比。 | 遍历列表,在业务层或 DTO 层转换成一个新的List<String>。 | @JsonSerialize(contentUsing=PercentageSerializer.class) | 声明式配置:在字段声明处即指定了容器内元素的转换规则,代码意图明确,且转换对上层业务透明。 |
从对比可以看出,@JsonSerialize的核心优势在于“声明式”和“精准控制”。它将序列化这一横切关注点,以一种高内聚的方式绑定在数据模型上,特别适合处理那些具有特定业务含义的格式化需求,或者默认序列化行为无法满足的复杂场景。
3. 手把手实现一个自定义序列化器
理解了参数和场景,我们来实战创建一个自定义序列化器。我们以实现一个经典的“金额格式化”场景为例:将BigDecimal类型的金额,序列化为保留两位小数的字符串,并添加千位分隔符。
3.1 第一步:创建自定义序列化器类
自定义序列化器必须继承com.fasterxml.jackson.databind.JsonSerializer<T>这个泛型抽象类,其中T是你要序列化的原始 Java 类型。
import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; import java.io.IOException; import java.math.BigDecimal; import java.text.DecimalFormat; /** * 自定义BigDecimal序列化器:格式化为带千位分隔符和两位小数的字符串。 */ public class MoneySerializer extends JsonSerializer<BigDecimal> { // 定义格式化器。注意:DecimalFormat非线程安全,但Jackson会为每个线程创建序列化器实例,所以这里可以定义为实例变量。 // 更稳妥的做法是使用ThreadLocal,但在此简单场景下,实例变量已足够。 private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00"); @Override public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { // 处理null值:可以选择写入null,或者写入空字符串等,这里我们写入null。 gen.writeNull(); return; } // 使用格式化器进行格式化 String formattedMoney = MONEY_FORMAT.format(value); // 将格式化后的字符串写入JSON生成器 gen.writeString(formattedMoney); } }代码解读:
- 继承与泛型:
extends JsonSerializer<BigDecimal>表明这个序列化器专门处理BigDecimal类型。 serialize方法:这是必须实现的核心方法。三个参数分别是:value: 待序列化的字段值。gen:JsonGenerator对象,用于向输出流写入JSON内容。你可以调用它的writeString,writeNumber,writeStartObject等方法。serializers:SerializerProvider,提供了访问当前序列化上下文和查找其他序列化器的能力,在复杂序列化中会用到。
- 格式化逻辑:我们使用
DecimalFormat来执行格式化。#,##0.00这个模式表示:整数部分使用千位分隔符,小数部分强制保留两位。 - 空值处理:这是一个非常重要的细节。在序列化器中主动处理
null值是一个好习惯。这里我们选择写入 JSON null。你也可以根据业务需要写入""或"0.00"。
3.2 第二步:在模型字段上应用注解
创建好序列化器后,就可以在实体类或 DTO 的字段上使用@JsonSerialize注解了。
import com.fasterxml.jackson.databind.annotation.JsonSerialize; public class OrderDTO { private String orderId; @JsonSerialize(using = MoneySerializer.class) private BigDecimal totalAmount; // 省略构造函数、getter、setter }3.3 第三步:测试与验证
编写一个简单的测试来验证效果:
import com.fasterxml.jackson.databind.ObjectMapper; public class SerializeTest { public static void main(String[] args) throws Exception { ObjectMapper mapper = new ObjectMapper(); OrderDTO order = new OrderDTO(); order.setOrderId("ORD123456"); order.setTotalAmount(new BigDecimal("1234567.891")); String json = mapper.writeValueAsString(order); System.out.println(json); // 输出: {"orderId":"ORD123456","totalAmount":"1,234,567.89"} // 测试null值 order.setTotalAmount(null); json = mapper.writeValueAsString(order); System.out.println(json); // 输出: {"orderId":"ORD123456","totalAmount":null} } }实操心得与避坑指南:
- 序列化器无状态与线程安全:理论上,
JsonSerializer实例在多个线程间可能被重用。虽然 Jackson 默认会为每个线程创建新实例(JsonSerializer通常被当作无状态对象使用),但如果你在序列化器中使用了像SimpleDateFormat这样的非线程安全类作为成员变量,就必须用ThreadLocal包装或每次调用时创建新实例。上面的DecimalFormat同理,我们将其定义为static final是基于“每个线程有自己的序列化器实例”的常见假设,但在极端情况下(如配置了特殊的SerializerProvider),仍可能存在风险。最安全的做法是避免使用非线程安全的实例变量,或者在serialize方法内部创建格式化工具。 - 注意循环引用:如果你的序列化器在处理对象 A 时,又通过
ObjectMapper或SerializerProvider去序列化另一个引用了 A 的对象 B,可能会导致栈溢出。在自定义序列化器中处理复杂对象图时要格外小心。 - 与
@JsonFormat的区别:对于简单的日期、数字格式化,优先考虑使用@JsonFormat注解。例如@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = “yyyy-MM-dd”)。@JsonFormat是声明式的配置,Jackson 内部有对应的标准序列化器来处理,比自定义序列化器更轻量、性能更好。@JsonSerialize是在@JsonFormat无法满足需求时的进阶选择。
4. 深入原理:Jackson 如何处理 @JsonSerialize
要真正用好@JsonSerialize,有必要了解一下 Jackson 在背后做了什么。这个过程涉及到 Jackson 的核心——ObjectMapper和SerializationConfig。
当ObjectMapper开始序列化一个对象时,它会为对象的每个属性寻找一个合适的JsonSerializer。这个寻找过程,称为“序列化器解析(Serializer Resolution)”。
- 注解扫描:Jackson 首先检查该属性(字段或 getter 方法)上是否有
@JsonSerialize注解。如果有,并且using参数被指定,Jackson 会直接使用这个指定的序列化器类,并跳过后续所有默认的查找逻辑。这是优先级最高的方式。 - 类型查找:如果没有
@JsonSerialize(using=…),Jackson 会查看@JsonSerialize(as=…)。如果指定了as,它会将属性的类型视为as指定的类型,然后去查找该类型的标准序列化器。 - 默认序列化器查找:如果以上都没有,Jackson 会进入默认的查找流程:
- 检查该属性的运行时类型(
JavaType)。 - 在
SerializationConfig中注册的“序列化器提供者(Serializers)”里查找是否有匹配该类型的自定义序列化器(通过module注册的)。 - 如果找不到,则使用 Jackson 内建的标准序列化器(如
StringSerializer,NumberSerializer,BeanSerializer等)。
- 检查该属性的运行时类型(
对于contentUsing和keyUsing,逻辑是类似的,只不过查找和应用序列化器的目标从字段本身,变成了字段所代表的Map或集合的内容元素或键。Jackson 在解析容器类型的序列化器时,会递归地为容器内的元素类型进行序列化器解析。
一个重要的底层机制是SerializerProvider。你在自定义序列化器的serialize方法中收到的SerializerProvider参数,就是用来处理这种递归查找的。如果你在自定义序列化器中需要序列化一个嵌套对象,你不应该自己 new 一个ObjectMapper,而应该通过serializers.findValueSerializer()方法来获取该嵌套对象对应的序列化器,然后使用它。这保证了全局配置(如日期格式、视图过滤等)的一致性,也避免了创建多余的开销。
// 在自定义序列化器内部,正确序列化一个嵌套对象的方式 public void serialize(MyComplexValue value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); gen.writeFieldName("nested"); // 使用 serializers 来查找并序列化嵌套对象 JsonSerializer<Object> nestedSerializer = serializers.findValueSerializer(value.getNested().getClass()); nestedSerializer.serialize(value.getNested(), gen, serializers); gen.writeEndObject(); }理解这个流程,你就明白了为什么@JsonSerialize(using=…)的优先级如此之高,以及如何在你自己的序列化器中与 Jackson 框架进行“正确”的交互。
5. 高级应用与周边生态集成
掌握了基础用法和原理后,我们可以探索一些更高级的应用场景,以及如何与 Spring Boot 等框架优雅集成。
5.1 组合注解与元注解
如果你发现某个自定义序列化器在多个项目、多个类中反复使用,每次都写@JsonSerialize(using = MySerializer.class)会显得冗余。你可以利用 Spring 的元注解功能,创建一个组合注解。
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import java.lang.annotation.*; /** * 元注解:金额格式化注解。 */ @Target({ElementType.FIELD, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotationsInside // Jackson 提供的注解,表明这是一个Jackson注解的容器 @JsonSerialize(using = MoneySerializer.class) public @interface MoneyFormat { // 可以在这里定义一些属性,例如是否显示货币符号,然后传递给序列化器 // String currencySymbol() default "¥"; }然后,你就可以在字段上使用这个简洁的注解了:
public class ProductDTO { @MoneyFormat private BigDecimal price; }这种方式极大地提升了代码的简洁性和声明性,将技术细节隐藏在自定义注解背后。
5.2 在 Spring Boot 中全局注册序列化器
虽然@JsonSerialize是字段级别的精准控制,但有时我们希望某个类型(如所有的BigDecimal)在整个应用中都采用同一种序列化方式。这时,全局注册是更好的选择。
在 Spring Boot 中,你可以通过配置一个Jackson2ObjectMapperBuilderCustomizerBean 或直接提供一个ObjectMapperBean 来实现。
@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> { // 为 BigDecimal 类型注册全局序列化器 builder.serializerByType(BigDecimal.class, new MoneySerializer()); // 同时可以注册反序列化器 // builder.deserializerByType(BigDecimal.class, new MoneyDeserializer()); }; } }全局注册与@JsonSerialize的优先级:当同时存在全局注册和字段上的@JsonSerialize注解时,字段注解的优先级更高。这意味着你仍然可以在特定字段上使用@JsonSerialize来覆盖全局行为,这提供了极大的灵活性。
5.3 与 Lombok 的协作
使用 Lombok 自动生成 Getter/Setter 时,@JsonSerialize应该放在哪里?最佳实践是放在字段上,而不是 Lombok 生成的 Getter 方法上。因为 Jackson 默认会通过字段(如果可见)或 Getter 方法来访问属性。将注解放在字段上,无论 Jackson 选择哪种访问方式,注解都能被正确识别。
import lombok.Data; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @Data public class UserDTO { @JsonSerialize(using = CustomSerializer.class) private BigDecimal score; // 注解放在字段上 }注意:如果你使用了 Lombok 的
@Getter或@Setter在类上,并且需要将注解放在方法上,你需要使用 Lombok 的onMethod属性,但这通常更复杂。因此,对于 Jackson 注解,直接放在字段上是最简单可靠的方式。
5.4 处理多态类型和泛型
对于泛型字段,自定义序列化会稍微复杂。例如,你有一个ResponseWrapper<T>类,你想根据不同的T来定制序列化。单纯的@JsonSerialize(using=…)在字段上可能不够,因为序列化器需要知道泛型参数T的具体类型。
这时,你需要创建能够处理泛型的序列化器,并可能结合@JsonSerialize的contentUsing或通过TypeReference在序列化器内部进行更精细的控制。更常见的做法是使用 Jackson 的@JsonTypeInfo和@JsonSubTypes来处理多态序列化,而将@JsonSerialize用于更具体的、非多态的自定义逻辑。
6. 性能考量、常见问题与排查技巧
引入自定义序列化器带来了灵活性,但也需要关注其对性能的影响和可能引入的问题。
6.1 性能影响分析
- 实例化开销:每次序列化可能都需要创建序列化器实例(除非序列化器被缓存和重用)。对于简单的格式化需求,这个开销相对于
DecimalFormat本身的格式化开销可能微不足道。但对于高频调用的接口,仍需注意。 - 逻辑复杂度:如果你的序列化器内部逻辑非常复杂(如数据库查询、远程调用),性能瓶颈将出现在这里,而不是注解机制本身。
- 缓存策略:Jackson 本身会对
JsonSerializer实例进行缓存(基于类型)。通常,一个Class对应的序列化器在ObjectMapper的生命周期内只会被实例化一次并复用。因此,在大多数场景下,性能开销是可接受的。
优化建议:
- 对于简单的格式化(如日期、数字),优先使用
@JsonFormat,它的性能通常优于自定义序列化器。 - 在自定义序列化器中,避免执行重量级操作(如 IO、网络请求)。
- 确保序列化器是无状态的,或者妥善管理有状态资源(使用
ThreadLocal)。
6.2 常见问题排查
问题一:注解不生效
- 检查点1:注解位置。确保
@JsonSerialize放在了正确的字段或 Getter 方法上。如果字段是private的,Jackson 默认会通过 Getter 访问,此时注解放在 Getter 上也可能生效,但放在字段上更稳妥。 - 检查点2:ObjectMapper 配置。如果你自定义了
ObjectMapperBean 并关闭了注解扫描功能(如mapper.disable(MapperFeature.USE_ANNOTATIONS)),那么所有注解都会失效。 - 检查点3:序列化器类路径。确保你自定义的
JsonSerializer类能被 Spring/Jackson 正确加载。如果序列化器类本身因为依赖问题无法初始化,Jackson 会抛出异常。 - 检查点4:Getter 方法冲突。如果 Lombok 生成了一个 Getter,而你自己又写了一个同名的 Getter,可能会导致 Jackson 访问了错误的方法而忽略了注解。
问题二:序列化器内部抛出异常
- 空指针异常:这是最常见的问题。永远记得在
serialize方法开始处检查value是否为null,并进行处理。 - 格式化异常:例如,
DecimalFormat.format()传入了一个非数字对象。确保传入值的类型与序列化器声明的泛型T一致。 - 循环引用导致栈溢出:如前所述,在序列化器中谨慎处理对象图的嵌套序列化。
问题三:与 Spring Boot 的默认配置冲突Spring Boot 自动配置的ObjectMapper已经预置了很多模块(如 Java 8 日期时间模块)。如果你完全替换了ObjectMapperBean,可能会丢失这些便利的配置。建议使用Jackson2ObjectMapperBuilderCustomizer或MappingJackson2HttpMessageConverter来进行定制,而非完全重建。
6.3 调试技巧
当序列化行为不符合预期时,可以开启 Jackson 的调试日志来观察序列化过程。
# 在 application.yml 中 logging: level: com.fasterxml.jackson.databind.ser: DEBUG这会在日志中输出 Jackson 为每个属性选择了哪个序列化器,对于理解@JsonSerialize、全局注册、默认序列化器之间的优先级和选择过程非常有帮助。
7. 总结与最佳实践选择
经过以上从原理到实战的拆解,我们可以对@JsonSerialize的应用形成一个清晰的决策路径。它不是所有序列化问题的银弹,而是一把用于特定场景的精密工具。
最佳实践选择指南:
- 默认优先:对于日期、时间、数字的简单格式化,永远优先使用
@JsonFormat注解。它更简洁、性能更好,且是 Jackson 原生支持的标准方式。 - 精准控制:当需要对一个字段的序列化输出进行非标准、业务逻辑复杂的转换时(如根据状态码映射为特定文案、将复杂对象树扁平化为特定 JSON 结构),使用
@JsonSerialize(using = …)。 - 容器处理:当需要统一处理集合或 Map 内所有元素的序列化方式时(如将列表内所有 ID 转换为字符串),使用
@JsonSerialize(contentUsing = …)或keyUsing。 - 全局通用:当某种类型的序列化规则在整个应用范围内通用且稳定时(如所有金额的格式化),考虑通过
Jackson2ObjectMapperBuilderCustomizer进行全局注册。这保持了代码的整洁,同时仍允许在特殊字段上用@JsonSerialize覆盖。 - 避免滥用:不要用
@JsonSerialize来做本应在业务层完成的数据聚合或计算。它的职责是“表示转换”,而不是“业务逻辑”。如果一个字段的序列化值需要依赖多个其他字段或外部服务调用才能计算出来,那么更好的做法是在 DTO 中直接定义一个计算好的属性,或者使用@JsonAnyGetter等动态生成 JSON 的方法。
我个人在实际项目中的体会是,@JsonSerialize就像是一个“字段级别的视图渲染器”。它在数据离开 Java 对象、即将变成 JSON 字符串的最后一刻,提供了一次干预的机会。这种声明式的方式,将视图逻辑固化在数据模型上,使得 API 的输出格式非常稳定和明确。然而,它的强大也意味着责任,一个设计不良的自定义序列化器可能会成为性能瓶颈或难以调试的 bug 来源。因此,在决定使用它之前,务必权衡其必要性与复杂性,并遵循上述的最佳实践。
