接口返回时间为什么差8小时?论SpringBoot中Jackson配置

概述

Jackson作为SpringBoot中默认的JSON mapping库,在java项目中应用十分广泛,你在项目实践中是不是遇到过这样的问题:

  1. 日期格式看上去没问题,但是序列化之后输出的字符串差了8小时
  2. 服务接口的日期格式不统一,你可能需要各个接口分别适配,不知道如何全局配置反序列化

Jackson简介

Jackson是一个简单基于Java应用库,Jackson可以轻松的将Java对象转换成json对象和xml文档,同样也可以将json、xml转换成Java对象。

ObjectMapper类

ObjectMapper是Jackson库的主要类。它提供一些功能将转换成Java对象匹配JSON结构,反之亦然。它使用JsonParser和JsonGenerator的实例实现JSON实际的读/写。

转换代码

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;

public class JacksonUtil {
    private static ObjectMapper mapper = new ObjectMapper();

    private JacksonUtil() {
    }

    /**
     * 序列化对象到Json字符
     */
    public static String generate(Object object) throws JsonProcessingException {
        return mapper.writeValueAsString(object);
    }

    /**
     * 反序列化Json字符到对象
     */
    public static <T> T parse(String content, Class<T> valueType) throws IOException {
        return mapper.readValue(content, valueType);
    }
}

数据绑定

简单的数据绑定是指JSON映射到Java核心数据类型。下表列出了JSON类型和Java类型之间的关系。

序号 JSON 类型 Java 类型
1 object LinkedHashMap<String,Object>
2 array ArrayList<Object>
3 string String
4 complete number Integer, Long or BigInteger
5 fractional number Double / BigDecimal
6 true | false Boolean
7 null null

Spring应用中如何使用Jackson

Spring Boot支持与三种JSON mapping库集成:Gson、Jackson和JSON-B。Jackson是首选和默认的。

Jackson是spring-boot-starter-json依赖中的一部分,spring-boot-starter-web中包含spring-boot-starter-json。也就是说,当项目中引入spring-boot-starter-web后会自动引入spring-boot-starter-json。

pom.xml依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Restful接口中返回对象的Date类型为什么少了8小时?

本地jackson配置

spring:
    jackson:
        date-format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z'

如果你用过java8新的时间类下的Instant,查看toString方法会发现它使用DateTimeFormatter.ISO_INSTANT标准时间格式输出,如下:

Instant now = Instant.now();
// 2019-08-18T02:57:55.234Z

这个输出格式与我们上面的配置一致,所以就日期格式配置而言这本身并没有问题,下面我们再查看JacksonAutoConfiguration源码中configureDateFormat方法

private void configureDateFormat(Jackson2ObjectMapperBuilder builder) {
    String dateFormat = this.jacksonProperties.getDateFormat();
    if(dateFormat != null) {
        try {
            Class ex = ClassUtils.forName(dateFormat, (ClassLoader)null);
            builder.dateFormat((DateFormat)BeanUtils.instantiateClass(ex));
        } catch (ClassNotFoundException var6) {
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat(dateFormat);
            TimeZone timeZone = this.jacksonProperties.getTimeZone();
            if(timeZone == null) {
                timeZone = (new ObjectMapper()).getSerializationConfig().getTimeZone();
            }

            simpleDateFormat.setTimeZone(timeZone);
            builder.dateFormat(simpleDateFormat);
        }
    }
}

当我们没有配置时区时,它会执行timeZone = (new ObjectMapper()).getSerializationConfig().getTimeZone();,进一步查看timeZone属性,发现这其实是默认时区DEFAULT_TIMEZONE = TimeZone.getTimeZone("UTC")

看来问题的根源就在这儿,也就是说它其实用的是UTC时间,这就解释 了为什么会少了8小时(本地时间为GMT+8),我原先以为不设置时区会使用JVM时区或系统所在时区(中国时区),所以只要加个时区配置就行了,如下:

# Asia/Shanghai 等同于 GMT+8
spring:
    jackson:
        date-format: yyyy-MM-dd'T'HH:mm:ss.SSS'Z'
        time-zone: Asia/Shanghai

注意!注意!

我们虽然从表面解决了时间差8小时的问题,但这种方法并不优雅(或者说有些令人费解),上面提到了Instant打印出来的结果,使用yyyy-MM-dd'T'HH:mm:ss.SSS'Z'格式就是应该返回标准时间(相对本地时间少8小时),时区本不需要配置或者你应该配置为UTC,比如上述时间在js中执行

var date = new Date('2019-08-18T02:57:55.234Z');
// Sun Aug 18 2019 10:57:55 GMT+0800 (中国标准时间)

说明前端使用的时候其实是能正确识别的,如果配置加上GMT+8返回的时间补上8小时,前端在解析的时候反而不正确了,但是这个时间不适合阅读(需要进行一个转换),或许我们该使用不带引号的大Z(小z输出的格式js转换会报Invalid Date),下面来说明

日期模式字符串说明

  • 文本可以由单引号'引起来,这样就不需要解析,比如yyyy-MM-dd'T'HH:mm:ss.SSS'Z'中的TZ(由此可见这种带引号的'Z'只是作为占位符,没有实际意义,但是你也可以认为带'Z'的格式表示使用的是UTC标准时间)
  • 时区可以用小写z和大写Z来表示,小z表示世界标准时间,大Z表示RFC 822 time zone

时区说明

  • UTC时间 世界标准时间
  • GMT时间 格林尼治平时,不再被作为标准时间使用,可以认为其等同于UTC时间
  • CST时间 北京时间,记为UTC+8,不过这个缩写它可以同时代表四个不同的时间(所以不建议使用该格式)
    • Central Standard Time (USA) UT-6:00
    • Central Standard Time (Australia) UT+9:30
    • China Standard Time UT+8:00
    • Cuba Standard Time UT-4:00

举个例子

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.TimeZone;

public class DateFormatTest {
    public static void main(String[] args) {
        Date date = new Date();
        String[][] formatArray = {
                {"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", null},
                {"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "GMT+8"},
                {"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "UTC"},
                {"yyyy-MM-dd'T'HH:mm:ss.SSSz", "GMT"},
                {"yyyy-MM-dd'T'HH:mm:ss.SSSz", "GMT+8"},
                {"yyyy-MM-dd'T'HH:mm:ss.SSSZ", "GMT+8"},
                {"yyyy-MM-dd'T'HH:mm:ss.SSSZ", "GMT"},
                {"yyyy-MM-dd'T'HH:mm:ss.SSSZ", "UTC"},
        };
        for (String[] item : formatArray) {
            SimpleDateFormat sdf = new SimpleDateFormat(item[0]);
            if (item[1] != null) {
                sdf.setTimeZone(TimeZone.getTimeZone(item[1]));
            }
            System.out.println(String.format("format=%s, timeZone=%s, print=%s", item[0], item[1], sdf.format(date)));
        }
    }
}

输出结果

format=yyyy-MM-dd'T'HH:mm:ss.SSS'Z', timeZone=null, print=2019-08-18T10:57:55.333Z
format=yyyy-MM-dd'T'HH:mm:ss.SSS'Z', timeZone=GMT+8, print=2019-08-18T10:57:55.333Z
format=yyyy-MM-dd'T'HH:mm:ss.SSS'Z', timeZone=UTC, print=2019-08-18T02:57:55.333Z
format=yyyy-MM-dd'T'HH:mm:ss.SSSz, timeZone=GMT, print=2019-08-18T02:57:55.333GMT
format=yyyy-MM-dd'T'HH:mm:ss.SSSz, timeZone=GMT+8, print=2019-08-18T10:57:55.333GMT+08:00
format=yyyy-MM-dd'T'HH:mm:ss.SSSZ, timeZone=GMT+8, print=2019-08-18T10:57:55.333+0800
format=yyyy-MM-dd'T'HH:mm:ss.SSSZ, timeZone=GMT, print=2019-08-18T02:57:55.333+0000
format=yyyy-MM-dd'T'HH:mm:ss.SSSZ, timeZone=UTC, print=2019-08-18T02:57:55.333+0000

从例子中可以得到如下结论

  1. SimpleDateFormat不设置时区默认使用本地时区
  2. 在使用UTC时间的情况下,要注意yyyy-MM-dd'T'HH:mm:ss.SSS'Z'返回的是标准时间(少了8小时)
  3. 写成带时区的格式yyyy-MM-dd'T'HH:mm:ss.SSSZ,即使不配时区也能从字面意思中翻译出北京时间,如2019-08-18T02:57:55.333+0000

日期格式反序列化全局配置

直接上代码,这里偷懒用到了apache common工具类中的DateUtils

import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.time.DateUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.io.IOException;
import java.text.ParseException;
import java.util.Date;

@Configuration
public class JacksonConfiguration {
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper om = new ObjectMapper();
        om.setSerializationInclusion(Include.NON_NULL);
        om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        SimpleModule module = new SimpleModule();
        module.addDeserializer(Date.class, new JsonDateDeserialize());
        om.registerModule(module);
        return om;
    }
}

@Slf4j
class JsonDateDeserialize extends JsonDeserializer {
    // 按照此优先级顺序尝试转换日期格式,建议从配置文件加载
    private static final String[] patterns = {"yyyy-MM-dd'T'HH:mm:ssZ", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd", "yyyy-MM-dd'T'HH:mm:ss"};

    public Date deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException {
        String dateAsString = jp.getText();
        Date parseDate = null;
        if (dateAsString.contains("-")) {
            // 日期类型
            try {
                parseDate = DateUtils.parseDate(dateAsString, patterns);
            } catch (ParseException e) {
                log.error(String.format("不支持的日期格式: %s, error=%s", dateAsString, e.getMessage()), e);
            }
        } else {
            // long毫秒
            try {
                long time = Long.valueOf(dateAsString);
                parseDate = new Date(time);
            } catch (NumberFormatException e) {
                log.error(String.format("日期格式非数字: %s, error=%s", dateAsString, e.getMessage()), e);
            }
        }
        return parseDate;
    }
}

mysql出现的时区问题

表现为入库时间,或者binlog日志中查询更新的实际日期与传递参数相差N小时,一般解决方法是在jdbc连接url中添加时区serverTimezone设置

serverTimezone=GMT%2B8
#或者
serverTimezone=Asia/Shanghai

例如:

spring:
    datasource:
        url: jdbc:mysql://host:3306/database?useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&serverTimezone=GMT%2B8

参考资料

Jackson教程
Spring Boot中Jackson应用详解
Jackson Annotations

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,122评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,070评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,491评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,636评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,676评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,541评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,292评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,211评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,655评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,846评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,965评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,684评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,295评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,894评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,012评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,126评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,914评论 2 355