Overview

This article was originally written to understand how Spring Boot2 specifically serializes and deserializes the JSR 310 datetime system, Spring MVC application scenarios are as follows.

  1. using @RequestBody to read the JSON request body from the client and encapsulate it into a Java object.
  2. use @ResponseBody to serialize the object into JSON data and respond to the client.

For some basic types of data like Integer, String, etc., Spring MVC can solve it with some built-in converters without user concern, but for datetime types (e.g. LocalDateTime), due to the variable format, there are no built-in converters available, so you need to configure and handle it yourself.

Reading this article assumes that the reader has an initial understanding of how to use Jackson.

Test environment

This article uses Spring Boot version 2.6.6 and the Jackson version used is as follows.

1
<jackson-bom.version>2.13.2.20220328</jackson-bom.version>

Jackson needs to import the following dependencies to process JSR 310 datetime.

1
2
3
4
5
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.13.2</version>
</dependency>

Spring Boot autoconfiguration

Jackson is automatically configured in the spring-boot-autoconfigure package.

1
2
3
4
5
6
7
package org.springframework.boot.autoconfigure.jackson;

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

One piece of code configures the ObjectMapper.

1
2
3
4
5
6
@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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@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 the Jackson2ObjectMapperBuilder is created here and the customize(builder, customizers) method is called, passing in List<Jackson2ObjectMapperBuilderCustomizer> to customize the ObjectMapper.

Jackson2ObjectMapperBuilderCustomizer is an interface with only one method and the source code is as follows.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@FunctionalInterface
public interface Jackson2ObjectMapperBuilderCustomizer {

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

}

To put it simply, Spring Boot collects all the Jackson2ObjectMapperBuilderCustomizer implementation classes inside the container and unifies the Jackson2ObjectMapperBuilder settings to customize the ObjectMapper. So if we want to customize ObjectMapper, we just need to implement the Jackson2ObjectMapperBuilderCustomizer interface and register it to the container.

Customizing the Jackson configuration class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Component
public class JacksonConfig implements Jackson2ObjectMapperBuilderCustomizer, Ordered {

    /** Default Date 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 format of serialization and deserialization of the java.util.Date.
        builder.simpleDateFormat(dateTimeFormat);

        // JSR 310 Date 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);

        // global configuration for serializing Long types to String, which solves the problem of lost precision of JSs numeric types in the browser client.
        builder.serializerByType(BigInteger.class, ToStringSerializer.instance);
        builder.serializerByType(Long.class,ToStringSerializer.instance);
    }

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

This configuration class implements three types of personalized configurations.

  1. setting the format of serialization and deserialization of the java.util.Date class.
  2. JSR 310 datetime processing.
  3. global configuration for serializing Long types to String, which solves the problem of lost precision of JSs numeric types in the browser client.

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

Testing

Here the test is done with JSR 310 date and time.

Create a User class

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    private Long id;
    private String name;
    private LocalDate localDate;
    private LocalTime localTime;
    private LocalDateTime localDateTime;
}

Create a UserController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@RestController
@RequestMapping("user")
public class UserController {

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

}

Request body

1
2
3
4
5
6
7
{
  "id": 184309536616640512,
  "name": "spring boot",
  "localDate": "2023-03-01",
  "localTime": "09:35:50",
  "localDateTime": "2023-03-01 09:35:50"
}

Response Body

1
2
3
4
5
6
7
{
  "id": "184309536616640512",
  "name": "spring boot",
  "localDate": "2023-03-01",
  "localTime": "09:35:50",
  "localDateTime": "2023-03-01 09:35:50"
}

As you can see, the client post what data, the back-end response to what data. The only difference is that the id in the response json is now a string, which prevents JavaScript from losing precision.

We also see that the date and time types such as LocalDateTime are serialized and deserialized according to the format we specify.

Cannot deserialize value of type java.time.LocalDateTime

If JacksonConfig is not configured, Spring MVC will throw the following exception after trying the built-in converter without success.

1
JSON parse error: Cannot deserialize value of type java.time.LocalDateTime

At this point, the response to the client is as follows.

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

Summary

The core ObjectMapper class

ObjectMapper is one of the most important classes of the jackson-databind module, which performs almost all the functions of data processing. Spring MVC relies on ObjectMapper to handle client-side JSON request bodies, complete with serialization and deserialization. Therefore, it is only necessary to customize the ObjectMapper provided by Spring Boot by default.

Don’t override the default configuration

We customize by implementing the Jackson2ObjectMapperBuilderCustomizer interface and registering it to the container, Spring Boot does not override the default ObjectMapper configuration, but rather merges and enhances it, which will also be sorted according to the Jackson2ObjectMapperBuilderCustomizer implementation class Order priority, so the above JacksonConfig 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 SpringBoot2 environment, do not register custom ObjectMapper objects to the container, this will overwrite the original ObjectMapper configuration!

Reference: https://segmentfault.com/a/1190000043498796