Jackson 配置深度解析

45 阅读6分钟

一、Jackson 序列化的优先级链

理解 Jackson 配置,先理解它的优先级体系。Jackson 在决定用哪个序列化器时,按以下顺序查找,找到即停:

字段级 @JsonSerialize(using=...)
  ↓ (未命中)
类级 @JsonSerialize(using=...)
  ↓
Module.addSerializer() 注册的类型序列化器
  ↓
@JsonFormat(由上面选出的序列化器内部处理)
  ↓
Jackson 内置默认序列化器

关键点@JsonFormat 不是序列化器选择机制,它是"配置参数",只有被选出的序列化器主动去读取它,才会生效。Jackson 内置序列化器(如 LocalDateSerializer)会读取 @JsonFormat自定义序列化器默认不读取

这就是本项目问题的根因:

JavaTimeModule.addSerializer(LocalDate.class, customSerializer)
  → customSerializer 被选中
  → customSerializer.serialize() 只写 epochMilli,不读 @JsonFormat@JsonFormat 被完全忽略

二、ObjectMapper 的核心配置项

序列化配置 SerializationFeature

配置作用
WRITE_DATES_AS_TIMESTAMPStrue=时间写为数字,false=写为字符串(ISO-8601)。对自定义序列化器无效,由序列化器自己决定
WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS时间戳精度,true=纳秒,false=毫秒
FAIL_ON_EMPTY_BEANS空对象是否抛异常
WRITE_ENUMS_USING_INDEX枚举用下标还是名称

反序列化配置 DeserializationFeature

配置作用
FAIL_ON_UNKNOWN_PROPERTIES遇到 JSON 中有但 POJO 没有的字段是否抛异常
ACCEPT_EMPTY_STRING_AS_NULL_OBJECT空字符串当 null 处理
READ_UNKNOWN_ENUM_VALUES_AS_NULL未知枚举值当 null(与枚举忽略大小写共存时需注意顺序)
READ_DATE_TIMESTAMPS_AS_NANOSECONDS反序列化时间戳精度

MapperFeature(全局行为)

objectMapper.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS);   // 枚举忽略大小写,无需自定义反序列化器
objectMapper.enable(MapperFeature.DEFAULT_VIEW_INCLUSION);            // @JsonView 默认包含无注解字段
objectMapper.disable(MapperFeature.AUTO_DETECT_GETTERS);             // 禁用 getter 自动探测

本项目说明:项目用 BeanDeserializerModifier 实现枚举忽略大小写,实际上 MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS 更简单。选择 BeanDeserializerModifier 的原因是:可以在反序列化失败时抛自定义异常(AppException(ResponseCode.UN_ERROR)),而不是 Jackson 默认的 InvalidFormatException


三、Module 系统:扩展 Jackson 的正确方式

SimpleModule vs JavaTimeModule

// SimpleModule:通用扩展容器
SimpleModule module = new SimpleModule();
module.addSerializer(Foo.class, new FooSerializer());
module.addDeserializer(Bar.class, new BarDeserializer());

// JavaTimeModule:java.time.* 专用
JavaTimeModule timeModule = new JavaTimeModule();
timeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(...));

两者都继承 SimpleModule,注册方式完全相同,只是 JavaTimeModule 预置了大量 java.time 类的支持。

BeanDeserializerModifier:拦截所有类型的反序列化器

module.setDeserializerModifier(new BeanDeserializerModifier() {
    @Override
    public JsonDeserializer<?> modifyEnumDeserializer(
            DeserializationConfig config, JavaType type,
            BeanDescription beanDesc, JsonDeserializer<?> deserializer) {
        // 在这里可以包装或替换任何枚举的反序列化器
        if (type.getRawClass().isEnum()) {
            return new CaseInsensitiveEnumDeserializer(...);
        }
        return deserializer;
    }

    @Override
    public JsonDeserializer<?> modifyDeserializer(
            DeserializationConfig config, BeanDescription beanDesc,
            JsonDeserializer<?> deserializer) {
        // 拦截所有 POJO 的反序列化器(可做通用校验、日志等)
        return super.modifyDeserializer(config, beanDesc, deserializer);
    }
});

类比BeanDeserializerModifier 相当于 Spring MVC 的 HandlerInterceptor,是全局反序列化的切面。


四、自定义序列化器的两种写法

写法 A:匿名内部类(本项目做法)

javaTimeModule.addSerializer(LocalDateTime.class, new JsonSerializer<LocalDateTime>() {
    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider s)
            throws IOException {
        gen.writeNumber(value.atZone(zone).toInstant().toEpochMilli());
    }
});

适合逻辑简单、不需要复用的场景。

写法 B:继承 StdSerializer + ContextualSerializer(可读 @JsonFormat)

public class EpochMilliLocalDateTimeSerializer
        extends StdSerializer<LocalDateTime>
        implements ContextualSerializer {

    private final ZoneId zone;

    public EpochMilliLocalDateTimeSerializer() {
        super(LocalDateTime.class);
        this.zone = ZoneId.of("Asia/Shanghai");
    }

    /**
     * ContextualSerializer:在序列化器绑定到具体字段时调用。
     * 可拿到字段上的所有注解,按需返回不同的序列化器实例。
     */
    @Override
    public JsonSerializer<?> createContextual(SerializerProvider prov,
                                               BeanProperty property) {
        if (property != null) {
            JsonFormat.Value format = findFormatOverrides(prov, property, handledType());
            if (format != null && format.hasPattern()) {
                // 有 @JsonFormat(pattern=...) → 返回按格式输出字符串的序列化器
                return new LocalDateTimeSerializer(
                    DateTimeFormatter.ofPattern(format.getPattern()));
            }
        }
        return this; // 无注解 → 输出 epochMilli
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider provider)
            throws IOException {
        gen.writeNumber(value.atZone(zone).toInstant().toEpochMilli());
    }
}

ContextualSerializer 是 Jackson 提供的回调接口,在序列化器实例化时调用,可以拿到字段的 BeanProperty(含所有注解),然后返回一个新的序列化器实例。这样 @JsonFormat 就能作用于自定义序列化器了。

本项目为何没有用这种方式:项目改用 @JsonSerialize(using = LocalDateSerializer.class) 字段级覆盖,更直接、零侵入序列化器代码。ContextualSerializer 适合"同一个全局序列化器,局部用注解微调行为"的场景。


五、全局 vs 局部配置对比

全局(ObjectMapper 注册)
│
├── 优点:统一管理,零侵入业务代码
├── 缺点:无法区分"哪个接口""哪个字段"
│
└── 局部覆盖方式(优先级由高到低)
    │
    ├── 1. 字段级 @JsonSerialize / @JsonDeserialize  ← 最高
    ├── 2. 类级  @JsonSerialize / @JsonDeserialize
    ├── 3. 字段级 @JsonFormat(依赖序列化器支持)
    └── 4. 类级  @JsonFormat

常见局部配置场景

场景 1:某字段输出字符串日期(部分 DTO 做法)

// 覆盖全局 epochMilli 序列化器,改为 ISO 字符串
@JsonSerialize(using = LocalDateSerializer.class)
@JsonDeserialize(using = LocalDateDeserializer.class)
@JsonFormat(pattern = "yyyy-MM-dd", shape = JsonFormat.Shape.STRING)
private LocalDate birthday;

场景 2:某字段不序列化

@JsonIgnore
private String password;

// 或者:序列化时包含,反序列化时忽略(只写不读)
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
private String createdBy;

场景 3:字段名映射

@JsonProperty("user_name")
private String userName;

场景 4:null 字段不输出

// 类级(只影响该类)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDTO { ... }

// 全局(影响所有类)
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

JsonInclude.Include 常用值:

含义
ALWAYS默认,始终输出
NON_NULL排除 null
NON_EMPTY排除 null、空字符串、空集合
NON_DEFAULT排除默认值(0、false、null 等)

场景 5:BigDecimal 不用科学计数法

SimpleModule module = new SimpleModule();
module.addSerializer(BigDecimal.class, new JsonSerializer<BigDecimal>() {
    @Override
    public void serialize(BigDecimal value, JsonGenerator gen, SerializerProvider s)
            throws IOException {
        gen.writeString(value.toPlainString()); // "1234567.89" 而非 "1.23456789E6"
    }
});

场景 6:Long 转 String(防止前端 JS 精度丢失)

// 全局:所有 Long 转 String
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);

// 局部:只针对某字段
@JsonSerialize(using = ToStringSerializer.class)
private Long id;

场景 7:@JsonView 按场景控制字段可见性

public class Views {
    public static class Public {}
    public static class Internal extends Public {}
}

public class UserDTO {
    @JsonView(Views.Public.class)
    private String name;

    @JsonView(Views.Internal.class)  // 只在 Internal 视图中输出
    private String idCard;
}

// Controller 指定视图
@GetMapping("/user")
@JsonView(Views.Public.class)
public UserDTO getUser() { ... }

六、多 ObjectMapper 实例:接口隔离

当同一个应用需要为不同接入方提供不同序列化规则时(如:内部前端用 epochMilli,外部 Feign 用字符串),可以注册多个 ObjectMapper Bean:

@Configuration
public class JacksonConfig {

    // 主 ObjectMapper:前端用,时间输出 epochMilli
    @Bean
    @Primary
    public ObjectMapper objectMapper() {
        // ... 注册 epochMilli 自定义序列化器
    }

    // API ObjectMapper:Feign 外部接口手动序列化用,时间输出字符串
    @Bean("apiObjectMapper")
    public ObjectMapper apiObjectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        JavaTimeModule m = new JavaTimeModule();
        m.addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE);
        m.addSerializer(LocalDateTime.class,
            new LocalDateTimeSerializer(
                DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        mapper.registerModule(m);
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        return mapper;
    }
}

在需要的地方注入指定实例:

@Service
public class SomeService {
    private final ObjectMapper apiObjectMapper;

    public SomeService(@Qualifier("apiObjectMapper") ObjectMapper apiObjectMapper) {
        this.apiObjectMapper = apiObjectMapper;
    }
}

注意@PrimaryObjectMapper 才是 Spring MVC HttpMessageConverter 使用的实例。多实例方案适合手动调用 objectMapper.writeValueAsString() 的场景,@ResponseBody 自动序列化永远走 Primary。


七、PropertyNamingStrategy:命名策略

// 驼峰(默认):userName → userName
objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.LOWER_CAMEL_CASE);

// 下划线:userName → user_name
objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);

// 大写驼峰:userName → UserName
objectMapper.setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE);

局部覆盖(类级,优先于全局策略):

@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
public class LegacyDTO { ... }

八、设计总结

全局(JacksonConfig)
├── LocalDateTime/LocalDate → epochMilli(东八区固定 Asia/Shanghai)
│     └── 服务对象:前端 JS,直接用毫秒时间戳,无时区歧义
├── 枚举反序列化 → 忽略大小写 + 未知值抛 AppException(UN_ERROR)
│     └── BeanDeserializerModifier 实现,优于 MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS
│         原因:可控制异常类型
└── FAIL_ON_UNKNOWN_PROPERTIES = false
      └── 接口字段新增时,旧版客户端不会因多余字段报错(向前兼容)

局部覆盖(部分 DTO 字段)
└── @JsonSerialize(using = LocalDateSerializer.class)
    + @JsonDeserialize(using = LocalDateDeserializer.class)
    + @JsonFormat(pattern = "yyyy-MM-dd")
      └── 服务对象:Feign 外部 Java 系统,需要字符串格式
          覆盖原理:字段级 @JsonSerialize 优先级高于 Module 注册的全局序列化器

三条核心原则

  1. @JsonFormat 不选序列化器,只配置序列化器。先确认选中的序列化器会读取它,@JsonFormat 才有意义。

  2. 全局自定义序列化器 + 局部 @JsonFormat 不兼容。必须配合 @JsonSerialize 在字段级切换回内置序列化器,或让自定义序列化器实现 ContextualSerializer

  3. @Primary ObjectMapper 是 Spring MVC 唯一入口。多实例只对手动序列化生效,HTTP 响应体的序列化始终走 Primary。