1 缘起

  后端使用Spring接收参数,用@ResquestBody接收请求入参,前端传参一日期类型的参数为“2022-12-30”,后端接收到以后打印的值为“2022-12-30 08:00:00”,后端比前端提前8小时。
  后端使用@RestController,给前端返回时间类型的参数时,前端接收到的时间比后端滞后8小时。

2 问题原因分析

  这种情况下,后端认为前端传参或者后端返回给前端的参数都是JSON格式,所以使用解析JSON的。后端在解析前端传参时,使用的参数转换器是MappingJackson2HttpMessageConverter,默认时区是UTC,而我们一般的机器中的JVM 是东八区,两个时区不一样,导致这种情况下日期类型的转换会做一定的处理。故,本质上取决于处理时间类型的处理器使用的时区和JVM时区是否一致。跟踪代码,发现最终使用的是StdDateFormat类中的parse方法进行解析,代码如下:
关键类:
com.fasterxml.jackson.databind.util.StdDateFormat#parse(java.lang.String, java.text.ParsePosition)

public class StdDateFormat extends DateFormat {
	@Override
    public Date parse(String dateStr) throws ParseException
    {
        dateStr = dateStr.trim();
        ParsePosition pos = new ParsePosition(0);
        Date dt = _parseDate(dateStr, pos);
        if (dt != null) {
            return dt;
        }
        StringBuilder sb = new StringBuilder();
        for (String f : ALL_FORMATS) {
            if (sb.length() > 0) {
                sb.append("\", \"");
            } else {
                sb.append('"');
            }
            sb.append(f);
        }
        sb.append('"');
        throw new ParseException
            (String.format("Cannot parse date \"%s\": not compatible with any of standard forms (%s)",
                           dateStr, sb.toString()), pos.getErrorIndex());
    }

	protected Date _parseDate(String dateStr, ParsePosition pos) throws ParseException
    {
        if (looksLikeISO8601(dateStr)) { // also includes "plain"
            return parseAsISO8601(dateStr, pos);
        }
        // Also consider "stringified" simple time stamp
        int i = dateStr.length();
        while (--i >= 0) {
            char ch = dateStr.charAt(i);
            if (ch < '0' || ch > '9') {
                // 07-Aug-2013, tatu: And [databind#267] points out that negative numbers should also work
                if (i > 0 || ch != '-') {
                    break;
                }
            }
        }
        if ((i < 0)
            // let's just assume negative numbers are fine (can't be RFC-1123 anyway); check length for positive
                && (dateStr.charAt(0) == '-' || NumberInput.inLongRange(dateStr, false))) {
            return _parseDateFromLong(dateStr, pos);
        }
        // Otherwise, fall back to using RFC 1123. NOTE: call will NOT throw, just returns `null`
        return parseAsRFC1123(dateStr, pos);
    }

	protected Date parseAsISO8601(String dateStr, ParsePosition pos)
        throws ParseException
    {
        try {
            return _parseAsISO8601(dateStr, pos);
        } catch (IllegalArgumentException e) {
            throw new ParseException(String.format("Cannot parse date \"%s\", problem: %s",
                    dateStr, e.getMessage()),
                    pos.getErrorIndex());
        }
    }

	protected Date _parseAsISO8601(String dateStr, ParsePosition bogus)
        throws IllegalArgumentException, ParseException
    {
        final int totalLen = dateStr.length();
        // actually, one short-cut: if we end with "Z", must be UTC
        TimeZone tz = DEFAULT_TIMEZONE;
        if ((_timezone != null) && ('Z' != dateStr.charAt(totalLen-1))) {
            tz = _timezone;
        }
        Calendar cal = _getCalendar(tz);
        cal.clear();
        String formatStr;
        // 对于只有年月日的字符串,走这个分支
        if (totalLen <= 10) {
            Matcher m = PATTERN_PLAIN.matcher(dateStr);
            if (m.matches()) {
                int year = _parse4D(dateStr, 0);
                int month = _parse2D(dateStr, 5)-1;
                int day = _parse2D(dateStr, 8);

                cal.set(year, month, day, 0, 0, 0);
                cal.set(Calendar.MILLISECOND, 0);
                return cal.getTime();
            }
            formatStr = DATE_FORMAT_STR_PLAIN;
        } else {
            Matcher m = PATTERN_ISO8601.matcher(dateStr);
            if (m.matches()) {
                // Important! START with optional time zone; otherwise Calendar will explode
                
                int start = m.start(2);
                int end = m.end(2);
                int len = end-start;
                if (len > 1) { // 0 -> none, 1 -> 'Z'
                    // NOTE: first char is sign; then 2 digits, then optional colon, optional 2 digits
                    int offsetSecs = _parse2D(dateStr, start+1) * 3600; // hours
                    if (len >= 5) {
                        offsetSecs += _parse2D(dateStr, end-2) * 60; // minutes
                    }
                    if (dateStr.charAt(start) == '-') {
                        offsetSecs *= -1000;
                    } else {
                        offsetSecs *= 1000;
                    }
                    cal.set(Calendar.ZONE_OFFSET, offsetSecs);
                    // 23-Jun-2017, tatu: Not sure why, but this appears to be needed too:
                    cal.set(Calendar.DST_OFFSET, 0);
                }
                
                int year = _parse4D(dateStr, 0);
                int month = _parse2D(dateStr, 5)-1;
                int day = _parse2D(dateStr, 8);

                // So: 10 chars for date, then `T`, so starts at 11
                int hour = _parse2D(dateStr, 11);
                int minute = _parse2D(dateStr, 14);

                // Seconds are actually optional... so
                int seconds;
                if ((totalLen > 16) && dateStr.charAt(16) == ':') {
                    seconds = _parse2D(dateStr, 17);
                } else {
                    seconds = 0;
                }
                cal.set(year, month, day, hour, minute, seconds);

                // Optional milliseconds
                start = m.start(1) + 1;
                end = m.end(1);
                int msecs = 0;
                if (start >= end) { // no fractional
                    cal.set(Calendar.MILLISECOND, 0);
                } else {
                    // first char is '.', but rest....
                    msecs = 0;
                    final int fractLen = end-start;
                    switch (fractLen) {
                    default: // [databind#1745] Allow longer fractions... for now, cap at nanoseconds tho

                        if (fractLen > 9) { // only allow up to nanos
                            throw new ParseException(String.format(
"Cannot parse date \"%s\": invalid fractional seconds '%s'; can use at most 9 digits",
                                       dateStr, m.group(1).substring(1)
                                       ), start);
                        }
                        // fall through
                    case 3:
                        msecs += (dateStr.charAt(start+2) - '0');
                    case 2:
                        msecs += 10 * (dateStr.charAt(start+1) - '0');
                    case 1:
                        msecs += 100 * (dateStr.charAt(start) - '0');
                        break;
                    case 0:
                        break;
                    }
                    cal.set(Calendar.MILLISECOND, msecs);
                }
                return cal.getTime();
            }
            formatStr = DATE_FORMAT_STR_ISO8601;
        }

        throw new ParseException
        (String.format("Cannot parse date \"%s\": while it seems to fit format '%s', parsing fails (leniency? %s)",
                       dateStr, formatStr, _lenient),
                // [databind#1742]: Might be able to give actual location, some day, but for now
                //  we can't give anything more indicative
                0);
    }
}

3 解决办法

第一种:在指定字段上加@JSonFormat注解并设置时区

@JsonFormat(timezone = "GMT+8")
private Date date;

第二种:若需要加注解的字段太多,给每个字段加注解也不是一种好方法。这时,可以在配置文件application.properties中配置时区,如下:

spring.jackson.time-zone=GMT+8

4 扩展

  最后,推荐两篇不错的博文:
1)SpringMVC如何正确接收时间
2)Jackson序列化
3)彻底弄透Java处理GMT-UTC日期时间

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐