Day610.SpringWebHeader解析常见错误 -Spring编程常见错误

Posted 阿昌喜欢吃黄桃

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Day610.SpringWebHeader解析常见错误 -Spring编程常见错误相关的知识,希望对你有一定的参考价值。

SpringWebHeader解析常见错误

针对SpringWeb开发解析Http请求中的参数有很多,之前的文章写的涉及的是Spring在URI上的参数解析的方案

那这次记录的笔记是Spring针对http请求中Header头上参数解析的一些常见问题。

针对Header 往往是不二之举,Header 是介于 URL 和 Body 之外的第二大重要组成,它提供了更多的信息以及围绕这些信息的相关能力,例如 Content-Type 指定了我们的请求或者响应的内容类型,便于我们去做解码。

虽然 Spring 对于 Header 的解析,大体流程和 URL 相同,但是 Header 本身具有自己的特点。


一、Header使用错的Map类型接收

我们想使用一个名为 myHeaderName 的 Header,我们会如下声明书写:

定义一个参数,标记上 @RequestHeader,指定要解析的 Header 名即可。

@RequestMapping(path = "/hi", method = RequestMethod.GET)
public String hi(@RequestHeader("myHeaderName") String name)
   //省略 body 处理
;

但是假设我们需要解析的 Header 很多时,按照上面的方式很明显会使得参数越来越多。

在这种情况下,我们一般都会使用 Map 去把所有的 Header 都接收到,然后直接对 Map 进行处理。于是我们可能会写出下面的代码:

@RequestMapping(path = "/hi1", method = RequestMethod.GET)
public String hi1(@RequestHeader() Map map)
    return map.toString();
;

正常情况下,是不会有问题。但是当我们需要对同名的Header中的一个参数指定两个值就会出现获取不全的问题。如下发起请求参数:

GET http://localhost:8080/hi1
myheader: h1
myheader: h2

这里存在一个 Header 名为 myHeader,不过这个 Header 有两个值。

此时我们执行请求,会发现返回的结果并不能将这两个值如数返回。结果示例如下:

发现这个Map去解析header中myheader作为Key的情况,只获取了其中一个

myheader=h1, host=localhost:8080, connection=Keep-Alive, user-agent=Apache-HttpClient/4.5.12 (Java/11.0.6), accept-encoding=gzip,deflate

那是为什么呢???


对于一个 Header 的解析,主要有两种方式,分别实现在 RequestHeaderMethodArgumentResolverRequestHeaderMapMethodArgumentResolver 中。

它们都继承于 AbstractNamedValueMethodArgumentResolver,但是应用的场景不同,我们可以对比下它们的 supportsParameter(),来对比它们适合的场景:

在上图中,左边是 RequestHeaderMapMethodArgumentResolver 的方法。

通过比较可以发现,对于一个标记了 @RequestHeader 的参数,如果它的类型是 Map,则使用 RequestHeaderMapMethodArgumentResolver,否则一般使用的是 RequestHeaderMethodArgumentResolver。

在我们的案例中,很明显,参数类型定义为 Map,所以使用的自然是 RequestHeaderMapMethodArgumentResolver。

接下来,继续查看它是如何解析 Header 的,关键代码参考 resolveArgument():

@Override
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
      NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception 
   Class<?> paramType = parameter.getParameterType();
   //先判断是否接收的类型是MultiValueMap吗?
   if (MultiValueMap.class.isAssignableFrom(paramType)) 
      MultiValueMap<String, String> result;
      if (HttpHeaders.class.isAssignableFrom(paramType)) 
         result = new HttpHeaders();
      
      else 
         result = new LinkedMultiValueMap<>();
      
      for (Iterator<String> iterator = webRequest.getHeaderNames(); iterator.hasNext();) 
         String headerName = iterator.next();
         String[] headerValues = webRequest.getHeaderValues(headerName);
         if (headerValues != null) 
            for (String headerValue : headerValues) 
               result.add(headerName, headerValue);
            
         
      
      return result;
   
   else 
      Map<String, String> result = new LinkedHashMap<>();
      for (Iterator<String> iterator = webRequest.getHeaderNames(); iterator.hasNext();) 
         String headerName = iterator.next();
         //只取了一个“值”,解析获取 Header 值的实际调用
         //在不同的容器下实现不同
         String headerValue = webRequest.getHeader(headerName);
         if (headerValue != null) 
            result.put(headerName, headerValue);
         
      
      return result;
   

针对我们的案例,这里并不是 MultiValueMap,所以我们会走入 else 分支。

这个分支首先会定义一个 LinkedHashMap,然后将请求一一放置进去,并返回。

在 Tomcat 容器下,它的执行方法参考 MimeHeaders#getValue

public MessageBytes getValue(String name) 
    for (int i = 0; i < count; i++) 
        if (headers[i].getName().equalsIgnoreCase(name)) 
        	//只要匹配到一个,就直接返回,所以他不会去取到一个Key对应多个Value的每个值
            return headers[i].getValue();
        
    
    return null;

在前面已经定义的接收类型是 LinkedHashMap,它的 Value 的泛型类型是 String,也不适合去组织多个值的情况。

综上,不管是结合代码还是常识,本代码中 是不能获取到myHeader 的所有值

那么又到了如何解决呢????


提供解决方案如下:

  • 使用MultiValueMap接收参数,或HttpHeaders
//方式 1
@RequestHeader() MultiValueMap map
//方式 2
@RequestHeader() HttpHeaders map

看如上框中的代码,看MultiValueMap的命名我们也能看出,这个Map的类型是可以接收多个Value的Map。那顾名思义,我们就用MultiValueMap类型来接受是可以解决问题的


二、header忽略首大小写问题

在 HTTP 协议中,Header 的名称是无所谓大小写的。

在使用各种框架构建 Web 时,我们都会把这个事实铭记于心。我们可以验证下这个想法。

例如,有一个 Web 服务接口如下:

@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestHeader("MyHeader") String myHeader)
    return myHeader;
;

然后,我们使用下面的请求来测试这个接口是可以获取到对应的值的:

GET http://localhost:8080/hi2
myheader: myheadervalue

另外,结合案例 1,我们知道可以使用 Map 来接收所有的 Header,那么这种方式下是否也可以忽略大小写呢?

这里我们不妨使用下面的代码来比较下:

@RequestMapping(path = "/hi2", method = RequestMethod.GET)
public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader MultiValueMap map)
    return myHeader + " compare with : " + map.get("MyHeader");
;

再次运行之前的测试请求,我们得出下面的结果:

myheadervalue compare with : null

结论是:通过@RequestHeader("MyHeader")的方式是不区分首字母大小写的;而@RequestHeader MultiValueMap map后者相反。

那为什么会这样子呢?


对于"@RequestHeader(“MyHeader”) String myHeader"的定义,Spring 使用的是 RequestHeaderMethodArgumentResolver 来做解析。

解析的方法参考 RequestHeaderMethodArgumentResolver#resolveName

protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception 
   String[] headerValues = request.getHeaderValues(name);
   if (headerValues != null) 
      return (headerValues.length == 1 ? headerValues[0] : headerValues);
   
   else 
      return null;
   

从上述方法的关键调用"request.getHeaderValues(name)"去按图索骥,我们可以找到查找 Header 的最根本方法,即 org.apache.tomcat.util.http.ValuesEnumerator#findNext

private void findNext() 
    next=null;
    for(; pos< size; pos++ ) 
        MessageBytes n1=headers.getName( pos );
        if( n1.equalsIgnoreCase( name )) 
            next=headers.getValue( pos );
            break;
        
    
    pos++;

在上述方法中,name 即为查询的 Header 名称,可以看出这里是忽略大小写的。


如果我们用 Map 来接收所有的 Header,我们来看下这个 Map 最后存取的 Header 和获取的方法有没有忽略大小写。

有了案例 1 的解析,针对当前的类似案例,结合具体的代码,我们很容易得出下面两个结论。

  • 存取 Map 的 Header 是没有忽略大小写的

参考案例 1 解析部分贴出的代码,可以看出,在存取 Header 时,需要的 key 是遍历 webRequest.getHeaderNames() 的返回结果。

而这个方法的执行过程参考 org.apache.tomcat.util.http.NamesEnumerator#findNext

private void findNext() 
    next=null;
    for(; pos< size; pos++ ) 
        next=headers.getName( pos ).toString();
        for( int j=0; j<pos ; j++ ) 
            if( headers.getName( j ).equalsIgnoreCase( next )) 
                // duplicate.
                next=null;
                break;
            
        
        if( next!=null ) 
            // it's not a duplicate
            break;
        
    
    // next time findNext is called it will try the
    // next element
    pos++;

这里,返回结果并没有针对 Header 的名称做任何大小写忽略或转化工作。

  • 从 Map 中获取的 Header 也没有忽略大小写

这点可以从返回是 LinkedHashMap 类型看出,LinkedHashMap 的 get() 未忽略大小写。

那如何解决呢?


那对于的解决方案是什么?

  • 获取 Header的接收类型为 Map 时注意下大小写

    @RequestMapping(path = "/hi2", method = RequestMethod.GET)
    public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader MultiValueMap map)
        return myHeader + " compare with : " + map.get("myHeader");
    ;
    
  • 使用的是 HttpHeaders接收

    @RequestMapping(path = "/hi2", method = RequestMethod.GET)
    public String hi2(@RequestHeader("MyHeader") String myHeader, @RequestHeader HttpHeaders map)
        return myHeader + " compare with : " + map.get("MyHeader");
    ;
    

    它的构造器推测出来,其构造器代码如下:

    public HttpHeaders() 
    	//它使用的是 LinkedCaseInsensitiveMap,而不是普通的 LinkedHashMap。所以这里是可以忽略大小写的,我们不妨这样修正:
       this(CollectionUtils.toMultiValueMap(new LinkedCaseInsensitiveMap<>(8, Locale.ENGLISH)));
    
    

在实际使用时,虽然 HTTP 协议规范可以忽略大小写,但是不是所有框架提供的接口方法都是可以忽略大小写的。


三、试图在 Controller 中随意自定义 CONTENT_TYPE 等

直接通过addHeader的方式,去自定义CONTENT_TYPE的类型

@RequestMapping(path = "/hi3", method = RequestMethod.GET)
public String hi3(HttpServletResponse httpServletResponse)
  httpServletResponse.addHeader("myheader", "myheadervalue");
  //直接通过addHeader的方式,去自定义CONTENT_TYPE 的类型
  httpServletResponse.addHeader(HttpHeaders.CONTENT_TYPE, "application/json");
    return "ok";
;

运行程序测试下(访问 GET http://localhost:8080/hi3 ),我们会得到如下结果:

GET http://localhost:8080/hi3
HTTP/1.1 200
myheader: myheadervalue
Content-Type: text/plain;charset=UTF-8
Content-Length: 2
Date: Wed, 17 Mar 2021 08:59:56 GMT
Keep-Alive: timeout=60
Connection: keep-alive

发现对于的Content-Type: text/plain;charset=UTF-8,没有修改成功。


首先我们来看下在 Spring Boot 使用内嵌 Tomcat 容器时,尝试添加 Header 会执行哪些关键步骤。

第一步我们可以查看 org.apache.catalina.connector.Response#addHeader 方法,代码如下:

private void addHeader(String name, String value, Charset charset) 
    //省略其他非关键代码
    char cc=name.charAt(0);
    if (cc=='C' || cc=='c') 
        //判断是不是 Content-Type,如果是不要把这个 Header 作为 header 添加到 org.apache.coyote.Response
        if (checkSpecialHeader(name, value))
        return;
    

    getCoyoteResponse().addHeader(name, value, charset);

参考代码及注释,正常添加一个 Header 是可以添加到 Header 集里面去的,但是如果这是一个 Content-Type,则事情会变得不一样。

它并不会如此做,而是去做另外一件事,即通过 Response#checkSpecialHeader 的调用来设置 org.apache.coyote.Response#contentType 为 application/json,关键代码如下:

private boolean checkSpecialHeader(String name, String value) 
    if (name.equalsIgnoreCase("Content-Type")) 
        setContentType(value);
        return true;
    
    return false;

最终我们获取到的 Response 如下:

从上图可以看出,Headers 里并没有 Content-Type,而我们设置的 Content-Type 已经作为 coyoteResponse 成员的值了。

当然也不意味着后面一定不会返回,我们可以继续跟踪后续执行。

在案例代码返回 ok 后,我们需要对返回结果进行处理,执行方法为 RequestResponseBodyMethodProcessor#handleReturnValue,关键代码如下:

@Override
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType,
      ModelAndViewContainer mavContainer, NativeWebRequest webRequest)
      throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException 

   mavContainer.setRequestHandled(true);
   ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
   ServletServerHttpResponse outputMessage = createOutputMessage(webRequest);

   //对返回值(案例中为“ok”)根据返回类型做编码转化处理
   writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage);

而在上述代码的调用中,writeWithMessageConverters 会根据返回值及类型做转化,同时也会做一些额外的事情。

它的一些关键实现步骤参考下面几步:


  • 决定用哪一种 MediaType 返回
   //决策返回值是何种 MediaType    
   MediaType selectedMediaType = null;
   MediaType contentType = outputMessage.getHeaders().getContentType();
   boolean isContentTypePreset = contentType != null && contentType.isConcrete();
   //如果 header 中有 contentType,则用其作为选择的 selectedMediaType。
   if (isContentTypePreset) 
      selectedMediaType = contentType;
   
   //没有,则根据“Accept”头、返回值等核算用哪一种
   else 
      HttpServletRequest request = inputMessage.getServletRequest();
      List<MediaType> acceptableTypes = getAcceptableMediaTypes(request);
      List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);
      //省略其他非关键代码 
      List<MediaType> mediaTypesToUse = new ArrayList<>();
      for (MediaType requestedType : acceptableTypes) 
         for (MediaType producibleType : producibleTypes) 
            if (requestedType.isCompatibleWith(producibleType)) 
 mediaTypesToUse.add(getMostSpecificMediaType(requestedType, producibleType));
            
         
      
      //省略其他关键代码 
      for (MediaType mediaType : mediaTypesToUse) 
         if (mediaType.isConcrete()) 
            selectedMediaType = mediaType;
            break;
         
        //省略其他关键代码 
      

上述代码是先根据是否具有 Content-Type 头来决定返回的 MediaType,通过前面的分析它是一种特殊的 Header,在 Controller 层并没有被添加到 Header 中去,所以在这里只能根据返回的类型、请求的 Accept 等信息协商出最终用哪种 MediaType。

实际上这里最终使用的是 MediaType#TEXT_PLAIN。

这里还需要补充说明下,没有选择 JSON 是因为在都支持的情况下,TEXT_PLAIN 默认优先级更高,参考代码 WebMvcConfigurationSupport#addDefaultHttpMessageConverters 可以看出转化器是有优先顺序的,所以用上述代码中的 getProducibleMediaTypes() 遍历 Converter 来收集可用 MediaType 也是有顺序的。

  • 选择消息转化器并完成转化

决定完 MediaType 信息后,即可去选择转化器并执行转化,关键代码如下:

for (HttpMessageConverter<?> converter : this.messageConverters) 
   GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
         (GenericHttpMessageConverter<?>) converter : null);
   if (genericConverter != null ?
         ((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
         converter.canWrite(valueType, selectedMediaType)) 
      //省略其他非关键代码
      if (body != null) 
        //省略其他非关键代码
         if (genericConverter != null) 
            genericConverter.write(body, targetType, selectedMediaType, outputMessage);
         
         else 
            ((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
         
      
      //省略其他非关键代码
   

如代码所示,即结合 targetType(String)、valueType(String)、selectedMediaType(MediaType#TEXT_PLAIN)三个信息来决策可以使用哪种消息 Converter。常见候选 Converter 可以参考下图:

最终,本案例选择的是 StringHttpMessageConverter,在最终调用父类方法 AbstractHttpMessageConverter#write 执行转化时,会尝试添加 Content-Type。

具体代码参考 AbstractHttpMessageConverter#addDefaultHeaders

protected void addDefaultHeaders(HttpHeaders headers, T t, @Nullable MediaType contentType) throws IOException 
   if (headers.getContentType() == null) 
      MediaType contentTypeToUse = contentType;
      if (contentType == null || contentType.isWildcardType() || contentType.isWildcardSubtype()) 
         contentTypeToUse = getDefaultContentType(t);
      
      else if (MediaType.APPLICATION_OCTET_STREAM.equals(contentType)) 
         MediaType mediaType = getDefaultContentType(t);
         contentTypeToUse = (mediaType != null ? mediaType : contentTypeToUse

以上是关于Day610.SpringWebHeader解析常见错误 -Spring编程常见错误的主要内容,如果未能解决你的问题,请参考以下文章

day23-xml解析

day23-xml解析

day23-xml解析

day23-xml解析

day18——json

Python学习Day4