java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot序列化时间字段

在SpringBoot接口中正确地序列化时间字段的方法

作者:编码的熊十二

文章主要介绍在 Spring Boot 接口中正确序列化时间字段的方法,包括 Java 中Date和LocalDateTime类型的区别,JSON 序列化和请求参数中时间字段的处理,如时间字符串的格式配置、时间戳的使用及相关配置,还提到了在 Swagger UI 中的类型设置,需要的朋友可以参考下

在 Java 项目中处理时间序列化从来就不是容易的事。一方面要面临多变的时间格式,年月日时分秒,毫秒,纳秒,周,还有讨厌的时区,稍不注意就可能收获一堆异常,另一方面,Java 又提供了 DateLocalDateTime 两个版本的时间类型,二者分别对应着不同的序列化配置,光是弄清楚这些配置,就是一件麻烦事。但是在大多数时候,我们又不得不和这一堆配置打交道。

作为开始,让我们来了解一下可用的配置和相应的效果。

时间字符串配置

准备一个简单接口来展示效果。

@Slf4j
@RestController
@RequestMapping("/boom")
public class BoomController {

    @Operation(summary = "boom")
    @GetMapping
    public BoomData getBoomData() {
        return new BoomData(Clock.systemDefaultZone());
    }

    @Operation(summary = "boom")
    @PostMapping
    public BoomData postBoomData(@RequestBody BoomData boomData) {
        log.info("boomData: {}", boomData);
        return boomData;
    }
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class BoomData {

    private Date date;
    private LocalDateTime localDateTime;
    private LocalDate localDate;
    private LocalTime localTime;

    public BoomData(Clock clock) {
        this.date = new Date(clock.millis());
        this.localDateTime = LocalDateTime.now(clock);
        this.localDate = LocalDate.now(clock);
        this.localTime = LocalTime.now(clock);
    }
}

上面涉及两种时间类型:

两种类型在记录的信息方面有一点区别:

了解了两个版本时间类型的区别,再看它们的序列化差异。

JSON 序列化

调用 GET 接口,得到默认的序列化结果。

{
  "date": "2024-10-10T21:07:08.781+08:00",
  "localDateTime": "2024-10-10T21:07:08.781283",
  "localDate": "2024-10-10",
  "localTime": "21:07:08.781263"
}

默认配置下,时间字段被序列化为时间字符串,但格式不尽相同。Spring Boot 使用 Jackson 进行 JSON 序列化,对不同的时间类型有不同的格式化规则:

所谓 ISO 标准,指的是 ISO 8601 标准,一种专门处理日期时间格式的国际标准。将时间日期组合按 yyyy-MM-dd'T'HH:mm:ss.SSSXXX 格式处理,比如 2024-10-10T21:07:08.781+08:00,其中字母 T 为日期和时间分隔符,日期表示为年-月-日,时间表示为时:分:秒.毫秒。格式中的 XXX 指的是时区信息,对于东 8 区,表示为 +08:00

默认情况下,调用 POST 接口,也需要保证 body 中的 JSON 串按照 ISO 8601 的格式处理时间字段,才能正常反序列化,否则 Spring Boot 会抛出异常。当然,时间格式的要求也没那么严格,可以省略时区、微秒、毫秒、秒,都能正常反序列化,但 T 不能省略,年月日时分不能省略。

在接口调用两端统一标准时,ISO 8601 表现不坏,但是,碰到国内互联网偏爱的 yyyy-MM-dd HH:mm:ss 格式,就会收获一个 HttpMessageNotReadableException,JVM 会提示你 JSON parse error: Cannot deserialize value of type XXX ...

如果想要加入 yyyy-MM-dd HH:mm:ss 大家庭,最简单的办法是使用 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")。@JsonFormat 注解用于指定时间类型的序列格式,对 Date 类型和 LocalDateTime 类型都有效。

public class BoomData {

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date date;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime localDateTime;
    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate localDate;
    @JsonFormat(pattern = "HH:mm:ss")
    private LocalTime localTime;

}

此时能 GET 到满足格式的时间字符串

{
  "date": "2024-11-20 15:15:57",
  "localDateTime": "2024-11-20 23:15:57",
  "localDate": "2024-11-20",
  "localTime": "23:15:57"
}

POST 请求也正常处理。

看样子 @JsonFormat 效果不坏。问题是,稍微有点繁琐,每个时间字段都要配置一遍。幸运的是,spring boot 支持全局设置 Jackson 时间序列化格式:

spring:
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss # 全局时间格式
    time-zone: GMT+8 # 指定默认时区,除非时间字段已指定时区,否则 JSON 序列化时都会使用此时区

更加幸运的是,@JsonFormat 优先级比全局配置更高,让我们可以实现某些要求特殊格式的需求。

似乎只要组合 spring.jackson.date-format 和 @JsonFormat,我们就可以无所不能了。没有人比我更懂时间序列化!

可惜的是,spring.jackson.date-format 不支持新版时间类型。是的,在 2024 年,距离 java.time 包发布已经十年了,Spring 的序列化配置仍然不支持 LocalDateTime 类型。如果你要序列化 LocalDateTime 类型,最简单的办法就是使用 @JsonFormat。因为 @JsonFormat 是 Jackson 提供的注解。Spring 对此毫无作为。

发完牢骚,考虑如何全局配置 LocalDateTime 的格式化规则。方案有很多种,最简单的就是明确地告诉 Jackson,LocalDateTime 等类型按照某某格式序列化和反序列化。

// 大概是 JacksonConfig 之类的类
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {

    return builder -> {

        // formatter
        DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        DateTimeFormatter dateTimeFormatter =  DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");

        // deserializers
        builder.deserializers(new LocalDateDeserializer(dateFormatter));
        builder.deserializers(new LocalDateTimeDeserializer(dateTimeFormatter));
        builder.deserializers(new LocalTimeDeserializer(timeFormatter));

        // serializers
        builder.serializers(new LocalDateSerializer(dateFormatter));
        builder.serializers(new LocalDateTimeSerializer(dateTimeFormatter));
        builder.serializers(new LocalTimeSerializer(timeFormatter));
    };
}

上述代码为三种类型构建了不同的 DateTimeFormatter(java.time 包提供的格式化工具,线程安全),然后为每种类型绑定序列化器(Serializer)和反序列化器(Deserializer)。

现在 Local 系统的日期类型就跟 Date 表现一致了。

总结一下,在 JSON 序列化时:

但此时还没结束,也并非结束的开始,只是开始的结束~

请求参数

除了 JSON 序列化,还有一种场景,也会涉及时间序列化。那就是请求参数中的时间字段,最常见的就是 Controller 方法中没有用 @RequestBody 标记的对象参数,比如 GET 请求,比如表单提交(application/x-www-form-urlencoded)的 POST 请求。

为了便于展示,在 BoomController 中添加一个新的接口方法。

@GetMapping("query")
public BoomData queryBoomData(BoomData boomData) {
    log.info("boomData: {}", boomData);
    return boomData;
}

一个比较常用的 Query 接口的写法。试着调用一下。

GET http://localhost:8080/boom/query?localDateTime=2024-10-10T21:07:08.781283&date=2024-10-10T21:07:08.781+08:00

报错,field 'date': rejected value [2024-10-10T21:07:08.781+08:00]。

再试试

GET http://localhost:8080/boom/query?localDateTime=2024-10-10T21:07:08.781283&date=2024-10-10 21:07:08

还是报错,field 'date': rejected value [2024-10-10 21:07:08]。

什么格式才能不报错?

GET http://localhost:8080/boom/query?localDateTime=2024-10-10T21:07:08.781283&date=10/10/2024 21:07:08

没错,要用 dd/MM/yyyy 的格式。因为请求参数不归 JSON 序列化管,而是由 Spring MVC 处理。Spring MVC 默认的 Date 类型格式就是 dd/MM/yyyy

要修改也简单,@DateTimeFormat,Spring 提供,专门处理时间参数格式化。

@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date date;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate localDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalTime localTime;

现在,正常处理请求 http://localhost:8080/boom/query?localDateTime=2024-10-10 21:07:08&date=2024-10-10 21:07:08&localDate=2024-10-10&localTime=11:09:15

又到了寻找全局配置的时间了。

spring:
  mvc:
    format:
      date: yyyy-MM-dd HH:mm:ss # 对 Date 和 LocalDate 类型有效,LocalDate 会忽略时间部分
      time: HH:mm:ss # 对 LocalTime 和 OffsetTime 有效
      date-time: yyyy-MM-dd HH:mm:ss # LocalDateTime, OffsetDateTime, and ZonedDateTime

按需选择即可。

总结一下,对于 GET 请求参数中的时间字段,和表单提交 POST 请求中的时间字段,可以通过 spring.mvc.format.date/time/date-time 来配置全局格式。

对于不使用全局配置的场景,用 @DateTimeFormat 指定单独的时间格式。

一起来用时间戳吧

以上是使用时间字符串传递时间的情况,接下来,我们讨论一下用时间戳格式。

先理解一下有关时间戳的概念:

指导了上述 3 个概念,时间戳的含义就容易解释了,从 Unix 纪 元开始经过的毫秒数(或秒数,计算机常用毫秒)。把时间想象为一条长长的坐标轴,0 的位置是 Unix 纪 元,在那之后,真实世界的每一毫秒,都对应时间轴上的一个点。

时间戳用整数表示,一个长整数,具备时间字符串一样的功能。因此,也可以用时间戳来传递时间信息。

如果我信誓旦旦地宣称时间戳优于时间字符串,肯定是十分主观的判断,但在接口中使用时间戳确实有一些亮晶晶的优点。

一些不可忽视的缺点:

用 long 型时间戳也不需要考虑序列化问题,大多数平台都可以妥善处理 long 类型的序列化。但有些时候,在代码中用 Date 和 LocalDateTime 等明确的类型还是比 long 更方便。所以可能有这么一个需求:在代码中使用时间类型,在序列化时使用时间戳。也就是在 DTO 类中用 Date,在 JSON 字符串中用 long。

和使用时间字符串类型,这个需求也分为两种情况:

二者要分开处理。

JSON 序列化中的时间戳

Spring 提供了一个配置项,控制 Jackson 在序列化时将时间类型处理为时间戳。

spring.jackson.serialization.write-dates-as-timestamps=true

此时,GET 请求中的 date 就会变成了 "date": 1728572627475,POST 时也能正确地识别时间戳。

但是,只有 Date 才有这种优渥的待遇,java.time 包的类型仍然面临自己动手丰衣足食的窘境。

开启 write-dates-as-timestamps 后,LocalDateTime 等类型会被序列化为整形数组(回忆一下 LocalDateTime 的简单公式)。

{
  "date": 1728572627475,
  "localDateTime": [
    2024,
    10,
    10,
    23,
    3,
    47,
    475519000
  ],
  "localDate": [
    2024,
    10,
    10
  ],
  "localTime": [
    23,
    3,
    47,
    475564000
  ]
}

也不能说有问题,毕竟 LocalDateTime 精确到纳秒,直接转换为毫秒时间戳,会丢失精度。总之,要实现和谐转换,需要设置 Jackson。

// 仍然是 JacksonConfig 之类的什么地方
@Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
    return builder -> {
        // deserializers
        builder.deserializers(new LocalDateDeserializer());
        builder.deserializers(new LocalDateTimeDeserializer());

        // serializers
        builder.serializers(new LocalDateSerializer());
        builder.serializers(new LocalDateTimeSerializer());
    };
}

public static class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {

    /**
     * 如果没有重写 handledType() 方法,会报错
     * @return LocalDateTime.class
     */
    @Override
    public Class<LocalDateTime> handledType() {
        return LocalDateTime.class;
    }

    @Override
    public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers)
            throws IOException {
        if (value != null) {
            gen.writeNumber(value.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli());
        }
    }
}

public static class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {

    @Override
    public Class<?> handledType() {
        return LocalDateTime.class;
    }

    @Override
    public LocalDateTime deserialize(JsonParser parser, DeserializationContext deserializationContext) throws IOException {
        long timestamp = parser.getValueAsLong();
        return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime();
    }
}

public static class LocalDateSerializer extends JsonSerializer<LocalDate> {

    @Override
    public Class<LocalDate> handledType() {
        return LocalDate.class;
    }

    @Override
    public void serialize(LocalDate value, JsonGenerator gen, SerializerProvider serializers)
            throws IOException {
        if (value != null) {
            gen.writeNumber(value.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli());
        }
    }
}

public static class LocalDateDeserializer extends JsonDeserializer<LocalDate> {

    @Override
    public Class<?> handledType() {
        return LocalDate.class;
    }

    @Override
    public LocalDate deserialize(JsonParser parser, DeserializationContext deserializationContext) throws IOException {
        long timestamp = parser.getValueAsLong();
        return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate();
    }
}

这里我们进行了一些生硬的强制措施,定义了一系列 Deserializer 和 Serializer,实现了 LocalDateTime 和 long 之间的序列化规则。

没有处理 LocalTime,因为单独的时间转换为时间戳不那么契合,时间戳有明确地年月日,这部分对于 LocalTime 显得多余,而且时间通常与时区有关,处理时要更谨慎一些。可以根据需求选择,如果明确需要使用时间戳来表示 LocalTime,可以采用类似的方法,注册 Deserializer 和 Serializer。

以上是在 JSON 序列化时将 Date、LocalDateTime 转化为时间戳需要的配置:

请求参数中的时间戳

在请求参数中使用时间戳复杂一些,因为不像时间字符串一样有现成的配置,需要手动实现转换规则。

可以利用 Converter 接口来解决这个问题。

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new LongStringToDateConverter());
        registry.addConverter(new LongStringToLocalDateTimeConverter());
        registry.addConverter(new LongStringToLocalDateConverter());
        // registry.addConverter(new LongStringToLocalTimeConverter()); // 按需
    }

    private static class LongStringToDateConverter implements Converter<String, Date> {
        @Override
        public Date convert(String source) {
            try {
                long timestamp = Long.parseLong(source);
                return new Date(timestamp);
            } catch (NumberFormatException e) {
                return null;
            }
        }
    }

    private static class LongStringToLocalDateTimeConverter implements Converter<String, LocalDateTime> {

        @Override
        public LocalDateTime convert(String source) {
            try {
                long timestamp = Long.parseLong(source);
                return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDateTime();
            } catch (NumberFormatException e) {
                return null;
            }
        }
    }

    private static class LongStringToLocalDateConverter implements Converter<String, LocalDate> {

        @Override
        public LocalDate convert(String source) {
            try {
                long timestamp = Long.parseLong(source);
                return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalDate();
            } catch (NumberFormatException e) {
                return null;
            }
        }
    }

    private static class LongStringToLocalTimeConverter implements Converter<String, LocalTime> {

        @Override
        public LocalTime convert(String source) {
            try {
                long timestamp = Long.parseLong(source);
                return Instant.ofEpochMilli(timestamp).atZone(ZoneId.systemDefault()).toLocalTime();
            } catch (NumberFormatException e) {
                return null;
            }
        }
    }

}

注意 Source 类型为 String 而不是 Long,因为 Spring MVC 会将所有的接口请求参数类型统一视为 String,然后调用 Converter 转换为其他类型。有许多内置的 Converter,比如转换为 long 类型时,就使用了内置的 StringToNumber 转换类。我们定义的 LongStringToDateConverter 与 StringToNumber 是平级的关系。

以上是在接口参数中将 Date、LocalDateTime 转化为时间戳需要的处理:很简单,注册 Converter 即可。

Swagger UI 中的类型

使用 SwaggerUI 时,默认会使用 DTO 字段类型作为请求参数类型,也就是接收时间字符串。序列化时改为时间戳后,还需要在 Swagger UI 中统一。

Java 项目有两种集成 Swagger 的方式,Springdoc 和 Spring Fox。Springdoc 更新,对应的配置如下:

@Bean
public OpenAPI customOpenAPI() {
    // 关键是要调用这个静态方法进行 replace
    SpringDocUtils.getConfig()
            .replaceWithClass(Date.class, Long.class)
            .replaceWithClass(LocalDateTime.class, Long.class)
            .replaceWithClass(LocalDate.class, Long.class);
    return new OpenAPI();
}

如果使用 Spring Fox,则需要使用另一种配置:

@Bean
public Docket createRestApi() {
    return new Docket(DocumentationType.OAS_30)
            ...
            .build()
            // 重点是这句
            .directModelSubstitute(LocalDateTime.class, Long.class);
}

此时,在 Swagger UI 页面调试接口时,时间类型的参数就显示为整数了。

The Only Neat Thing to Do

回顾一下,在 Spring Boot 接口中处理时间字段序列化,涉及两个场景:

两种情况要分开设置。

在 Java 类型选择方面,Spring 对 Date 类型的支持比 LocalDateTime 好,有很多内置的配置,能省去很多麻烦。

如果要使用 LocalDateTime 等类型,在 JSON 序列化时要指定时间格式,在请求参数中也要指定时间格式。前者需要手动配置,后者可以使用 Spring 提供的配置项。

如果想要用时间戳传递数据,也需要分别设置,在 JSON 序列化时指定序列化器和反序列化器,在请求参数中绑定对应的 Converter 实现类。此外,统一 Swagger UI 的类型体验更佳。

以上就是在Spring Boot接口中正确地序列化时间字段的方法的详细内容,更多关于Spring Boot序列化时间字段的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文