Jackson @JsonSerialize 注解详解:精准控制 Java 对象 JSON 序列化
2026/8/17 8:23:08 网站建设 项目流程

1. 项目概述:从日常开发痛点说起

如果你写过Java后端服务,尤其是那种需要频繁对外提供API接口的项目,那你肯定对下面这个场景不陌生:数据库里存了一个BigDecimal类型的金额字段,比如123.45,但前端同学跑过来抱怨,说接口返回的JSON里这个字段一会儿是123.45,一会儿又变成了123.4500,导致他们做展示和计算时总得出错。又或者,你有一个Date类型的createTime字段,直接序列化出去是一长串毫秒时间戳,前端还得再费劲转换一遍。这些看似琐碎的“格式不一致”问题,在微服务架构和前后端分离的背景下,往往会演变成影响联调和数据一致性的“大坑”。

这些问题,本质上都是对象序列化过程中的细节控制问题。在Java生态里,Jackson库是处理JSON序列化与反序列化的事实标准,无论是Spring Boot的默认集成,还是众多开源框架的底层依赖,都离不开它。而@JsonSerialize注解,就是Jackson赋予我们的一把“手术刀”,让我们能精准地控制一个Java对象属性被转换成JSON字符串时的每一个细节。它不像@JsonProperty那样只是改个名字,也不像@JsonIgnore那样直接隐藏,它的能力在于定制转换过程本身。理解并熟练运用这个注解,意味着你能从“JSON序列化结果不可控”的被动局面,转变为“按需产出精准JSON”的主动掌控。这不仅是解决上述格式问题的钥匙,更是实现复杂定制化序列化逻辑的基石。

2. @JsonSerialize 注解核心机制深度解析

2.1 注解定义与核心属性拆解

@JsonSerialize注解位于com.fasterxml.jackson.databind.annotation包下。它的核心作用是指定在序列化某个属性或类时,应该使用哪个自定义的序列化器(JsonSerializer的子类)。我们先来看它的主要构成:

@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.TYPE, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @JacksonAnnotation public @interface JsonSerialize { // 1. 指定自定义序列化器的核心属性 Class<? extends JsonSerializer> using() default JsonSerializer.None.class; // 2. 指定用于序列化“内容”(如List的元素、Map的值)的序列化器 Class<? extends JsonSerializer> contentUsing() default JsonSerializer.None.class; // 3. 指定用于序列化“键”(如Map的键)的序列化器,仅对Map类型有效 Class<? extends JsonSerializer> keyUsing() default JsonSerializer.None.class; // 4. 指定序列化时使用的类型 Class<?> as() default Void.class; // 5. 指定一个自定义转换器,在序列化器之前执行类型转换 Class<? extends Converter<?, ?>> converter() default Converter.None.class; // 6. 已废弃的属性,早期用于指定null值的序列化行为,现推荐使用`@JsonInclude` @Deprecated Inclusion include() default Inclusion.DEFAULT; // 7. 已废弃的属性,早期用于指定包含规则 @Deprecated public static enum Inclusion { ALWAYS, NON_NULL, NON_DEFAULT, NON_EMPTY, DEFAULT } }

对于日常开发,最常用、最需要理解的是usingcontentUsingkeyUsing这三个属性。using是全局性的,指定整个属性或类用什么序列化器。contentUsingkeyUsing则是针对容器类型(Collection,Map, 数组)的精细化控制,它们体现了Jackson设计上的层次性:一个Map<String, User>对象,其序列化过程可以被拆解为“Map整体”、“Key(String)”和“Value(User)”三个层次,每个层次都可以独立定制。

as属性相对特殊,它用于执行“类型伪装”。例如,你有一个Object类型的属性,实际运行时可能是User实例,但你想让Jackson在序列化时将其视为BaseEntity类型来处理。这时@JsonSerialize(as = BaseEntity.class)会指示Jackson按照BaseEntity的类型描述(包括其自身的@JsonSerialize注解)来序列化这个属性,无论其运行时类型是什么。这个功能在处理继承层次或动态类型时非常有用。

2.2 自定义序列化器(JsonSerializer)的编写范式

@JsonSerialize(using = MySerializer.class)的灵魂在于MySerializer。一个标准的自定义序列化器需要继承com.fasterxml.jackson.databind.JsonSerializer<T>这个泛型抽象类,并实现其唯一的抽象方法serialize

public class MySerializer extends JsonSerializer<TargetType> { @Override public void serialize(TargetType value, JsonGenerator gen, SerializerProvider serializers) throws IOException { // 核心序列化逻辑 } }
  • TargetType: 你要处理的Java类型。它决定了这个序列化器能用于哪些属性。
  • value: 正在被序列化的对象实例。
  • gen(JsonGenerator): Jackson提供的JSON生成器。所有向输出流写入JSON内容的操作都通过它完成。它是线程不安全的,但Jackson会确保每次序列化调用都使用一个新的实例或正确重置的实例。
  • serializers(SerializerProvider): 序列化器提供者。它是一个核心工具类,最重要的作用是通过它来获取其他默认或注册的序列化器,用于处理嵌套对象的序列化。这是实现复杂序列化逻辑的关键。

一个常见的误区是试图在自定义序列化器里“重新发明轮子”,手动拼接所有JSON字符串。正确做法是充分利用JsonGeneratorSerializerProvider。例如,你要序列化一个User对象,其中包含一个List<Order>属性,你不需要自己循环List然后拼接字符串。你应该在serialize方法中调用gen.writeStartObject()开始对象,然后对于orders属性,通过serializers.findValueSerializer(Order.class)找到Order的序列化器,再调用该序列化器进行序列化。这样既保证了代码简洁,又确保了Jackson内部缓存、类型处理等机制的正常工作。

注意:在serialize方法内,务必处理好null值。即使属性本身有@JsonInclude(JsonInclude.Include.NON_NULL),一旦你使用了自定义序列化器,这个全局的null值处理规则就可能失效。安全的做法是在方法开始判断if (value == null) { gen.writeNull(); return; },或者按照业务逻辑写入一个默认的非null JSON值(如空对象、空字符串)。

2.3 注解生效的优先级与作用域

理解@JsonSerialize的生效范围至关重要,它直接决定了你的注解是“精准打击”还是“狂轰滥炸”。

  1. 作用域优先级(从高到低)

    • 属性(Field/Method)级别:最高优先级。注解在某个getter方法或字段上,只作用于该属性。这是最常用、最推荐的方式,控制粒度最细。
    • 类(Type)级别:次优先级。注解在类定义上,会影响该类所有实例的默认序列化行为,以及所有未在属性级别单独覆盖该注解的属性。例如,在Money类上标注@JsonSerialize(using = MoneySerializer.class),那么所有Money类型的属性,除非自己指定了using,否则都会使用MoneySerializer
    • 全局注册:通过ObjectMapperSimpleModule注册序列化器,优先级低于注解。当注解和全局注册冲突时,以注解为准。
  2. 属性 vs Getter方法:Jackson默认通过getter方法访问属性。因此,将@JsonSerialize放在getAmount()方法上,与放在amount字段上,效果通常是相同的。但有一个关键区别:如果同时存在,Getter方法上的注解优先级高于字段上的注解。为了避免混淆和潜在问题,团队内部最好约定统一的位置(例如,统一放在Getter方法上)。

  3. 与其它Jackson注解的协作@JsonSerialize可以与绝大多数其他Jackson注解协同工作,执行顺序通常是:@JsonFormat(如果支持)->@JsonSerializeconverter->@JsonSerializeusing序列化器。例如,一个属性可以同时用@JsonFormat(pattern = “yyyy-MM-dd”)定义日期格式,再用@JsonSerialize(using = MyDateSerializer.class)做进一步的包装(比如在外面加一个{“date”: “2023-10-01”, “timestamp”: 1696118400000}的结构)。@JsonSerializeusing是最终执行者。

3. 四大核心应用场景与实战代码

3.1 场景一:自定义数据类型格式化(金额、日期)

这是@JsonSerialize最经典的应用场景。以金额格式化为例,数据库的BigDecimal精度可能很高(如123.450000),但前端通常只需要两位小数。

1. 定义金额序列化器:

public class BigDecimalMoneySerializer extends JsonSerializer<BigDecimal> { private static final DecimalFormat DF = new DecimalFormat("#0.00"); @Override public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } // 使用DecimalFormat格式化,并写入字符串。注意:这里返回的是字符串,不是数字。 gen.writeString(DF.format(value)); // 如果希望返回数字,且前端能处理固定小数位,也可以使用: // gen.writeNumber(value.setScale(2, RoundingMode.HALF_UP)); } }

2. 在实体类中使用:

public class OrderVO { private String orderId; @JsonSerialize(using = BigDecimalMoneySerializer.class) private BigDecimal totalAmount; // 序列化为 "123.45" // 标准getter/setter }

3. 日期类型自定义包装:有时前端不仅需要格式化后的日期字符串,还需要对应的时间戳。我们可以包装成一个对象。

public class DateDetailSerializer extends JsonSerializer<Date> { @Override public void serialize(Date value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); gen.writeStartObject(); // 开始写入一个JSON对象 gen.writeStringField("dateString", sdf.format(value)); gen.writeNumberField("timestamp", value.getTime()); gen.writeEndObject(); // 结束对象 } } // 使用 public class Event { @JsonSerialize(using = DateDetailSerializer.class) private Date startTime; // 序列化结果:{"dateString": "2023-10-27 14:30:00", "timestamp": 1698395400000} }

实操心得:在日期格式化时,要特别注意SimpleDateFormat的线程安全问题。虽然上面的例子在方法内创建是安全的,但频繁创建开销大。更优的做法是将SimpleDateFormat声明为ThreadLocal变量,或者直接使用Jackson内置的@JsonFormat注解处理简单格式化,@JsonSerialize用于更复杂的包装逻辑。

3.2 场景二:敏感信息脱敏与数据裁剪

在返回用户信息时,手机号、邮箱、身份证号等需要部分隐藏。

public class SensitiveInfoSerializer extends JsonSerializer<String> { @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null || value.length() < 3) { gen.writeString(value != null ? value : ""); return; } // 简单脱敏逻辑:保留前3位和后4位,中间用*填充 int prefixLen = 3; int suffixLen = 4; if (value.length() <= prefixLen + suffixLen) { // 字符串太短,直接全显或部分显示 gen.writeString(value.charAt(0) + "****" + (value.length() > 1 ? value.charAt(value.length()-1) : "")); } else { String prefix = value.substring(0, prefixLen); String suffix = value.substring(value.length() - suffixLen); String masked = prefix + "****" + suffix; gen.writeString(masked); } } } public class UserDTO { private String name; @JsonSerialize(using = SensitiveInfoSerializer.class) private String phone; // “13800138000” -> “138****8000” @JsonSerialize(using = SensitiveInfoSerializer.class) private String email; // “abc@example.com” -> “abc****@example.com” (需更精细的逻辑) }

对于更复杂的脱敏规则(如邮箱、姓名),可以在序列化器内编写更精细的匹配和替换逻辑。这种方式的优势在于,脱敏规则与DTO模型强绑定,业务代码无需关心,保证了数据出口的一致性。

3.3 场景三:枚举类型的友好展示

数据库存储的枚举通常是ORDINAL(序号)或NAME(字符串),但前端需要更友好的中文描述。

public enum OrderStatus { UNPAID(0, “待支付”), PAID(1, “已支付”), DELIVERED(2, “已发货”), COMPLETED(3, “已完成”); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } } // 自定义枚举序列化器,返回一个包含code和desc的对象 public class EnumDetailSerializer extends JsonSerializer<Enum<?>> { @Override public void serialize(Enum<?> value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeNull(); return; } gen.writeStartObject(); gen.writeStringField(“name”, value.name()); gen.writeNumberField(“ordinal”, value.ordinal()); // 尝试获取desc字段,这里假设枚举有getDesc方法(可通过反射实现更通用) try { Method getDesc = value.getClass().getMethod(“getDesc”); String desc = (String) getDesc.invoke(value); gen.writeStringField(“description”, desc); } catch (Exception e) { // 如果枚举没有getDesc方法,则忽略 } gen.writeEndObject(); } } // 使用 public class OrderVO { @JsonSerialize(using = EnumDetailSerializer.class) private OrderStatus status; // 序列化结果:{"name": "PAID", "ordinal": 1, "description": "已支付"} }

更优雅的做法是让枚举实现一个Describable接口,然后在序列化器中通过接口方法获取描述,避免反射。Jackson也提供了@JsonFormat(shape = JsonFormat.Shape.OBJECT)注解,可以让枚举序列化为整个对象(需要相应的getter方法),但自定义序列化器提供了最大的灵活性。

3.4 场景四:复杂对象结构的扁平化与聚合

有时,为了适配前端特定的数据结构,需要将嵌套的对象图“拍平”,或者将多个字段聚合成一个。

1. 对象扁平化:假设有一个User对象,里面包含Address地址对象,但前端需要一个扁平结构。

public class User { private String name; private Address address; // {“city”: “北京”, “street”: “海淀区”} } public class Address { private String city; private String street; } // 目标JSON: {“name”: “张三”, “city”: “北京”, “street”: “海淀区”} // 我们不能直接在User类上使用@JsonSerialize,因为会改变整个User的序列化。 // 正确做法:为前端专门创建一个UserFlatDTO,并在其中使用@JsonSerialize聚合逻辑。 // 或者,在User类的getter方法上做文章(不推荐,破坏模型清晰度)。 // 更推荐使用@JsonUnwrapped注解(Jackson提供)来实现扁平化,这比自定义序列化器更简洁。 // 但如果是非常复杂的转换,序列化器仍是终极武器。

2. 字段聚合:firstNamelastName聚合成一个fullName字段返回。

public class Person { private String firstName; private String lastName; // 标准getter/setter @JsonSerialize(using = FullNameSerializer.class) public String getFullName() { // 这是一个“虚拟”的getter // 序列化器会处理,这里可以返回null或任意值,因为实际输出由序列化器决定 return null; } } public class FullNameSerializer extends JsonSerializer<String> { @Override public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException { // 注意:这里的value是getFullName()的返回值,我们并不需要它。 // 我们需要从序列化上下文中获取原始对象。这很棘手,通常不建议这样做。 // 更好的模式:直接创建一个真实的getFullName()方法返回拼接字符串,然后对这个方法使用@JsonProperty。 // 如果需要非常复杂的聚合逻辑,可以考虑使用@JsonSerialize在类级别,并操作整个对象的序列化。 } }

对于聚合场景,更常见的做法是:使用专门的DTO(Data Transfer Object)或VO(View Object)来承载面向API的数据结构,在DTO的getter方法中直接完成计算和聚合,然后对这个getter方法使用@JsonProperty即可。自定义序列化器在这里显得过于重量级。@JsonSerialize更适合用于对已有属性值的转换,而非创建不存在的属性。

4. 高级技巧与性能优化

4.1 使用 contentUsing 与 keyUsing 处理容器类型

当你的属性是一个List<BigDecimal>Map<String, SensitiveObject>时,你希望对容器内的每一个元素应用特定的序列化规则,而不是整个容器。这时contentUsingkeyUsing就派上用场了。

public class Portfolio { // 对List中的每一个BigDecimal金额进行格式化 @JsonSerialize(contentUsing = BigDecimalMoneySerializer.class) private List<BigDecimal> assetValues; // 对Map的键(String)进行脱敏,值(User)使用其自身的序列化规则或自定义规则 @JsonSerialize(keyUsing = SensitiveInfoSerializer.class) private Map<String, User> userContacts; // 甚至可以组合使用:对Map的值(User)中的某个字段进行特殊序列化,这需要在User类内部定义。 }

实现原理:当Jackson处理List<BigDecimal>时,它会先获取一个List序列化器,然后这个序列化器在遍历元素时,会通过SerializerProvider来查找每个BigDecimal元素的序列化器。contentUsing注解的作用,就是告诉SerializerProvider:“当为这个List属性的内容查找序列化器时,不要用默认的,用我指定的这个”。keyUsing对于Map同理。

注意事项keyUsing指定的序列化器,其泛型类型必须是Map键的类型(通常是StringInteger等可序列化为JSON键的类型)。JSON标准要求键必须是字符串,所以即使你的Java键是Integer,序列化器最终也需要调用gen.writeFieldName(String)或类似方法写入一个字符串。

4.2 通过 Module 进行全局注册与管理

在类或属性上打注解虽然方便,但如果某个自定义序列化器(如BigDecimalMoneySerializer)需要在几十个地方使用,到处写@JsonSerialize(using = ...)就显得冗余。此时,可以通过Jackson的Module机制进行全局注册。

public class MoneySerializationModule extends SimpleModule { public MoneySerializationModule() { super(); // 为BigDecimal类型全局注册序列化器 addSerializer(BigDecimal.class, new BigDecimalMoneySerializer()); // 也可以为自定义类型注册 addSerializer(MyCustomType.class, new MyCustomSerializer()); } } // 在Spring Boot中配置(通常在@Configuration类中) @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new MoneySerializationModule()); // 可以注册多个Module mapper.registerModule(new JavaTimeModule()); // 处理Java 8时间API mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); return mapper; }

全局注册与注解的优先级:全局注册的序列化器优先级低于属性或类级别上的@JsonSerialize注解。这意味着,如果你在某个属性上明确指定了using,那么全局注册的对应类型序列化器将不会对该属性生效。这提供了很好的灵活性:全局配置默认规则,局部注解覆盖特殊规则。

管理建议:对于通用的、无歧义的格式化规则(如全局金额格式化、全局日期格式),推荐使用Module全局注册,保持代码简洁。对于业务含义强、规则特殊的序列化(如特定业务状态的枚举展示、某个核心接口的敏感信息脱敏),则使用@JsonSerialize注解进行精准控制。

4.3 序列化器的缓存与复用机制

Jackson内部有完善的序列化器缓存机制(SerializerProvider负责管理),这保证了高性能。但我们在编写自定义序列化器时,也需要注意避免破坏这种性能。

  1. 无状态设计:尽量将你的JsonSerializer实现为无状态的(即不包含可变的成员变量)。如果必须持有状态(如配置参数),应确保它是线程安全的(如使用final字段,或通过构造器注入)。因为同一个序列化器实例可能会被多个线程同时调用其serialize方法。

  2. 重用 JsonGenerator 和 SerializerProvider:在serialize方法内部,不要自己创建JsonGeneratorObjectMapper,完全使用传入的参数。对于需要递归序列化内部对象的情况,务必使用serializers.findValueSerializer(Class)来获取正确的序列化器,然后调用serializer.serialize(value, gen, serializers)。这样做能充分利用Jackson的缓存和类型解析系统。

  3. 避免在序列化器内进行复杂IO或远程调用serialize方法可能会被频繁调用,尤其是在序列化大型列表时。如果在这里面执行数据库查询、HTTP请求等操作,性能将是灾难性的。所有需要外部获取的数据,都应在业务层提前加载好,放入要序列化的对象中。

4.4 与 Spring Boot 的集成配置

在Spring Boot项目中,Jackson通常被自动配置。你可以通过application.ymlapplication.properties文件进行大量默认行为配置,但这主要影响Jackson的全局特性(如是否输出空值、日期格式等)。

要注册自定义的Module,最优雅的方式是提供一个Jackson2ObjectMapperBuilderCustomizerObjectMapper类型的@Bean

@Configuration public class JacksonConfig { @Bean public Module customSerializersModule() { SimpleModule module = new SimpleModule(); module.addSerializer(BigDecimal.class, new BigDecimalMoneySerializer()); module.addSerializer(Date.class, new DateDetailSerializer()); return module; } // 或者使用定制器,这种方式更灵活,可以同时设置多个配置 @Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.serializers(new BigDecimalMoneySerializer()); builder.serializers(new DateDetailSerializer()); builder.modulesToInstall(new JavaTimeModule()); // 安装其他模块 builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); }; } }

Spring Boot会自动探测到这些Bean,并将其应用到自动配置的ObjectMapper上。这样,你的自定义序列化器就在整个Spring MVC的@ResponseBody@RequestBody处理中生效了。

5. 常见问题排查与实战避坑指南

5.1 注解不生效的排查步骤

  1. 检查注解位置:确认@JsonSerialize是放在getter方法上,还是字段上。确保没有在其他地方(如setter)错误放置。最稳妥的方式是统一放在getter方法上。
  2. 检查序列化器类型匹配@JsonSerialize(using = MySerializer.class)中的MySerializer必须是JsonSerializer<T>的子类,并且其泛型T必须与待序列化属性的类型严格匹配或为其父类。如果属性是BigDecimal,序列化器泛型是Object,虽然可以工作,但不够精确;如果是String,则完全不会生效。
  3. 检查 ObjectMapper 配置:如果你在代码中手动创建或修改了ObjectMapper,确保它启用了注解扫描功能(默认是开启的)。mapper.configure(MapperFeature.USE_ANNOTATIONS, true)
  4. 检查 Getter 方法是否存在:Jackson默认通过getter方法访问属性。如果只有字段没有getter,且没有启用字段直接访问(mapper.configure(MapperFeature.AUTO_DETECT_FIELDS, true)),那么字段上的注解可能不会被识别。
  5. 检查是否有更高优先级的配置:全局注册的序列化器(通过Module)优先级低于属性注解。但如果你在类上使用了@JsonSerialize,而属性上没有,那么类级别的注解会生效。确认是否存在冲突的配置。
  6. 使用调试工具:在serialize方法开始处打一个断点或打印日志,看是否被调用。如果没有,说明Jackson根本没有选择你的序列化器。

5.2 循环引用与栈溢出问题

当两个对象互相引用时(如User有一个List<Order>,而Order又有一个User属性),在序列化时如果不加控制,Jackson会陷入无限递归,最终导致StackOverflowError

解决方案:

  1. 使用@JsonIgnore:在反向引用的一方(如Orderuser属性)上添加@JsonIgnore,直接忽略该属性。这是最简单粗暴的方法,但可能会丢失前端需要的信息。
  2. 使用@JsonManagedReference@JsonBackReference:这是一对注解,用于标识父子关系。在“主”对象(如Userorders属性)上使用@JsonManagedReference,在“从”对象(如Orderuser属性)上使用@JsonBackReference。Jackson在序列化时会序列化@JsonManagedReference端,而忽略@JsonBackReference端;在反序列化时能正确重建关系。这种方式更语义化。
  3. 在自定义序列化器中手动控制:这是最灵活但最复杂的方式。在你的UserSerializer中,序列化orders时,可以只序列化Order的ID,而不是整个Order对象。
    public void serialize(User value, JsonGenerator gen, SerializerProvider serializers) throws IOException { gen.writeStartObject(); gen.writeStringField(“id”, value.getId()); gen.writeStringField(“name”, value.getName()); // 处理orders,避免循环 gen.writeArrayFieldStart(“orderIds”); for (Order order : value.getOrders()) { gen.writeString(order.getId()); } gen.writeEndArray(); gen.writeEndObject(); }
  4. 配置 ObjectMapper 禁用某些特性mapper.configure(SerializationFeature.FAIL_ON_SELF_REFERENCES, false)可以防止因自引用(对象引用自身)而抛出异常,但对于互相引用,它可能仍然会栈溢出。更常用的是mapper.configure(SerializationFeature.WRITE_SELF_REFERENCES_AS_NULL, true),但这会将自引用部分写为null,可能不符合预期。

5.3 泛型类型擦除带来的挑战

Java的泛型在运行时会被擦除。这意味着在自定义序列化器JsonSerializer<T>中,如果你需要基于T的具体类型来做一些动态逻辑,可能会遇到困难。

例如,你想写一个通用的“Null安全序列化器”,将null集合序列化为空数组[],而不是null

public class NullSafeCollectionSerializer extends JsonSerializer<Collection<?>> { @Override public void serialize(Collection<?> value, JsonGenerator gen, SerializerProvider serializers) throws IOException { if (value == null) { gen.writeStartArray(); gen.writeEndArray(); } else { // 问题:如何序列化集合内的元素?我们不知道元素的具体类型。 // 不能直接调用 gen.writeObject(value),那会绕开这个序列化器。 // 正确做法:委托给默认的集合序列化器去处理非空情况。 serializers.findValueSerializer(Collection.class, null).serialize(value, gen, serializers); } } }

在上面的例子中,对于非空集合,我们通过serializers找到了默认的Collection序列化器并委托给它。这个默认序列化器知道如何处理集合内的具体类型(因为Jackson通过字段的泛型声明或方法签名保留了类型信息)。

关键点:在自定义序列化器中,当需要处理包含泛型的容器时,最佳实践是尽可能将具体元素的序列化工作委托回SerializerProvider,让它利用完整的类型上下文信息来查找正确的序列化器,而不是自己硬编码。

5.4 与 Lombok 等字节码增强工具的兼容性

Lombok通过注解在编译时生成getter、setter等方法。如果@JsonSerialize注解放在字段上,而Lombok生成的getter方法名不符合Jackson的默认探测规则(或者你使用了@Getter注解在类上),可能会出现问题。

最佳实践

  • @JsonSerialize放在 Lombok 生成的 Getter 方法上:但这需要你手动编写getter方法,失去了使用Lombok的便利性。不推荐。
  • 使用 Lombok 的@Getter@Setter在类级别,并将@JsonSerialize放在字段上:这是最常用的方式,且通常工作良好。因为Jackson在发现字段上的注解时,会去寻找对应的访问器(accessor),而Lombok生成的getter方法符合标准Bean规范,能被Jackson正确关联。
  • 潜在问题:如果你使用了Lombok的特殊特性,如@Data@Value,或者自定义了访问器级别(AccessLevel),需要确保Jackson能“看到”这些方法。在极少数情况下,可能需要配置Jackson的可见性规则或使用@JsonProperty在字段上作为补充。
  • 测试:在集成Lombok后,务必对序列化/反序列化进行单元测试,确保注解按预期工作。

一个常见的坑是:当你同时使用了@JsonSerialize@JsonProperty(用于指定JSON字段名)时,确保它们放在同一个元素(都放在字段上,或都放在手动编写的getter上)。如果@JsonProperty放在字段上,而@JsonSerialize放在一个非标准的getter上,Jackson可能会混淆。

5.5 性能考量与最佳实践

  1. 避免过度使用@JsonSerialize注解和自定义序列化器会带来一定的运行时开销(反射查找、实例化等)。对于简单的格式化需求,优先考虑使用Jackson内置的注解,如@JsonFormat(用于日期/数字)、@JsonInclude(控制包含规则)。内置注解经过高度优化。
  2. 序列化器实例化:Jackson会缓存并复用序列化器实例。确保你的序列化器构造过程是轻量的。避免在序列化器构造函数中执行耗时的操作(如加载资源、建立连接)。
  3. 为 null 处理做好准备:如前所述,在serialize方法开头处理null值。考虑是否要写入null、空值或默认值。这比依赖全局的WRITE_NULLS配置更可靠。
  4. 使用@JsonValue作为简单替代:如果一个类的序列化逻辑仅仅是转换为一个简单的值(如字符串、数字),可以考虑使用@JsonValue注解在一个方法上。例如,在枚举上标注@JsonValuegetDesc()方法上,那么该枚举序列化时就直接输出描述字符串。这比自定义序列化器更简洁高效。
  5. 测试不同场景:对你的自定义序列化器进行单元测试,覆盖null、空值、边界值、嵌套对象、循环引用等情况。使用ObjectMapperwriteValueAsString方法进行测试。

通过系统地理解@JsonSerialize的工作原理、应用场景和避坑指南,你就能在复杂的业务序列化需求面前游刃有余,打造出既符合业务要求又保持高性能和可维护性的API数据层。记住,它的强大在于“定制”,但力量越大责任越大,谨慎而恰当地使用它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询