Jackson @JsonSerialize 注解详解:自定义序列化实战指南 📅 2026/8/17 8:21:26 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 MapKeyObject, ListBigDecimal complexMap;这个功能非常强大。想象一下你有一个MapLocalDateTime, 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(usingMoneySerializer.class)在MoneySerializer中统一格式化逻辑。逻辑内聚格式化规则与字段定义在一起清晰且易于复用。避免了业务代码污染和 Getter 方法职责过重。复杂对象简化一个User对象序列化时只输出id和name。1. 定义一个专用的UserSimpleVO。2. 使用JsonIgnore忽略其他字段但需忽略的字段多时配置繁琐。JsonSerialize(usingUserSimpleSerializer.class)在序列化器中手动构造只包含id和name的 JSON 节点。灵活精准对于一次性或非常规的简化需求无需创建大量 VO 类。序列化器可以访问原对象的所有属性按需组装。枚举自定义输出StatusEnum有code和desc属性希望输出desc。在枚举类上实现自定义的序列化/反序列化逻辑或使用JsonValue。JsonSerialize(usingEnumDescSerializer.class)解耦与复用序列化逻辑与枚举定义解耦。同一个枚举在不同 API 中可以通过不同的序列化器输出不同内容如一个接口输出code另一个输出desc。处理容器内元素ListBigDecimal需统一格式化为百分比。遍历列表在业务层或 DTO 层转换成一个新的ListString。JsonSerialize(contentUsingPercentageSerializer.class)声明式配置在字段声明处即指定了容器内元素的转换规则代码意图明确且转换对上层业务透明。从对比可以看出JsonSerialize的核心优势在于“声明式”和“精准控制”。它将序列化这一横切关注点以一种高内聚的方式绑定在数据模型上特别适合处理那些具有特定业务含义的格式化需求或者默认序列化行为无法满足的复杂场景。3. 手把手实现一个自定义序列化器理解了参数和场景我们来实战创建一个自定义序列化器。我们以实现一个经典的“金额格式化”场景为例将BigDecimal类型的金额序列化为保留两位小数的字符串并添加千位分隔符。3.1 第一步创建自定义序列化器类自定义序列化器必须继承com.fasterxml.jackson.databind.JsonSerializerT这个泛型抽象类其中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 JsonSerializerBigDecimal { // 定义格式化器。注意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 JsonSerializerBigDecimal表明这个序列化器专门处理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 来查找并序列化嵌套对象 JsonSerializerObject 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 处理多态类型和泛型对于泛型字段自定义序列化会稍微复杂。例如你有一个ResponseWrapperT类你想根据不同的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 上也可能生效但放在字段上更稳妥。检查点2ObjectMapper 配置。如果你自定义了ObjectMapperBean 并关闭了注解扫描功能如mapper.disable(MapperFeature.USE_ANNOTATIONS)那么所有注解都会失效。检查点3序列化器类路径。确保你自定义的JsonSerializer类能被 Spring/Jackson 正确加载。如果序列化器类本身因为依赖问题无法初始化Jackson 会抛出异常。检查点4Getter 方法冲突。如果 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 来源。因此在决定使用它之前务必权衡其必要性与复杂性并遵循上述的最佳实践。