How to elegantly personalize Jackson in Spring Boot2

Overview

The original intention of writing this article is to understand how to serialize and deserialize the JSR 310 date and time system in Spring Boot2. There are two Spring MVC application scenarios:

  1. Use @RequestBody to obtain JSON parameters and encapsulate them into entity objects;
  2. Use @ResponseBody to convert the data returned to the front end into JSON data.

For some basic types of data such as Integer and String, Spring MVC can solve it through some built-in converters without users needing to care. However, for date and time types (such as LocalDateTime), due to the changeable formats, no built-in converters are available, so users need to do it themselves. It’s time to configure and process.

Reading this article assumes that the reader has a preliminary understanding of how to use Jackson.

test environment

This article uses Spring Boot2.6.6 version, and the locked Jackson version is as follows:

<jackson-bom.version> 2.13 .2 .20220328 </jackson-bom.version>

Jackson needs to introduce dependencies to handle JSR 310 date and time:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version> 2.13 .2 </version>
</dependency>

Spring Boot automatic configuration

In the spring-boot-autoconfigure package, Jackson is automatically configured:

package org.springframework.boot.autoconfigure.jackson;

@Configuration(proxyBeanMethods = false) 
@ConditionalOnClass(ObjectMapper.class) 
public  class  JacksonAutoConfiguration {
     // Detailed code omitted 
}

There is a piece of code that configures ObjectMapper

@Bean 
@Primary 
@ConditionalOnMissingBean 
ObjectMapper jacksonObjectMapper (Jackson2ObjectMapperBuilder builder) {
    return builder.createXmlMapper( false ).build();
}

You can see that ObjectMapper is built by Jackson2ObjectMapperBuilder.

Further down you will see the following code:

@Configuration(proxyBeanMethods = false) 
@ConditionalOnClass(Jackson2ObjectMapperBuilder.class) 
static  class  JacksonObjectMapperBuilderConfiguration {

   @Bean 
   @Scope("prototype") 
   @ConditionalOnMissingBean 
   Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder (ApplicationContext applicationContext,
         List<Jackson2ObjectMapperBuilderCustomizer> customizers) {
       Jackson2ObjectMapperBuilder  builder  =  new  Jackson2ObjectMapperBuilder ();
      builder.applicationContext(applicationContext);
      customize(builder, customizers);
      return builder;
   }

   private  void  customize (Jackson2ObjectMapperBuilder builder,
         List<Jackson2ObjectMapperBuilderCustomizer> customizers) {
       for (Jackson2ObjectMapperBuilderCustomizer customizer : customizers) {
         customizer.customize(builder);
      }
   }

}

It is found that Jackson2ObjectMapperBuilder is created here, and the customize(builder, customizers) method is called, passing in Lis<Jackson2ObjectMapperBuilderCustomizer> to customize the ObjectMapper.

Jackson2ObjectMapperBuilderCustomizer is an interface with only one method. The source code is as follows:

@FunctionalInterface 
public  interface  Jackson2ObjectMapperBuilderCustomizer {

   /**
    * Customize the JacksonObjectMapperBuilder.
    * @param jacksonObjectMapperBuilder the JacksonObjectMapperBuilder to customize
    */ 
   void  customize (Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder) ;

}

To put it simply, Spring Boot will collect all the Jackson2ObjectMapperBuilderCustomizer implementation classes in the container and uniformly set the Jackson2ObjectMapperBuilder to achieve customized ObjectMapper. Therefore, if we want to customize the ObjectMapper, we only need to implement the Jackson2ObjectMapperBuilderCustomizer interface and register it with the container.

Custom Jackson configuration class

Without further ado, let’s go straight to the code:

@Component 
public  class  JacksonConfig  implements  Jackson2ObjectMapperBuilderCustomizer , Ordered {

    /** Default date and time format*/ 
    private  final  String  dateTimeFormat  =  "yyyy-MM-dd HH:mm:ss" ;
     /** Default date format*/ 
    private  final  String  dateFormat  =  "yyyy-MM-dd" ;
     /* *Default time format*/ 
    private  final  String  timeFormat  =  "HH:mm:ss" ;

    @Override 
    public  void  customize (Jackson2ObjectMapperBuilder builder) {
         // Set the serialization and deserialization format of the java.util.Date time class
        builder.simpleDateFormat(dateTimeFormat);

        // JSR 310 date and time processing 
        JavaTimeModule  javaTimeModule  =  new  JavaTimeModule ();

        DateTimeFormatter  dateTimeFormatter  = DateTimeFormatter.ofPattern(dateTimeFormat);
        javaTimeModule.addSerializer(LocalDateTime.class, new  LocalDateTimeSerializer (dateTimeFormatter));
        javaTimeModule.addDeserializer(LocalDateTime.class, new  LocalDateTimeDeserializer (dateTimeFormatter));

        DateTimeFormatter  dateFormatter  = DateTimeFormatter.ofPattern(dateFormat);
        javaTimeModule.addSerializer(LocalDate.class, new  LocalDateSerializer (dateFormatter));
        javaTimeModule.addDeserializer(LocalDate.class, new  LocalDateDeserializer (dateFormatter));

        DateTimeFormatter  timeFormatter  = DateTimeFormatter.ofPattern(timeFormat);
        javaTimeModule.addSerializer(LocalTime.class, new  LocalTimeSerializer (timeFormatter));
        javaTimeModule.addDeserializer(LocalTime.class, new  LocalTimeDeserializer (timeFormatter));

        builder.modules(javaTimeModule);

        // Globally convert the Long type to String to solve the problem of precision loss of the Long type passed to the front end after serialization
        builder.serializerByType(BigInteger.class, ToStringSerializer.instance);
        builder.serializerByType(Long.class,ToStringSerializer.instance);
    }

    @Override 
    public  int  getOrder () {
         return  1 ;
    }
}

This configuration class implements three personalized configurations:

  1. Set the serialization and deserialization format of the java.util.Date time class;
  2. JSR 310 date and time processing;
  3. Globally convert the Long type to String to solve the problem of missing precision of the Long type passed to the front end after serialization.

Of course, readers can continue to customize other configurations according to their own needs.

test

JSR 310 date and time is used here for testing.

Create entity class User

@Data 
@NoArgsConstructor 
@AllArgsConstructor 
public  class  User {
     private Long id;
     private String name;
     private LocalDate localDate;
     private LocalTime localTime;
     private LocalDateTime localDateTime;
}

Create controller UserController

@RestController 
@RequestMapping("user") 
public  class  UserController {

    @PostMapping("test") 
    public User test ( @RequestBody User user) {
        System.out.println(user.toString());
        return user;
    }

}

Front-end parameter passing

{ 
  "id" :  184309536616640512 , 
  "name" :  "Bagua Program" , 
  "localDate" :  "2023-03-01" , 
  "localTime" :  "09:35:50" , 
  "localDateTime" :  "2023-03-01 09:35:50" 
}

Backend returns data

{ 
  "id" :  "184309536616640512" , 
  "name" :  "Basic Program" , 
  "localDate" :  "2023-03-01" , 
  "localTime" :  "09:35:50" , 
  "localDateTime" :  "2023-03 -01 09:35:50" 
}

You can see that whatever data is passed in to the front end will be returned to the back end. The only difference is that the id returned by the back end is a string, which can prevent precision loss problems in the front end (such as JavaScript).

At the same time, it also proves that LocalDateTime and other date and time types have returned normally after visiting the backend (not rejected, nor beaten and deformed by the backend, for example, turned into a timestamp and returned, causing my mother not to recognize it) .

Front-end confession rejected

If JacksonConfig is not configured, Spring MVC will report the following exception after trying the built-in converter to no avail:
JSON parse error: Cannot deserialize value of typejava.time.LocalDateTime

The data returned to the front end is as follows:

{ 
  "timestamp" :  "2023-03-01T09:53:02.158+00:00" , 
  "status" :  400 , 
  "error" :  "Bad Request" , 
  "path" :  "/user/test" 
}

You know, rejected.

Summarize

Core class ObjectMapper

ObjectMapper is the most important class of the jackson-databind module. It completes almost all functions of data processing.
Although Spring MVC performs a series of dazzling operations when processing the JSON parameters passed by the front end, the operation is as fierce as a tiger, and ultimately relies on ObjectMapper to complete serialization and deserialization. Therefore, you only need to customize the ObjectMapper provided by Spring Boot by default.

Do not override default configuration

We implement the Jackson2ObjectMapperBuilderCustomizer interface and register it with the container for personalized customization. Spring Boot will not overwrite the default ObjectMapper configuration, but merge and enhance it. Specifically, it will be sorted according to the Order priority of the Jackson2ObjectMapperBuilderCustomizer implementation class, so the above JacksonConfig The configuration class also implements the Ordered interface.

The default Jackson2ObjectMapperBuilderCustomizerConfiguration priority is 0, so if we want to override the configuration, just set the priority greater than 0.

Note: In the SpringBoot2 environment, do not inject custom ObjectMapper objects into the container, as this will overwrite the original ObjectMapper configuration!

QueryString format parameters

It should be noted that Jackson cannot solve the problem of QueryString format parameters, because Spring uses the Converter type conversion mechanism for such parameters, which is another way to bind parameters (sorry, Jackson did not help on this way) .
A custom parameter type converter is needed to handle date and time types, which requires another article to be introduced.