• 为何你在用ObjectMapper时经常会出现反序列化字段是null的情形?
  • 发布于 2个月前
  • 417 热度
    0 评论
兄弟们,咱谁没跟 ObjectMapper 打过交道啊?每次跟 JSON 打交道,不是日期格式突然冒出个 “T” 让前端小姐姐追着问,就是 null 值莫名消失被测试怼 “接口漏字段”,更绝的是 —— 明明字段名对着呢,反序列化完字段全是 null,当时真想把键盘拍在桌上喊 “这玩意儿咋不按套路出牌”!其实啊,不是 ObjectMapper 难用,是咱没 get 到它在 SpringBoot 里的 “优雅姿势”。今天就跟大家唠唠,怎么把 ObjectMapper 用得顺风顺水,既不用在业务代码里堆一堆转换逻辑,又能避免那些让人头大的 bug,看完这篇,保准你想把之前的代码重构一遍(狗头)。

一、别再瞎 new ObjectMapper 了!SpringBoot 早帮你安排了
先问大家一个问题:你是不是写过这样的代码?
// 是不是你?
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(user);
User user = objectMapper.readValue(json, User.class);
要是你点头了,那咱得先纠正这个小习惯 ——别再每次用都 new ObjectMapper 了!SpringBoot 早就帮咱们做了自动配置,在JacksonAutoConfiguration里,已经默认创建了一个 ObjectMapper 实例,还帮咱们配了不少基础参数。你直接用@Autowired注入就行,既不用自己管理生命周期,还能跟 SpringBoot 的其他组件(比如消息转换器、接口返回值处理)无缝衔接。

不信你看,咱随便写个 Service:
@Service
public class UserService {
    // 直接注入,不用自己new!
    private final ObjectMapper objectMapper;
    // 构造器注入,Spring推荐姿势
    public UserService(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    public String getUserJson(User user) throws JsonProcessingException {
        // 直接用,省心!
        return objectMapper.writeValueAsString(user);
    }
}
有人可能会问:“我自己 new,想配啥配啥,不行吗?”还真不行!你自己 new 的 ObjectMapper,跟 SpringBoot 默认的不是一个实例。比如你在application.yml里配了全局日期格式,自己 new 的那个根本读不到这个配置,到时候就会出现 “我明明配了啊,怎么没生效” 的迷惑行为。更坑的是,要是你在 Controller 里返回对象,SpringBoot 会用它自己的 ObjectMapper 来序列化,你自己 new 的那个配置完全没用,等于白忙活。所以听我的,先把 “Autowired 注入 ObjectMapper” 这个习惯养成,咱再谈后续优化。

二、日期处理:从 “T 乱入” 到 “格式自由”
日期处理绝对是 ObjectMapper 的 “重灾区”,没有之一。上次我同事小王写了个用户列表接口,返回的日期是2024-05-20T13:14:00.000+08:00,前端小姐姐拿着截图来找他:“王哥,这日期里的‘T’是啥意思啊?是要我备注‘今天适合表白’吗?” 小王当场社死,后来查了半小时才知道,这是 ObjectMapper 默认的日期格式 ——ISO 8601 标准,但前端根本不认这个 “T”,还得转成yyyy-MM-dd HH:mm:ss才行。

2.1 全局配置:一招解决所有日期格式问题
最优雅的方式,就是在application.yml(或application.properties)里配全局日期格式,这样所有用 SpringBoot 默认 ObjectMapper 序列化的日期,都会按这个格式来,不用在每个字段上写注解。
# application.yml
spring:
  jackson:
    # 日期格式:全局统一成 yyyy-MM-dd HH:mm:ss
    date-format: yyyy-MM-dd HH:mm:ss
    # 时区:必须配!不然会有8小时时差
    time-zone: GMT+8
    # 针对LocalDateTime等JDK8新日期类型的配置
    deserialization:
      adjust-dates-to-context-time-zone: false
这里有个坑必须提醒大家:时区一定要配! 要是没配time-zone: GMT+8,ObjectMapper 会默认用 UTC 时区,结果就是返回的日期比实际少 8 小时,比如你本地是 2024-05-20 13:14,序列化后变成 2024-05-20 05:14,到时候前端以为你接口返回的是昨天的数据,能把你怼到怀疑人生。要是你用的是 JDK8 的新日期类型(LocalDateTime、LocalDate),光配date-format还不够,因为date-format是针对java.util.Date的。这时候得加个依赖,让 Jackson 支持 JDK8 日期类型:
<!-- pom.xml 加这个依赖 -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <!-- SpringBoot父工程已经管理了版本,不用自己写version -->
</dependency>
加了依赖后,再在application.yml里配 JDK8 日期的格式:
spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: GMT+8
    # 配置LocalDateTime的格式
    Java-time-module:
      date-time-formatter: yyyy-MM-dd HH:mm:ss
这样不管是Date还是LocalDateTime,序列化后都是yyyy-MM-dd HH:mm:ss,前端再也不用问你 “T 是啥” 了。

2.2 局部调整:个别字段要特殊格式怎么办?
有时候全局格式是yyyy-MM-dd HH:mm:ss,但某个字段需要yyyy-MM-dd(比如用户的生日,不用时分秒),这时候用@JsonFormat注解就能搞定,局部配置会覆盖全局配置,非常灵活。
public class User {
    private Long id;
    private String userName;
    // 生日只需要日期,局部配置格式
    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
    private LocalDate birthday;
    // 注册时间用全局格式,但这里可以显式指定(可选)
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
    private LocalDateTime registerTime;
    // getter/setter 省略
}
这里要注意:@JsonFormat里的timezone也得写,不然会继承全局的时区,但要是你局部格式没写时分秒,时区配置不影响结果,不过写上更稳妥,避免踩坑。

三、null 值处理:留还是删?别让前端跟你吵架
另一个高频痛点:null 值消失。比如你返回的 User 对象里,nickName是 null,序列化后 JSON 里直接没这个字段了,前端拿到数据一看:“哎?nickName 呢?你接口漏字段了吧!” 你查代码发现字段明明在,就是值为 null,这时候才反应过来 ——ObjectMapper 默认会忽略 null 值。

3.1 全局配置:决定 null 值要不要显示
想让所有 null 值都显示在 JSON 里,直接在application.yml里配:
spring:
  jackson:
    # 序列化时包含所有字段,包括null值
    serialization:
      include-null-map-values: true
    # 更直接的配置:所有null值都包含
    default-property-inclusion: ALWAYS
default-property-inclusion有四个可选值,咱解释一下:
ALWAYS:不管是不是 null,都包含字段(最常用)
NON_NULL:忽略 null 值(默认)
NON_EMPTY:忽略 null、空字符串("")、空集合(比如 [])
NON_DEFAULT:忽略字段值等于默认值的(比如 int 字段 0,boolean 字段 false)
比如你想忽略空字符串和空集合,但保留 null 值,就配NON_EMPTY:
spring:
  jackson:
    default-property-inclusion: NON_EMPTY
这样一来,nickName: null会显示,address: ""(空字符串)和hobbies: [](空集合)会被忽略,很灵活。

3.2 局部控制:个别字段特殊处理
要是全局配置是ALWAYS(显示所有 null),但某个字段是 null 时不想显示,比如password字段(用户没传的话,null 值没必要返回),用@JsonInclude注解就行:
public class User {
    private Long id;
    privateString userName;

    // 要是password是null,序列化时忽略这个字段
    @JsonInclude(JsonInclude.Include.NON_NULL)
    privateString password;

    // 要是nickName是空字符串或null,都忽略
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    privateString nickName;

    // getter/setter 省略
}
@JsonInclude的取值和全局配置的default-property-inclusion对应,局部配置会覆盖全局,比如全局是ALWAYS,但password用了NON_NULL,那password为 null 时就会被忽略,其他字段还是显示 null。这里插个小技巧:要是你想让某个字段 “永远显示”,哪怕是 null,就用@JsonInclude(JsonInclude.Include.ALWAYS),不管全局怎么配,这个字段都会显示,适合那些前端必须拿到的字段(比如userId,哪怕是 null,前端也要知道这个字段存在)。

四、字段名映射:camelCase 和 snake_case 的 “和解方案”
Java 里我们习惯用驼峰命名(camelCase),比如userName、registerTime,但前端有时候用下划线命名(snake_case),比如user_name、register_time,这时候反序列化就会出问题 —— 前端传user_name,你用userName接收,结果userName是 null,因为字段名对不上。以前我见过有人这么解决:在每个字段上写@JsonProperty注解,指定下划线的字段名:
public class User {
    @JsonProperty("user_id")
    private Long userId;

    @JsonProperty("user_name")
    private String userName;

    @JsonProperty("register_time")
    private LocalDateTime registerTime;

    // getter/setter 省略
}
这么写能解决问题,但字段多了的话,每个都要加注解,手都酸了,而且容易漏写。其实 SpringBoot 里配个全局字段命名策略,就能让 camelCase 自动转 snake_case,不用写一个注解。

4.1 全局配置:驼峰自动转下划线
在application.yml里加一行配置:
spring:
  jackson:
    # 字段命名策略:驼峰转下划线(snake_case)
    property-naming-strategy: SNAKE_CASE
这样一来,你 Java 类里的userName,序列化后会变成user_name;前端传user_name,反序列化时也会自动映射到userName,完美!除了SNAKE_CASE,还有其他命名策略,比如:
CAMEL_CASE_TO_LOWER_CASE_WITH_UNDERSCORES:和SNAKE_CASE一样,驼峰转下划线(老版本的写法,现在推荐用SNAKE_CASE)
PASCAL_CASE_TO_CAMEL_CASE:帕斯卡命名(首字母大写,比如 UserName)转驼峰(userName)
LOWER_CASE:所有字母小写,比如userName转username
比如你前端用帕斯卡命名(UserName),就配PASCAL_CASE_TO_CAMEL_CASE,Java 里的userName会自动和前端的UserName映射。

4.2 局部特殊:个别字段不按全局规则来
要是全局是SNAKE_CASE,但某个字段需要特殊命名,比如userId要映射到user_id,但phoneNumber要映射到mobile(不是phone_number),这时候用@JsonProperty注解覆盖全局规则:
public class User {
    // 全局是SNAKE_CASE,这里会自动转user_id,不用写@JsonProperty
    private Long userId;
    // 堆代码 duidaima.com
    // 特殊情况:phoneNumber要映射到mobile,用@JsonProperty指定
    @JsonProperty("mobile")
    private String phoneNumber;

    // getter/setter 省略
}
这样既保留了全局的便捷性,又能处理特殊字段,优雅!

五、复杂类型:List变 Map?TypeReference 救场
处理 List、Map 这些复杂类型时,很多人会踩一个坑:反序列化 List 的时候,拿到的不是List<User>,而是List<LinkedHashMap>,遍历的时候强转User直接报错。比如你写了这样的代码:
// 前端传的JSON数组
String userJson = "[{\"user_name\":\"张三\",\"age\":20},{\"user_name\":\"李四\",\"age\":22}]";
// 想反序列化成List<User>
List<User> userList = objectMapper.readValue(userJson, List.class);
// 遍历的时候强转,报错!
for (User user : userList) { // ClassCastException: LinkedHashMap cannot be cast to User
    System.out.println(user.getUserName());
}
为啥会这样?因为 Java 的 “泛型擦除”—— 编译的时候List<User>会变成List,ObjectMapper 不知道你要反序列化成User对象,就默认转成LinkedHashMap(JSON 对象转 Map)。这时候就得用TypeReference来告诉 ObjectMapper:“我要的是List<User>,不是普通的 List!”

5.1 用 TypeReference 处理 List
正确的写法是这样的:
String userJson = "[{\"user_name\":\"张三\",\"age\":20},{\"user_name\":\"李四\",\"age\":22}]";
// 用TypeReference指定泛型类型
List<User> userList = objectMapper.readValue(userJson, new TypeReference<List<User>>() {});
// 遍历,没问题!
for (User user : userList) {
    System.out.println(user.getUserName()); // 正常输出:张三、李四
}
TypeReference是 Jackson 提供的一个抽象类,通过匿名内部类的方式,保留了泛型的具体类型(因为匿名内部类会在编译时生成 class 文件,泛型信息不会被擦除),ObjectMapper 就能知道要转成List<User>了。

5.2 处理嵌套泛型:比如 Map<String, List>
要是更复杂一点,比如 JSON 是{"male":[{"user_name":"张三"}], "female":[{"user_name":"李四"}]},想转成Map<String, List<User>>,同样用TypeReference:
String json = "{\"male\":[{\"user_name\":\"张三\",\"age\":20}], \"female\":[{\"user_name\":\"李四\",\"age\":22}]}";
// 嵌套泛型也能搞定
Map<String, List<User>> genderMap = objectMapper.readValue(json, new TypeReference<Map<String, List<User>>>() {});
// 取值
List<User> maleUsers = genderMap.get("male");
System.out.println(maleUsers.get(0).getUserName()); // 张三
这里要注意:TypeReference的匿名内部类不能复用,比如你不能写个public class UserListTypeReference extends TypeReference<List<User>> {}然后反复用,虽然能跑,但可能会有线程安全问题(Jackson 官方不推荐),最好每次用的时候都 new 一个匿名内部类,虽然代码看起来重复,但安全第一。

六、自定义序列化:让性别 1→“男”,不用再写 if-else
有时候我们需要对字段做特殊转换,比如数据库里存的性别是 1(男)、2(女),但接口要返回 “男”、“女”;或者金额存的是分(比如 1000 分 = 10 元),接口要返回元(10.00 元)。要是在业务代码里写 if-else 转换,比如:
// 不优雅的写法:业务代码里混着格式转换
public UserVO convert(User user) {
    UserVO vo = new UserVO();
    vo.setUserName(user.getUserName());
    // 性别转换:1→男,2→女
    if (user.getGender() == 1) {
        vo.setGender("男");
    } elseif (user.getGender() == 2) {
        vo.setGender("女");
    } else {
        vo.setGender("未知");
    }
    // 金额转换:分→元
    vo.setBalance(user.getBalance() / 100.00);
    return vo;
}
这样写能实现功能,但业务代码和格式转换混在一起,要是有多个地方需要转换,就会写一堆重复代码,维护起来麻烦。这时候用 ObjectMapper 的自定义序列化器,就能把转换逻辑抽离出来,一劳永逸。

6.1 写个自定义序列化器:处理性别转换
首先,写一个序列化器,继承StdSerializer,重写serialize方法:
// 性别序列化器:Integer(1/2)→ String(男/女)
publicclass GenderSerializer extends StdSerializer<Integer> {
    // 必须写无参构造器,不然Jackson会报错
    public GenderSerializer() {
        this(null);
    }
    protected GenderSerializer(Class<Integer> t) {
        super(t);
    }
    // 核心方法:转换逻辑
    @Override
    public void serialize(Integer gender, JsonGenerator gen, SerializerProvider provider) throws IOException {
        // 转换逻辑:1→男,2→女,其他→未知
        String genderStr = switch (gender) {
            case1 -> "男";
            case2 -> "女";
            default -> "未知";
        };
        // 把转换后的值写入JSON
        gen.writeString(genderStr);
    }
}
然后,在需要转换的字段上用@JsonSerialize注解指定这个序列化器:
public class User {
    private Long id;
    private String userName;
    // 用自定义序列化器处理gender字段
    @JsonSerialize(using = GenderSerializer.class)
    private Integer gender; // 1→男,2→女

    // getter/setter 省略
}
这样一来,序列化 User 对象时,gender: 1会自动变成"gender": "男",不用在业务代码里写 if-else 了,清爽!

6.2 自定义反序列化器:前端传 “男”→后端存 1
要是前端传的是 “男”、“女”,后端需要转成 1、2 存数据库,就需要自定义反序列化器,继承StdDeserializer:
// 性别反序列化器:String(男/女)→ Integer(1/2)
publicclass GenderDeserializer extends StdDeserializer<Integer> {
    public GenderDeserializer() {
        this(null);
    }
    protected GenderDeserializer(Class<?> vc) {
        super(vc);
    }
    // 核心方法:反序列化逻辑
    @Override
    public Integer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
        // 拿到前端传的字符串(比如“男”)
        String genderStr = p.getText();
        // 转换逻辑:男→1,女→2,其他→0(未知)
        returnswitch (genderStr) {
            case"男" -> 1;
            case"女" -> 2;
            default -> 0;
        };
    }
}
然后在字段上用@JsonDeserialize注解指定:
public class User {
    private Long id;
    private String userName;

    // 序列化用GenderSerializer,反序列化用GenderDeserializer
    @JsonSerialize(using = GenderSerializer.class)
    @JsonDeserialize(using = GenderDeserializer.class)
    private Integer gender;

    // getter/setter 省略
}
这样前端传"gender": "男",反序列化后gender就是 1;后端存 1,序列化后返回"gender": "男",完美闭环。

6.3 全局注册自定义序列化器
要是很多字段都需要用同一个序列化器(比如所有性别字段),每个字段都加@JsonSerialize太麻烦,这时候可以全局注册序列化器。在 SpringBoot 里,写一个Jackson2ObjectMapperBuilderCustomizer的 Bean,把自定义序列化器注册进去:
@Configuration
publicclass JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        return builder -> {
            // 全局注册性别序列化器:所有Integer类型的gender字段都用这个序列化器
            builder.serializerByType(Integer.class, new GenderSerializer());
            // 全局注册性别反序列化器
            builder.deserializerByType(Integer.class, new GenderDeserializer());

            // 要是只想针对特定字段(比如字段名叫gender),可以用Module
            SimpleModule module = new SimpleModule();
            // 这里的“gender”是字段名,指定这个字段用GenderSerializer
            module.addSerializer("gender", new GenderSerializer());
            module.addDeserializer("gender", new GenderDeserializer());
            builder.modules(module);
        };
    }
}
这里有两种方式:
serializerByType:按类型注册,比如所有Integer类型的字段都用这个序列化器(适合所有同类型字段都需要转换的场景)
addSerializer(字段名, 序列化器):按字段名注册,只有指定字段名的字段才用这个序列化器(适合特定字段的场景)
根据自己的需求选就行,全局注册后,就不用在每个字段上写注解了,更高效。

七、SpringBoot 高级配置:Jackson2ObjectMapperBuilderCustomizer 才是王道
前面我们讲了很多配置,比如全局日期格式、字段命名策略、自定义序列化器,有些是在application.yml里配的,有些是用 Bean 配置的。其实 SpringBoot 推荐用Jackson2ObjectMapperBuilderCustomizer来统一管理所有 ObjectMapper 的配置,这样所有配置都在一个地方,方便维护。Jackson2ObjectMapperBuilderCustomizer是一个函数式接口,通过它可以自定义Jackson2ObjectMapperBuilder,而Jackson2ObjectMapperBuilder又会用来创建 ObjectMapper 实例,所以用它配置,能覆盖所有 ObjectMapper 的参数。

咱写一个完整的配置类,把前面讲的痛点解决方案都整合进去:
@Configuration
publicclass JacksonConfig {

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
        // 函数式接口,用lambda表达式实现
        return builder -> {
            // 1. 日期配置
            builder.dateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")) // Date类型格式
                  .timeZone(TimeZone.getTimeZone("GMT+8")) // 时区
                  .modules(new JavaTimeModule() // JDK8日期类型支持
                          .addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
                          .addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))));

            // 2. null值处理
            builder.serializationInclusion(JsonInclude.Include.ALWAYS) // 显示所有null值
                  .featuresToEnable(SerializationFeature.INDENT_OUTPUT) // 格式化JSON(开发环境用,生产环境关闭)
                  .featuresToDisable(SerializationFeature.WRITE_NULL_MAP_VALUES); // 忽略Map中的null值(可选)

            // 3. 字段命名策略
            builder.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); // 驼峰转下划线

            // 4. 自定义序列化器/反序列化器
            SimpleModule module = new SimpleModule();
            module.addSerializer(Integer.class, new GenderSerializer()) // 性别序列化
                  .addDeserializer(Integer.class, new GenderDeserializer()) // 性别反序列化
                  .addSerializer(Long.class, new MoneySerializer()) // 金额序列化(分→元)
                  .addDeserializer(Long.class, new MoneyDeserializer()); // 金额反序列化(元→分)
            builder.modules(module);

            // 5. 其他配置:比如允许单引号、允许非标准JSON格式
            builder.featuresToEnable(
                    JsonParser.Feature.ALLOW_SINGLE_QUOTES, // 允许JSON里用单引号(比如'user_name':'张三')
                    JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, // 允许字段名不加引号(比如user_name:'张三')
                    DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT // 空字符串转null
            );
        };
    }
    // 堆代码 duidaima.com
    // 金额序列化器:Long(分)→ Double(元)
    staticclass MoneySerializer extends StdSerializer<Long> {
        public MoneySerializer() {
            this(null);
        }
        protected MoneySerializer(Class<Long> t) {
            super(t);
        }
        @Override
        public void serialize(Long money, JsonGenerator gen, SerializerProvider provider) throws IOException {
            // 分转元,保留两位小数
            gen.writeNumber(BigDecimal.valueOf(money).divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).doubleValue());
        }
    }

    // 金额反序列化器:Double(元)→ Long(分)
    staticclass MoneyDeserializer extends StdDeserializer<Long> {
        public MoneyDeserializer() {
            this(null);
        }
        protected MoneyDeserializer(Class<?> vc) {
            super(vc);
        }
        @Override
        public Long deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
            // 元转分,四舍五入
            Double moneyDouble = p.getDoubleValue();
            return BigDecimal.valueOf(moneyDouble).multiply(new BigDecimal(100)).setScale(0, RoundingMode.HALF_UP).longValue();
        }
    }
}
这个配置类整合了:
.日期处理(Date 和 LocalDateTime 都搞定)
.null 值显示
.字段名驼峰转下划线
.性别和金额的自定义序列化 / 反序列化
.允许单引号、空字符串转 null 等友好配置
这样一来,所有 ObjectMapper 的配置都在一个地方,后续要修改某个配置,直接改这里就行,不用到处找,非常优雅。

这里提个小建议:开发环境可以开启SerializationFeature.INDENT_OUTPUT(格式化 JSON),方便调试;生产环境要关闭,因为格式化会增加 JSON 的体积,影响接口性能。

八、性能优化:ObjectMapper 线程安全,别再 “买杯子” 了
之前我们说过 “别瞎 new ObjectMapper”,除了配置不生效的问题,还有性能问题。ObjectMapper 的创建成本很高,它需要加载很多模块、初始化序列化器 / 反序列化器,要是每次用都 new 一个,就像每次喝水都新买个杯子,喝完就扔,太浪费资源了。而且 ObjectMapper 是线程安全的!只要初始化后不修改它的配置(比如不调用setDateFormat、registerModule这些方法),多个线程同时用它序列化 / 反序列化,完全没问题。

所以在 SpringBoot 里,最佳实践是:
1.用@Autowired注入 SpringBoot 自动配置的 ObjectMapper(或者自己用Jackson2ObjectMapperBuilderCustomizer配置的)
2.不要每次用都 new ObjectMapper
3.不要在多线程环境下修改 ObjectMapper 的配置
比如你写个工具类,也应该注入 ObjectMapper,而不是自己 new:
@Component
publicclass JsonUtils {
    privatefinal ObjectMapper objectMapper;
    // 注入,不是new!
    public JsonUtils(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }
    // 序列化
    public <T> String toJson(T obj) throws JsonProcessingException {
        return objectMapper.writeValueAsString(obj);
    }
    // 反序列化
    public <T> T fromJson(String json, Class<T> clazz) throws JsonProcessingException {
        return objectMapper.readValue(json, clazz);
    }
    // 反序列化复杂类型(List、Map)
    public <T> T fromJson(String json, TypeReference<T> typeReference) throws JsonProcessingException {
        return objectMapper.readValue(json, typeReference);
    }
}
这样工具类里的 ObjectMapper 是单例的,性能好,而且配置和 SpringBoot 全局一致,不会出现配置不生效的问题。

九、异常处理:JSON 错了别返回 500,友好点
最后再聊聊异常处理。当 ObjectMapper 序列化 / 反序列化出错时(比如 JSON 格式错误、字段类型不匹配),会抛出JsonProcessingException(序列化)或JsonMappingException(反序列化)。要是不处理这些异常,SpringBoot 会默认返回 500 错误,前端看到 “服务器内部错误”,根本不知道哪里错了。咱得捕获这些异常,返回友好的提示,比如 “JSON 格式错误,请检查参数”。

9.1 全局异常处理:用 @RestControllerAdvice
写一个全局异常处理器,捕获 Jackson 相关的异常:
@RestControllerAdvice
publicclass GlobalExceptionHandler {

    // 捕获序列化异常(比如对象里有循环引用)
    @ExceptionHandler(JsonProcessingException.class)
    public ResponseEntity<ErrorResult> handleJsonProcessingException(JsonProcessingException e) {
        ErrorResult result = new ErrorResult(
                HttpStatus.BAD_REQUEST.value(),
                "JSON序列化失败:" + e.getMessage()
        );
        returnnew ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
    }

    // 捕获反序列化异常(比如JSON格式错、字段类型不匹配)
    @ExceptionHandler(JsonMappingException.class)
    public ResponseEntity<ErrorResult> handleJsonMappingException(JsonMappingException e) {
        // 提取错误字段(比如哪个字段类型不匹配)
        String field = e.getPath().stream()
                .map(JsonMappingException.Reference::getFieldName)
                .findFirst()
                .orElse("未知字段");
        String message = "JSON反序列化失败:字段[" + field + "]" + e.getOriginalMessage();
        ErrorResult result = new ErrorResult(
                HttpStatus.BAD_REQUEST.value(),
                message
        );
        returnnew ResponseEntity<>(result, HttpStatus.BAD_REQUEST);
    }

    // 错误响应体
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    publicstaticclass ErrorResult {
        private Integer code; // 错误码
        private String message; // 错误信息
    }
}
这样一来,当出现 JSON 错误时,会返回:
{
  "code": 400,
  "message": "JSON反序列化失败:字段[age]Cannot deserialize value of type `java.lang.Integer` from String \"二十\": not a valid Integer value"
}
前端能清楚知道是哪个字段错了,错在哪里,不用再跟后端反复沟通 “我传的参数没问题啊”,效率高多了。

十、总结:优雅使用 ObjectMapper 的 9 个要点
唠了这么多,最后总结一下,SpringBoot 里优雅用 ObjectMapper 的核心就是这 9 点:
.别瞎 new:用@Autowired注入 SpringBoot 自动配置的 ObjectMapper,别自己 new;
.全局配置优先:日期格式、null 值处理、字段命名策略,先在application.yml或Jackson2ObjectMapperBuilderCustomizer里配全局的,减少重复代码;
.局部配置补充:个别字段特殊需求,用@JsonFormat、@JsonInclude、@JsonProperty等注解覆盖全局;
.复杂类型用 TypeReference:反序列化 List、Map 等泛型类型,一定要用new TypeReference<>() {};
.自定义序列化抽离逻辑:特殊转换(比如性别、金额)用自定义序列化器,别在业务代码里堆 if-else;
.全局注册序列化器:多个字段用同一个序列化器,全局注册比每个字段加注解更高效;
.线程安全要记住:ObjectMapper 是线程安全的,初始化一次全局用,别频繁 new;
.开发生产环境区分:开发环境开启 JSON 格式化,生产环境关闭,兼顾调试和性能;
.异常处理要友好:捕获 Jackson 异常,返回明确的错误信息,别让前端猜。

其实 ObjectMapper 这玩意儿,你用顺了之后会发现,它比你想象中灵活多了。以前踩的那些坑,大多是因为没搞懂 SpringBoot 的自动配置逻辑,或者没掌握它的高级用法。
用户评论