为啥加了时分秒就不能解析了呢?

博客探讨了在Java后端接收到前端传来的yy-MM-dd HH:mm:ss格式日期字符串时解析异常的原因,深入源码分析了Jackson库在处理日期格式时的逻辑,揭示了由于默认只处理yyyy-MM-dd格式,而解析带时分秒的日期需要额外的@JsonFormat注解来指定解析模式。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

后端接口上被@RequestBody注解的参数中包含Date类型字段,如果前端传递yy-MM-dd HH:mm:ss格式的时间字符串就会解析异常,传递yy-MM-dd格式的字符串就能成功转换,嗯?这是为什么呢,是不是有什么小秘密。

原因分析

请求在被处理具体的handler处理之前,需要解析请求中携带的参数,以将请求中携带的请求参数与controller方法上的参数进行绑定设值,因此缩小排查的范围,出现问题的地方就是在Resolves a method parameter into an argument value from a given request里,定位到AbstractMessageConverterMethodArgumentResolver这个类的readWithMessageConverters方法,看到这个:
在这里插入图片描述
read方法用来从请求中获取给定类型的参数值,成功找到了处理入口:
在这里插入图片描述
在这里插入图片描述
在这里因为我们接收的是json数据,具体实现类使用的是MappingJackson2HttpMessageConverter,MappingJackson2HttpMessageConverter类继承于AbstractJackson2HttpMessageConverter,它的read方法其实就是AbstractJackson2HttpMessageConverter类中的read方法:
在这里插入图片描述
我们看到read方法调用了readInternal(Class<? extends T> clazz, HttpInputMessage inputMessage)方法,这个readInternal是AbstractHttpMessageConverter类的一个抽象方法,AbstractJackson2HttpMessageConverter类中进行了重写:
在这里插入图片描述
这个方法实现中调用了私有方法readJavaType(JavaType javaType, HttpInputMessage inputMessage),这个方法没有注释,从方法名字推断是读取出java的数据类型,那应该是将请求中json数据体中的属性字段值转换成java类型。

private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
		MediaType contentType = inputMessage.getHeaders().getContentType();
		Charset charset = getCharset(contentType);

		boolean isUnicode = ENCODINGS.containsKey(charset.name());
		try {
			if (inputMessage instanceof MappingJacksonInputMessage) {
				Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
				if (deserializationView != null) {
					ObjectReader objectReader = this.objectMapper.readerWithView(deserializationView).forType(javaType);
					if (isUnicode) {
						return objectReader.readValue(inputMessage.getBody());
					}
					else {
						Reader reader = new InputStreamReader(inputMessage.getBody(), charset);
						return objectReader.readValue(reader);
					}
				}
			}
			if (isUnicode) {
				return this.objectMapper.readValue(inputMessage.getBody(), javaType);
			}
			else {
				Reader reader = new InputStreamReader(inputMessage.getBody(), charset);
				return this.objectMapper.readValue(reader, javaType);
			}
		}
		catch (InvalidDefinitionException ex) {
			throw new HttpMessageConversionException("Type definition error: " + ex.getType(), ex);
		}
		catch (JsonProcessingException ex) {
			throw new HttpMessageNotReadableException("JSON parse error: " + ex.getOriginalMessage(), ex, inputMessage);
		}
	}

实际调用的是ObjectMapper的readValue方法,这个ObjectMapper是Jackson包中的类,readValue方法为:
在这里插入图片描述
接着跟下去,看到了_readMapAndClose方法:

protected Object _readMapAndClose(JsonParser p0, JavaType valueType)
        throws IOException
    {
        try (JsonParser p = p0) {
            Object result;
            JsonToken t = _initForReading(p, valueType);
            final DeserializationConfig cfg = getDeserializationConfig();
            final DeserializationContext ctxt = createDeserializationContext(p, cfg);
            if (t == JsonToken.VALUE_NULL) {
                // Ask JsonDeserializer what 'null value' to use:
                result = _findRootDeserializer(ctxt, valueType).getNullValue(ctxt);
            } else if (t == JsonToken.END_ARRAY || t == JsonToken.END_OBJECT) {
                result = null;
            } else {
                JsonDeserializer<Object> deser = _findRootDeserializer(ctxt, valueType);
                if (cfg.useRootWrapping()) {
                    result = _unwrapAndDeserialize(p, ctxt, cfg, valueType, deser);
                } else {
                    result = deser.deserialize(p, ctxt);
                }
                ctxt.checkUnresolvedObjectId();
            }
            if (cfg.isEnabled(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)) {
                _verifyNoTrailingTokens(p, ctxt, valueType);
            }
            return result;
        }
    }

这里实际走的是deser.deserialize(p, ctxt)这一行代码,deserialize方法是JsonDeserializer的抽象方法,JsonDeserializer有多个子类,这里用的子类是BeanDeserializer,它重写的deserialize方法为:

@Override
    public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
    {
        // common case first
        if (p.isExpectedStartObjectToken()) {
            if (_vanillaProcessing) {
                return vanillaDeserialize(p, ctxt, p.nextToken());
            }
            // 23-Sep-2015, tatu: This is wrong at some many levels, but for now... it is
            //    what it is, including "expected behavior".
            p.nextToken();
            if (_objectIdReader != null) {
                return deserializeWithObjectId(p, ctxt);
            }
            return deserializeFromObject(p, ctxt);
        }
        return _deserializeOther(p, ctxt, p.getCurrentToken());
    }

然后会走到return deserializeFromObject(p, ctxt)这一行,deserializeFromObject方法的实现为:

@Override
    public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException
    {
        /* 09-Dec-2014, tatu: As per [databind#622], we need to allow Object Id references
         *   to come in as JSON Objects as well; but for now assume they will
         *   be simple, single-property references, which means that we can
         *   recognize them without having to buffer anything.
         *   Once again, if we must, we can do more complex handling with buffering,
         *   but let's only do that if and when that becomes necessary.
         */
        if ((_objectIdReader != null) && _objectIdReader.maySerializeAsObject()) {
            if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)
                    && _objectIdReader.isValidReferencePropertyName(p.getCurrentName(), p)) {
                return deserializeFromObjectId(p, ctxt);
            }
        }
        if (_nonStandardCreation) {
            if (_unwrappedPropertyHandler != null) {
                return deserializeWithUnwrapped(p, ctxt);
            }
            if (_externalTypeIdHandler != null) {
                return deserializeWithExternalTypeId(p, ctxt);
            }
            Object bean = deserializeFromObjectUsingNonDefault(p, ctxt);
            /* 27-May-2014, tatu: I don't think view processing would work
             *   at this point, so commenting it out; but leaving in place
             *   just in case I forgot something fundamental...
             */
            /*
            if (_needViewProcesing) {
                Class<?> view = ctxt.getActiveView();
                if (view != null) {
                    return deserializeWithView(p, ctxt, bean, view);
                }
            }
            */
            return bean;
        }
        final Object bean = _valueInstantiator.createUsingDefault(ctxt);
        // [databind#631]: Assign current value, to be accessible by custom deserializers
        p.setCurrentValue(bean);
        if (p.canReadObjectId()) {
            Object id = p.getObjectId();
            if (id != null) {
                _handleTypedObjectId(p, ctxt, bean, id);
            }
        }
        if (_injectables != null) {
            injectValues(ctxt, bean);
        }
        if (_needViewProcesing) {
            Class<?> view = ctxt.getActiveView();
            if (view != null) {
                return deserializeWithView(p, ctxt, bean, view);
            }
        }
        if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
            String propName = p.getCurrentName();
            do {
                p.nextToken();
                SettableBeanProperty prop = _beanProperties.find(propName);
                if (prop != null) { // normal case
                    try {
                        prop.deserializeAndSet(p, ctxt, bean);
                    } catch (Exception e) {
                        wrapAndThrow(e, bean, propName, ctxt);
                    }
                    continue;
                }
                handleUnknownVanilla(p, ctxt, bean, propName);
            } while ((propName = p.n:
            extFieldName()) != null);
        }
        return bean;
    }

接着走到prop.deserializeAndSet(p, ctxt, bean),deserializeAndSet方法是抽象类SettableBeanProperty的抽象方法,这个抽象类有多个子类,这里用到的子类是MethodProperty,它重写的deserializeAndSet方法为:

 @Override
    public void deserializeAndSet(JsonParser p, DeserializationContext ctxt,
            Object instance) throws IOException
    {
        Object value;
        if (p.hasToken(JsonToken.VALUE_NULL)) {
            if (_skipNulls) {
                return;
            }
            value = _nullProvider.getNullValue(ctxt);
        } else if (_valueTypeDeserializer == null) {
            value = _valueDeserializer.deserialize(p, ctxt);
            // 04-May-2018, tatu: [databind#2023] Coercion from String (mostly) can give null
            if (value == null) {
                if (_skipNulls) {
                    return;
                }
                value = _nullProvider.getNullValue(ctxt);
            }
        } else {
            value = _valueDeserializer.deserializeWithType(p, ctxt, _valueTypeDeserializer);
        }
        try {
            _setter.invoke(instance, value);
        } catch (Exception e) {
            _throwAsIOE(p, e, value);
        }
    }

继续往下跟进,发现会进入到value = _valueDeserializer.deserialize(p, ctxt);这一行,这个_valueDeserializer为DateDeserializer对象,DateDeserializer是抽象类JsonDeserializer类的子类,重写的deserialize方法为:
在这里插入图片描述
调用了_parseDate方法:

@Override
        protected java.util.Date _parseDate(JsonParser p, DeserializationContext ctxt)
            throws IOException
        {
            if (_customFormat != null) {
                if (p.hasToken(JsonToken.VALUE_STRING)) {
                    String str = p.getText().trim();
                    if (str.length() == 0) {
                        return (Date) getEmptyValue(ctxt);
                    }
                    synchronized (_customFormat) {
                        try {
                            return _customFormat.parse(str);
                        } catch (ParseException e) {
                            return (java.util.Date) ctxt.handleWeirdStringValue(handledType(), str,
                                    "expectedprotected java.util.Date _parseDate(JsonParser p, DeserializationContext ctxt)
        throws IOException
    {
        switch (p.getCurrentTokenId()) {
        case JsonTokenId.ID_STRING:
            return _parseDate(p.getText().trim(), ctxt);
        case JsonTokenId.ID_NUMBER_INT:
            {
                long ts;
                try {
                    ts = p.getLongValue();
                // 16-Jan-2019, tatu: 2.10 uses InputCoercionException, earlier JsonParseException
                //     (but leave both until 3.0)
                } catch (JsonParseException | InputCoercionException e) {
                    Number v = (Number) ctxt.handleWeirdNumberValue(_valueClass, p.getNumberValue(),
                            "not a valid 64-bit long for creating `java.util.Date`");
                    ts = v.longValue();
                }
                return new java.util.Date(ts);
            }
        case JsonTokenId.ID_NULL:
            return (java.util.Date) getNullValue(ctxt);
        case JsonTokenId.ID_START_ARRAY:
            return _parseDateFromArray(p, ctxt);
        }
        return (java.util.Date) ctxt.handleUnexpectedToken(_valueClass, p);
    } format \"%s\"", _formatString);
                        }
                    }
                }
            }
            return super._parseDate(p, ctxt);
        }

我们发现在执行的时候_customFormat是为null的,因此会调用父类的_parseDate方法。来看下父类的_parseDate方法实现:

protected java.util.Date _parseDate(JsonParser p, DeserializationContext ctxt)
        throws IOException
    {
        switch (p.getCurrentTokenId()) {
        case JsonTokenId.ID_STRING:
            return _parseDate(p.getText().trim(), ctxt);
        case JsonTokenId.ID_NUMBER_INT:
            {
                long ts;
                try {
                    ts = p.getLongValue();
                // 16-Jan-2019, tatu: 2.10 uses InputCoercionException, earlier JsonParseException
                //     (but leave both until 3.0)
                } catch (JsonParseException | InputCoercionException e) {
                    Number v = (Number) ctxt.handleWeirdNumberValue(_valueClass, p.getNumberValue(),
                            "not a valid 64-bit long for creating `java.util.Date`");
                    ts = v.longValue();
                }
                return new java.util.Date(ts);
            }
        case JsonTokenId.ID_NULL:
            return (java.util.Date) getNullValue(ctxt);
        case JsonTokenId.ID_START_ARRAY:
            return _parseDateFromArray(p, ctxt);
        }
        return (java.util.Date) ctxt.handleUnexpectedToken(_valueClass, p);
    }

进入到case JsonTokenId.ID_STRING条件中,执行 return _parseDate(p.getText().trim(), ctxt)这一行,这个方法具体实现为:

 protected java.util.Date _parseDate(String value, DeserializationContext ctxt)
        throws IOException
    {
        try {
            // Take empty Strings to mean 'empty' Value, usually 'null':
            if (_isEmptyOrTextualNull(value)) {
                return (java.util.Date) getNullValue(ctxt);
            }
            return ctxt.parseDate(value);
        } catch (IllegalArgumentException iae) {
            return (java.util.Date) ctxt.handleWeirdStringValue(_valueClass, value,
                    "not a valid representation (error: %s)",
                    ClassUtil.exceptionMessage(iae));
        }
    }

ctxt为DeserializationContext类的对象,parseDate方法的实现为:
在这里插入图片描述
它先获取抽象类DateFormat的对象,然后调用DateFormat对象的parse方法,这里获取到的是DateFormat类的子类StdDateFormat,来看下StdDateFormat是如何实现parse方法的:

@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());
    }

它调用了_parseDate方法,这个_parseDate是StdDateFormat类的protected方法,具体实现为:

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);
    }

哇,这一层一层的跟套娃似,太难了。。。代码判断if (looksLikeISO8601(dateStr)) 为ture,调用parseAsISO8601方法,这个方法的实现为:

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);
    }

真相马上就要浮出水面了,为啥可以解析yyyy-MM-dd的日期格式,但是不可以解析yyyy-MM-dd HH:mm:ss的日期格式,带着这个疑问,试着破解其中的奥妙,首先_parseAsISO8601方法会先判断日期字符串的长度,如果长度小于等于10,则认为解析的是yyyy-MM-dd格式的日期字符串,也就是解析年月日,否则认为是解析yyyy-MM-dd’T’HH:mm:ss.SSSX格式的日期字符串,也就是ISO-8601格式的日期字符串。到这里真相大白了,方法在最后抛出的异常,也验证了这一点,可以看到这和之前参数解析错误返回的异常一致:
在这里插入图片描述

总结

在实体类的Date字段上加上@JsonFormat(pattern = “yyyy-MM-dd HH:mm:ss”)注解,可以发现DateDeserializers类_parseDate方法中的_customFormat不为空了,那调用的就是_customFormat的parse方法来进行解析。

 @Override
        protected java.util.Date _parseDate(JsonParser p, DeserializationContext ctxt)
            throws IOException
        {
            if (_customFormat != null) {
                if (p.hasToken(JsonToken.VALUE_STRING)) {
                    String str = p.getText().trim();
                    if (str.length() == 0) {
                        return (Date) getEmptyValue(ctxt);
                    }
                    synchronized (_customFormat) {
                        try {
                            return _customFormat.parse(str);
                        } catch (ParseException e) {
                            return (java.util.Date) ctxt.handleWeirdStringValue(handledType(), str,
                                    "expected format \"%s\"", _formatString);
                        }
                    }
                }
            }
            return super._parseDate(p, ctxt);
        }
    }

在这里插入图片描述
这就是为什么加上@JsonFormt就能够正常解析yyyy-MM-dd HH:mm:ss格式的日期字符串了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值