一、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_TIMESTAMPS | true=时间写为数字,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;
}
}
注意:
@Primary的ObjectMapper才是 Spring MVCHttpMessageConverter使用的实例。多实例方案适合手动调用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 注册的全局序列化器
三条核心原则
-
@JsonFormat不选序列化器,只配置序列化器。先确认选中的序列化器会读取它,@JsonFormat才有意义。 -
全局自定义序列化器 + 局部
@JsonFormat不兼容。必须配合@JsonSerialize在字段级切换回内置序列化器,或让自定义序列化器实现ContextualSerializer。 -
@PrimaryObjectMapper 是 Spring MVC 唯一入口。多实例只对手动序列化生效,HTTP 响应体的序列化始终走 Primary。