Java 中时间对象的序列化

在 Java 应用程序中,时间对象是使用地比较频繁的对象,比如,记录某一条数据的修改时间,用户的登录时间等应用场景。在传统的 Java 编程中,大部分的程序员都会选择使用 java.uti.Date 这个类型的类来表示时间(这个类可不是什么善类)。

在现代化互联网的使用场景中,由于前后端分离的原因,在前后端之间进行数据的交互都会默认采用 JSON(JavaScript Object Notion 即 JavaScript 对象表示法)来完成前后端的数据交互。在对时间对象的 JSON 序列化处理的过程中,可能或多或少都会遇到一些坑,本文将结合笔者自身遇到的一些问题,提供我个人认为比较合理的解决方案。

Date 对象的序列化

如果你正在使用 java.util.Date 或者它的一些子类,那么请尽快放弃使用这一系列类,这个类可能是 Java 中为数不多令人觉得恶心的类。这个类存在以下几点显而易见的缺陷:

  • 这个类是表示时间的,但是它表示的时间并没有时区的概念,只是单纯地存储了一个 long 类型的时间戳来表示时间,而这个时间戳则是基于系统默认的时区

  • 尽管大部分的 gettersetter 方法已经被弃用了,但是如果去翻看这个类的 getYear() 等方法绝对会让你大吃一惊。它的 year 是基于 1900 年为起始年,month 则是以 \(0\) 为开始月份

  • 这个类是一个可变类,这意味着在记录了一个时间之后,依旧可以修改这个时间对象,这从设计上来讲是不合理的

尽管自 JDK 1.1 开始着手设计了 java.util.Calendar 准备修复这个类存在的一些问题,但是结果不是很明显,java.util.Calendar 依旧是可变的

出于以上的一些原因,建议不要使用 java.util 包下的时间类,如果必要,可以考虑使用 java.time.Instant 来替换 java.util.Date

但是总会有意外,如果现有的系统中存在大量的使用 java.util.Date 的场景,那么也只能试着和这个类友好的相处。对于没有配置任何序列化规则的 JSON 序列化工具类,会默认将类中的所有实例属性递归地进行处理方法来转换成对应的 JSON 内容。对于下面定义的类:

import java.util.Date;

public class Person {
    private String name;
    private Date createdTime;

    // 省略部分 Getter 和 Setter 方法
}

使用下面的方法来设置相关的属性:

Person person = new Person();
person.setName("xhliu");
person.setCreatedTime(new Date());

Jackson 的序列化

当使用 Jackson 将这个对象进行序列化时(此时没有设置 Jackson ),会得到类似下面的输出结果:

{"name":"xhliu","createdTime":1655642583437}

由于 Date 默认情况下只有一个存储时间戳的非空属性,因此会将其进行序列化。显然,实际使用时肯定不希望是这样的格式。如果希望 Jackson 能够序列化成指定的格式,可以在这个 Date 类型的属性上加上 @JsonFormat 注解使得 Jackson 序列化成对应的格式,一般都会采用如下的格式:

import com.fasterxml.jackson.annotation.JsonFormat;
class Person {
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private Date createdTime;
}

Jackson 中 @JsonFormat 注解的目的是格式化属性的序列化形式,在这里格式化 Date 的输出格式,具体的 Date 的格式以及各个字段的含义可以参考:ISO 8601

此时,再使用 Jackson 进行序列化可以看到类似下面的效果:

{"name":"xhliu","createdTime":"2022-06-19 13:08:51"}

除了在预先的字段上加上 @JsonFormt 的注解来显式地格式化时间,也可以通过配置 Jackson 的全局日期格式来配置日期的输出格式,如下所示:

ObjectMapper mapper = new ObjectMapper();
mapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

需要注意的是,@JsonFormat 的优先级会高于全局的配置,因此,如果遇到某些需要进行特定格式化的场景,使用 @JsonFormat 是一个比较好的选择

Jackson 的反序列化

由于已经使用了相关的序列化格式,因此在进行反序列化时也需要按照相同的格式才能完成JSON 的反解析。大部分的 REST 请求在接受参数时,对于 Date 的解析出现异常都是由于格式不匹配导致的,为了解决这个问题,可以使用 @JsonFormat 的注解来规定时间的格式,使得它能够正常解析对应的时间属性`

Gson 的序列化

和 Jackson 的序列化不同,Gson 在没有配置相关的属性的情况下,会调用 DatetoString 方法来填充属性的 JSON 值,对于上面的例子,如果使用没有进行任何配置的 Gson 来进行 JSON 的序列化,输出的结果可能如下所示:

{"name":"xhliu","createdTime":"Jun 19, 2022, 9:20:41 PM"}

和默认的 Jackson 的输出相比,只能说是一个五八,一个四十了

如果想要格式化 Date,需要为 Gson 注册一个序列化适配器,注册到 Gson 中来实现 Date 的序列化。可以手动实现序列化适配器,只需要定义一个类实现相关的序列化和反序列化操作即可,类似的适配器如下所示:

import com.google.gson.*;
import lombok.SneakyThrows;

import java.lang.reflect.Type;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class NormalDateSerializerAdapter 
    implements JsonSerializer<Date>, JsonDeserializer<Date> {
    @Override
    public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
        /*
         * 尽管 SimpleDateFormat 是线程不安全的(网上铺天盖地都有的八股文),但是在使用的过程中
         * 中是一个 ”栈封闭“ 的状态,明显这项操作是线程安全的
         *
         * Tips: SimpleDateFormat 不是是线程安全的类,因为它在格式化日期的过程中修改了私有属性状态,
         *       并且没有使用任何同步手段来保证操作的有序性
         */
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return new JsonPrimitive(format.format(src));
    }

    @SneakyThrows
    @Override
    public Date deserialize(
            JsonElement json, Type typeOfT,
            JsonDeserializationContext context
    ) throws JsonParseException {
        DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        return format.parse(json.getAsString());
    }
}

在 Gson 的构造过程中注入这个类型适配器:

Gson gson = new GsonBuilder()
                .registerTypeAdapter(Date.class, new NormalDateSerializerAdapter())
                .create();

此时再进行序列化的操作,可以看到 Date 已经符合一般的表现形式了:

{"name":"xhliu","createdTime":"2022-06-20 09:31:43"}

有时由于恶性的需求的原因,这种全局的时间格式可能并不能满足要求。有些需求并不需要时间精确到时分秒。针对这种情况,在 Gson 中注册类型适配器无法满足要求。和 Jackson 类似同,Gson 也支持通过注解的方式来设置字段的序列化格式,这样就能够使得序列化的领域精确到某一个字段属性,Gson 通过 @JsonAdapter 的注解来定义属性的对象序列化格式,如下所示:

import com.google.gson.*;
import com.google.gson.annotations.JsonAdapter;
import lombok.SneakyThrows;

import java.lang.reflect.Type;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;

public class Person {
    private String name;

    @JsonAdapter(value = YearDateSerializerAdapter.class)
    private Date createdTime;

    // 自定义的日期序列化格式
    public static class YearDateSerializerAdapter
            implements JsonDeserializer<Date>, JsonSerializer<Date> {

        @Override
        public JsonElement serialize(Date src, Type typeOfSrc, JsonSerializationContext context) {
            DateFormat format = new SimpleDateFormat("yyyy-MM-dd");
            return new JsonPrimitive(format.format(src));
        }

        @SneakyThrows
        @Override
        public Date deserialize(
                JsonElement json, Type typeOfT,
                JsonDeserializationContext context
        ) throws JsonParseException {
            DateFormat format = new SimpleDateFormat("yyyy-MM-dd");
            return format.parse(json.getAsString());
        }
    }
}

这样,在序列化这个对象时将会按照 @JsonAdapter 注解中定义的类型适配器进行序列化和反序列化

Gson 的反序列化

由于已经配置了相关的类型适配器,因此反序列化时需要符合预期能够解析的格式。对于上面的适配器来讲,只要是符合 "yyy-MM-dd HH:mm:ss" 的格式便能够进行解析。具体处理时需要注意和自己的反序列化格式相匹配

java.time 包下时间对象的序列化

java.time 包下的的时间类自 JDK 1.8 引入,它解决了原有 java.util 包下有关时间类中存在的问题。这些类相比较于旧有的时间类,存在以下的优势:

  • API 都是很清楚的、易懂的,和 Dateget 系列方法形成鲜明对比

  • Date 直接保存时间戳不同,java.time 包下表示时间的类是可伸缩的,比如:Instant 就表示时间戳、LocalDate 表示日期(年、月、日)、ZonedDateTime 表示带有时区的时间

  • java.time 包下的所有类都是不可变类,这也就意味着它们一定是线程安全的

  • 这个包下的类提供了链式的 API 调用,使得代码意图更加清晰

Jackson 的序列化

对于 java.time 包下的时间类,Jackson 已经提供了相应的时间模块来处理这些类的序列化和反序列化操作。对于一般的 Maven 项目,需要加入相关的依赖项目:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>${jackson-version}</version>
</dependency>

当使用时,注册对应的时间模块即可:

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;

ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());

现在,对相关的时间属性加上 @JsonFormat 注解格式化时间即可:

import com.fasterxml.jackson.annotation.JsonFormat;

import java.time.LocalDate;
import java.time.LocalDateTime;

public class Order {
    private int id;
    private String orderName;
    private String orderDesc;

    @JsonFormat(pattern = "yyyy-MM-dd")
    private LocalDate orderCreatedDate;

    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime orderCreatedDateTime;
}

相关的序列化结果如下:

{"id":1,"orderName":null,"orderDesc":null,"orderCreatedDate":"2022-06-24","orderCreatedDateTime":"2022-06-24 21:51:58"}

Gson 的序列化

Gson 并没有提供相关的时间模块组件,因此需要自定义相关的序列化和反序列化实现,以 LocalDateTime 的序列化和反序列化为例,可以定义相关的序列化和反序列化实现:

private static class LocalDateTimeAdapter
    implements JsonSerializer<LocalDateTime>, JsonDeserializer<LocalDateTime> {
    // 自定义的时间格式
    private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @Override
    public LocalDateTime deserialize(
        JsonElement json, Type typeOfT,
        JsonDeserializationContext context
    ) throws JsonParseException {
        return LocalDateTime.parse(
            json.getAsString(),
            dateTimeFormatter.withZone(ZoneId.systemDefault())
        );
    }

    @Override
    public JsonElement serialize(
        LocalDateTime src, Type typeOfSrc,
        JsonSerializationContext context
    ) {
        return new JsonPrimitive(dateTimeFormatter.format(src));
    }
}

然后,将这个类型适配器注册到 Gson 中,使得其能够处理时间的序列化:

Gson gson = new GsonBuilder()
    .registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
    .create();

值得注意的一点是,Gson 是通过反射的方式来访问相关的属性的,而这一方式在 JDK 9 开始就已经被禁用了,因此在序列化时可能会看到类似下面的异常:

Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field private final int java.time.LocalDate.year accessible: module java.base does not "opens java.time" to unnamed module @2d9d4f9d
	at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
	at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
	at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:178)
	at java.base/java.lang.reflect.Field.setAccessible(Field.java:172)
	at com.google.gson.internal.reflect.UnsafeReflectionAccessor.makeAccessible(UnsafeReflectionAccessor.java:44)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.getBoundFields(ReflectiveTypeAdapterFactory.java:159)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.create(ReflectiveTypeAdapterFactory.java:102)
	at com.google.gson.Gson.getAdapter(Gson.java:489)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.createBoundField(ReflectiveTypeAdapterFactory.java:117)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.getBoundFields(ReflectiveTypeAdapterFactory.java:166)
	at com.google.gson.internal.bind.ReflectiveTypeAdapterFactory.create(ReflectiveTypeAdapterFactory.java:102)
	at com.google.gson.Gson.getAdapter(Gson.java:489)
	at com.google.gson.Gson.toJson(Gson.java:727)
	at com.google.gson.Gson.toJson(Gson.java:714)
	at com.google.gson.Gson.toJson(Gson.java:669)
	at com.google.gson.Gson.toJson(Gson.java:649)
	at com.example.demo.config.GsonConfig.main(GsonConfig.java:85)

为了解决这个问题,可以在运行时添加 --add-opens java.base/java.time=ALL-UNNAMED 虚拟机选项(VM Options)来使得反射功能能够正常使用

java -cp xxx --add-opens java.base/java.time=ALL-UNNAMED

如果是 IDEA 的话,可以在 Edit Configuration ——> Modify Options 中找到 VM Options

序列化的结果类似下面所示:

{"id":1,"orderCreatedDate":{"year":2022,"month":6,"day":24},"orderCreatedDateTime":"2022-06-24 22:20:26"}

对于其它 java.time 包下的时间类,也可以使用类似的方式来定义相关的序列化行为


参考:

[1] https://iogogogo.github.io/2020/06/23/gson-java8-datetime/

posted @ 2022-06-25 20:50  FatalFlower  阅读(2287)  评论(0编辑  收藏  举报