/

记一次curl的坑

SpringMVC对象解析的“浅坑”

SpringMVC这个坑主要原因不在curl,不过算是背景吧,也顺带记一下。如果想直接看curl的坑请跨过该“浅坑”,直接到后面的“深坑”。

最近在写一个接口,接受个推推送的回执数据,由于看文档那边推送的数据是json格式的,所以就想着直接用一个对象接受数据,如下:

public GetuiResponse getuiCallback(@RequestBody GetuiCallback getuiCallback)

测试都好好的,没想到一上线就跪了,抛下面异常:

org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/unknown;charset=UTF-8' not supported
    at org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(AbstractMessageConverterMethodArgumentResolver.java:237)
    at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.readWithMessageConverters(RequestResponseBodyMethodProcessor.java:150)
    at org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor.resolveArgument(RequestResponseBodyMethodProcessor.java:128)

然后紧急回滚后开始排查问题。照着异常栈信息定位到SpringMVC的解析数据的代码:

protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
            Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {

        MediaType contentType;
        boolean noContentType = false;
        try {
            contentType = inputMessage.getHeaders().getContentType();
        }
        catch (InvalidMediaTypeException ex) {
            throw new HttpMediaTypeNotSupportedException(ex.getMessage());
        }
        if (contentType == null) {
            noContentType = true;
            contentType = MediaType.APPLICATION_OCTET_STREAM;
        }

        Class<?> contextClass = (parameter != null ? parameter.getContainingClass() : null);
        Class<T> targetClass = (targetType instanceof Class ? (Class<T>) targetType : null);
        if (targetClass == null) {
            ResolvableType resolvableType = (parameter != null ?
                    ResolvableType.forMethodParameter(parameter) : ResolvableType.forType(targetType));
            targetClass = (Class<T>) resolvableType.resolve();
        }

        HttpMethod httpMethod = ((HttpRequest) inputMessage).getMethod();
        Object body = NO_VALUE;

        try {
            inputMessage = new EmptyBodyCheckingHttpInputMessage(inputMessage);

            for (HttpMessageConverter<?> converter : this.messageConverters) {
                Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
                if (converter instanceof GenericHttpMessageConverter) {
                    GenericHttpMessageConverter<?> genericConverter = (GenericHttpMessageConverter<?>) converter;
                    if (genericConverter.canRead(targetType, contextClass, contentType)) {
                        if (logger.isDebugEnabled()) {
                            logger.debug("Read [" + targetType + "] as \"" + contentType + "\" with [" + converter + "]");
                        }
                        if (inputMessage.getBody() != null) {
                            inputMessage = getAdvice().beforeBodyRead(inputMessage, parameter, targetType, converterType);
                            body = genericConverter.read(targetType, contextClass, inputMessage);
                            body = getAdvice().afterBodyRead(body, inputMessage, parameter, targetType, converterType);
                        }
                        else {
                            body = getAdvice().handleEmptyBody(null, inputMessage, parameter, targetType, converterType);
                        }
                        break;
                    }
                }
                else if (targetClass != null) {
                    if (converter.canRead(targetClass, contentType)) {
                        if (logger.isDebugEnabled()) {
                            logger.debug("Read [" + targetType + "] as \"" + contentType + "\" with [" + converter + "]");
                        }
                        if (inputMessage.getBody() != null) {
                            inputMessage = getAdvice().beforeBodyRead(inputMessage, parameter, targetType, converterType);
                            body = ((HttpMessageConverter<T>) converter).read(targetClass, inputMessage);
                            body = getAdvice().afterBodyRead(body, inputMessage, parameter, targetType, converterType);
                        }
                        else {
                            body = getAdvice().handleEmptyBody(null, inputMessage, parameter, targetType, converterType);
                        }
                        break;
                    }
                }
            }
        }
        catch (IOException ex) {
            throw new HttpMessageNotReadableException("Could not read document: " + ex.getMessage(), ex);
        }

        if (body == NO_VALUE) {
            if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
                    (noContentType && inputMessage.getBody() == null)) {
                return null;
            }
            throw new HttpMediaTypeNotSupportedException(contentType, this.allSupportedMediaTypes); //异常抛自这里
        }

        return body;
    }

观察上面代码可以看到,如果让SpringMVC来解析请求的数据,它会先遍历已有的转化器集合,里面包含JSON,XML等等转化器,如下图。不同的解析器会支持对应的MediaType,代码中会根据用户请求头中的ContentType值来做判断,确定具体使用哪种converter(详见converter.canRead(targetClass, contentType))。

converts

所以问题就很清楚了,个推那边请求时,请求头中的ContentType给的值是application/unknown;charset=UTF-8 ,跟我们想要的MappingJackson2HttpMessageConverter所支持的application/jsonapplication/*+json不匹配,所以没有定位到MappingJackson2HttpMessageConverter。而且也成功绕过了所有的converter(因为没有一个MediaType是对应application/unknown;charset=UTF-8这么坑的类型),所以最后抛异常了。

测试环境之所以没有测出来是因为我用swagger生成的curl默认给我加了--header 'Content-Type: application/json',毕竟swagger很规范,知道我请求的数据是json。

解决办法请接着往下看。

curl请求默认ContentType的“深坑”

上面的问题解决办法是改成这样接受请求public GetuiResponse getuiCallback(HttpServletRequest request),然后从request中拿到InputStream读出请求体JSON数据自己用ObjectMapper(jackson转化的一个类)转成对象。

写完后用POST man测试正常,想再用curl测试一下。考虑到之前是ContentType造成的问题,这次改成读流的方式就不传ContentType了。

大致请求是这样:

  curl --request POST \
  --url http://xxx/callback/getui \
  --data '{数据保密}'

结果又发现服务端报:

com.fasterxml.jackson.databind.JsonMappingException: No content to map due to end-of-input
 at [Source: com.enniu.cloud.service.server.filter.BaseServletRequestWrapper$ResettableServletInputStream@2fc8503; line: 1, column: 0]
    at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:270)
    at com.fasterxml.jackson.databind.ObjectMapper._initForReading(ObjectMapper.java:3838)
    at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:3783)

调试后发现从request中的InputStream读出来的数据是空的,导致ObjectMapper转化空数据报错,很困惑,为啥postman测试都可以,用charles抓包发现请求体却是是有数据的,实在不知道问题出在哪。

于是开始在网上搜索原因,找到了这个:

根据Servlet规范,如果同时满足下列条件,则请求体(Entity)中的表单数据,将被填充到request的parameter集合中(request.getParameter系列方法可以读取相关数据):
1 这是一个HTTP/HTTPS请求

2 请求方法是POST(querystring无论是否POST都将被设置到parameter中)

3 请求的类型(Content-Type头)是application/x-www-form-urlencoded

4 Servlet调用了getParameter系列方法

JSR-340 3.1.1

然后调试发现如果没有设置curl的ContentType,它会默认传application/x-www-form-urlencoded,这样就导致request中的stream流已经被读完了,后面再读就是空的了。

于是curl请求改下,加个任意的ContentType头,这样就不满足Servlet规范就可以了。

 curl --request POST \
  --url http://xxx/callback/getui \
  --header 'Content-Type: application/unknown;charset=UTF-8' \
  --data '{数据保密}'

这下问题终于水落石出了,当然代码写的是没有问题的,因为个推那边不是用curl给我们推数据,只是测试过程中遇到这种坑还是陷的蛮深的,纠结了好长时间。

参考链接:

https://blog.csdn.net/liuyang755855737/article/details/79998716

https://jcp.org/en/jsr/detail?id=340