Loading

APP 莫名崩溃,开始以为是 Header 中 name 大小写的锅,最后发现原来是容器的错!

前言


部署测试,部署预发布,一切测试就绪,上生产。

发布生产

闪退

What???

马上回滚

开始排查

后端一模一样的代码,不是 APP 端的问题吧。可 APP 端没有发版啊。

…… 一番排查

原来是 APP 端打包,测试和预发布包 Header 传的都是 Authorization ,生产传的是 authorization 。就是大小写问题,那赶紧改。

背景

首页接口只有登录才可以进入,因为首页要展示获取用户账户的一些信息。这里使用的是统一拦截,从 Header 中获取 token 后,使用 token 获取用户信息。

而现在要改为用户未登录也可以查看首页信息中的宣传文案等等,只不过账户信息不显示。

原流程

FiwOie-8ZLjgv

整个过程代码在 ThreadLocal底层原理 里面有所介绍。这里省略一部分代码。

@Component
public class TokenInterceptor implements HandlerInterceptor {

    @Override
    public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3)
            throws Exception {
        LocalUserUtils.remove();
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 请求方法是否存在注解
        boolean assignableFrom = handler.getClass().isAssignableFrom(HandlerMethod.class);

        if (!assignableFrom) {
            return true;
        }

        CheckToken checkToken = null;
        if (handler instanceof HandlerMethod) {
            checkToken = ((HandlerMethod) handler).getMethodAnnotation(CheckToken.class);
        }

        // 没有加注解 直接放过
        if (checkToken == null) {
            return true;
        }

        // 从Header中获取Authorization
        String authorization = request.getHeader("Authorization");
        log.info("header authorization : {}", authorization);
        if (StringUtils.isBlank(authorization)) {
            log.error("从Header中获取Authorization失败");
            throw CustomExceptionEnum.NOT_HAVE_TOKEN.throwCustomException();
        }

        // 其他代码省略

        return true;
    }
}

从代码中可以看出这里大概过程如下:

  1. 是使用拦截器拦截请求
  2. 如果方法没有 CheckToken 注解直接放过
  3. 有 CheckToken 注解,则从 request 的 header 中获取 Authorization

新需求

这里想到只需要把注解去掉,然后从请求参数中获取 token 即可。获取到走原逻辑,获取不到则只返回宣传文案等信息。

从 Header 中获取信息

直接获取请求头某一个 headerName

@PostMapping("/getAuthorizationByKey")
public String getAuthorizationByKey(@RequestHeader("Authorization") String authorization) {

    log.info("获取 Authorization --->{}", authorization);

    return authorization;
}

使用 Map 获取所有请求头

@PostMapping("/getAuthorizationByMap")
public String getAuthorizationByMap(@RequestHeader Map<String, String> map) {

    String authorization = map.get("Authorization");

    log.info("获取 Authorization --->{}", authorization);

    return authorization;
}

使用 MultiValueMap 获取请求头

@PostMapping("/getAuthorizationByMultiValueMap")
public String getAuthorizationByMultiValueMap(@RequestHeader MultiValueMap<String, String> map) {

    List<String> authorization = map.get("Authorization");

    log.info("获取 Authorization --->{}", authorization);

    return "SUCCESS";
}

使用 HttpHeaders 获取请求头

@PostMapping("/getAuthorizationByHeaders")
public String getAuthorizationByHeaders(@RequestHeader HttpHeaders headers) {

    List<String> authorization = headers.get("Authorization");

    log.info("获取 Authorization --->{}", authorization);

    return "SUCCESS";
}

使用 HttpServletRequest 获取

@PostMapping("/getAuthorizationByServlet")
public String getAuthorizationByServlet(HttpServletRequest request) {

    String authorization = request.getHeader("Authorization");

    log.info("获取 Authorization --->{}", authorization);

    return authorization;
}

测试文件

dqAEES-LSudFy

经过测试这些都是可以的,最终选择使用 Map 接收 Header ,然后从 Map 中获取 Authorization。

PS: 可能有小伙伴测试不过,发现接受的 header 里的 name 全都是小写了,可以继续阅读。
源码在文末,也可以关注公众号,发送 headerName/4 获取。

rXC2fS-cVftVR

你以为事情如果到这里就结束了,那真是太天真了。

这不,出现了文章开头的描述的场景,赶紧回滚,然后排查问题,最后定位到是 Header 的 name 大小写问题。

思考

  1. 之前 APP 端也是这么传的,那为什么使用拦截器是正常的呢?
  2. 上面的那几种方式是不是都是这样?
  3. 不排除 tomcat 发现原来都会转换为小写,又是为什么?

模拟排查

环境配置

模拟生产首先使用相同的容器配置,这里排除了内置的 tomcat 容器,并且使用 undertow 容器。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
        <!-- Exclude the Tomcat dependency -->
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

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

使用拦截器传小写为什么没有问题

  • 修改使用小写 authorization

BnwZnf-7AvYYg

  • debug 断点

OOjOyV-54K6Na

神奇的一幕出现了,收到的确实是小写,但是 request.getHeader("Authorization"); 却可以获取到 authorization

  • F7 继续往里跟

JwgnFS-cW3pJY

io.undertow.servlet.spec.HttpServletRequestImpl#getHeader 第 190 行,从 HeaderMap 中获取第一个元素

DnRSum-crs0Mb

io.undertow.util.HeaderMap#getFirst 第 297 行, 通过 getEntry 方法获取 header

C8lSGw-XxKDPW

继续追踪,发现在 io.undertow.util.HeaderMap#getEntry(java.lang.String) 方法 77~79 行的时候获取到了 header 信息。那就看一下这块的源码吧。

在仔细看一下发现是 77 行 final int hc = HttpString.hashCodeOf(headerName); 在获取 name 的 hashCode 时,这里无论大小写,都是同一个 hashCode。这块代码如下

3v1FVJ-MuPXUA

higher 方法:

private static int higher(byte b) {
    return b & (b >= 'a' && b <= 'z' ? 0xDF : 0xFF);
}

这块的含义

  1. 如果 b 是小写字符则 b & 0xDF
  2. 如果 b 是大写字符则 b & 0xFF

对照 ASCII 表,大小写字母相差 32 而 0xFF(255) 和 0xDF(223) 同样相差 32,所以问题定位到了。header 的 name 无论是大写还是小写,都会查出同一个值。

当然你也可以这么传

VOOF8k-LoGjAf

这样的话谁在上面,Header 中使用的 name 就是那个。

使用 Map 为什么会区分大小写

传入的是大写

HttpServlet 
-> DispatcherServlet#doDispatch 
-> AbstractHandlerMethodAdapter#handle 
-> RequestMappingHandlerAdapter#handleInternal 
-> RequestMappingHandlerAdapter#invokeHandlerMethod 
-> ServletInvocableHandlerMethod#invokeAndHandle
-> InvocableHandlerMethod#invokeForRequest (解析参数值)
-> InvocableHandlerMethod#getMethodArgumentValues
-> RequestHeaderMapMethodArgumentResolver#resolveArgument

dqAEES-LSudFy

如图所示 String headerName = iterator.next(); name 被区分大小写放到了 LinkedHashMap 中,后续会反射调用对应的 Controller 方法。

所以也就出现了我所遇到的问题。

当然理论上 APP 客户端不应该测试和预发布使用大写,而生产使用小写。

上面阅读的源码只是 Spring 对 Header 的处理,Spring 在 HttpServlet 收到请求时,Spring 没有对请求 Header 的 name 大小写进行转换,只是在获取对应 value 的时候,没有区分大小写进行获取。

容器对 header 的处理

undertow 容器的处理
  • 请求参数的处理

这里发现 undertow 并没有对请求参数进行大小写转换处理操作。

  • 从 HttpServletRequest 获取 header

debug 发现调用的是 io.undertow.servlet.spec.HttpServletRequestImpl#getHeader,这个过程就是上面的排查过程。

  • 从 Headers 中获取 header

通过 debug 发现 jetty 调用的是 org.springframework.http.HttpHeaders#get,然后调用 org.springframework.util.MultiValueMapAdapter#get,然后调用 org.springframework.util.LinkedCaseInsensitiveMap#get

nRw6xN-GkUqAN

这里会不区分大小写

  • 从 MultiValueMap 获取 header

这块 debug 发现是直接从 LinkedHashMap 获取的,所以区分了大小写。

tomcat 容器的处理

  • 请求参数的处理

而如果没有排除的话,即使用内嵌的 tomcat 容器无论传递大写还是小写,接收到的全部都是小写,又是怎么个情况呢?

通过 debug 发现没有排除 tomcat 使用的是,在接收请求时使用的是 org.apache.coyote.http11.Http11Processor

Http11Processor#service 方法中

303mmp-6TczCR

类 284 行负责处理解析 header

进入 org.apache.coyote.http11.Http11InputBuffer#parseHeaders 方法

cqHTEZ-1E9mH7

第 589 行 (Download Sources 后),阅读 parseHeader 方法

oEEbej-rMulR7

发现会将请求 header 的 name 转换为小写

  • 从 HttpServletRequest 获取 header

当使用 tomcat 容器时,调用 org.apache.catalina.connector.RequestFacade#getHeaderorg.apache.catalina.connector.Request#getHeaderorg.apache.coyote.Request#getHeader org.apache.tomcat.util.http.MimeHeaders#getHeader 最后调用 org.apache.tomcat.util.http.MimeHeaders#getValue 获取 header

qDJXXn-z6hTjZ

这里也会忽略大小写判断

  • 从 Headers 获取 header

通过 debug 发现 tomcat 容器下调用的是 org.springframework.http.HttpHeaders#get,然后调用 org.springframework.util.MultiValueMapAdapter#get,然后调用 org.springframework.util.LinkedCaseInsensitiveMap#get

nRw6xN-GkUqAN

这里会不区分大小写

  • 从 MultiValueMap 获取 header

这块 debug 发现是直接从 LinkedHashMap 获取的,所以区分了大小写。

jetty 容器的处理

  • 请求参数的处理

如果换成 jetty 容器的话

org.eclipse.jetty.server.HttpConnection 中又会发现无论传入大写还是小写都会被转换为驼峰。

源码可以阅读 org.eclipse.jetty.http.HttpParser#parseFields

Q9KjmT-HSRusz

会转换为驼峰命名法。

  • 从 HttpServletRequest 获取 header

通过 debug 发现 jetty 调用的是 org.eclipse.jetty.server.Request#getHeader

H9onCc-uyaMMn

jetty 在获取 header 时,会调用 org.eclipse.jetty.http.HttpFields#get

BOkxFw-op6ZIV

ioe7pS-uNuRU6

原来在获取的时候忽略了大小写

  • 从 Headers 获取 header

通过 debug 发现 jetty 容器下调用的是 org.springframework.http.HttpHeaders#get,然后调用 org.springframework.util.MultiValueMapAdapter#get,然后调用 org.springframework.util.LinkedCaseInsensitiveMap#get

nRw6xN-GkUqAN

这里会不区分大小写

  • 从 MultiValueMap 获取

也是调用的 org.springframework.util.MultiValueMapAdapter#get 然后不区分大小写。和从 Headers 中获取相同。

总结

Q&A

Q: 为什么拦截器获取 Authorization 可以不区分大小写?

A: 从拦截器获取 Authorization 其实就是从 HttpServletRequest 中获取,这里无论使用 tomcat 还是使用 undertow 或者 jetty 获取 Header 是都是忽略 headerName 的大小写的。具体可以阅读上面的源码分析。

Q: 这么多获取 Header 的方式有什么区别?
A:

不同的容器下实现方式不同,这里列表说明

undertow tomcat jetty
请求参数大小写转换 不变 小写 驼峰
直接获取请求头某一个 headerName 忽略大小写,不能为空 忽略大小写,不能为空 忽略大小写,不能为空
使用 Map 获取所有请求头 Map 的 key 和传入 headerName 大小写的一致,保持一致可获取到 Map 的 key 全是小写,需要使用小写headerName 获取 Map 的 key 是驼峰命名法,要使用驼峰命名才可以获取到
使用 MultiValueMap 获取请求头 实际是从 LinkedHashMap 中获取,区分大小写 实际是从 LinkedHashMap 中获取,区分大小写 从 LinkedCaseInsensitiveMap 获取,不区分大小写
使用 HttpHeaders 获取请求头 从 LinkedCaseInsensitiveMap 获取,不区分大小写 从 LinkedCaseInsensitiveMap 获取,不区分大小写 从 LinkedCaseInsensitiveMap 获取,不区分大小写
使用 HttpServletRequest 获取 使用 HttpString.hashCodeOf(headerName) 忽略了大小写 调用 MimeHeaders#getValue 忽略了大小写 HttpFields#get 忽略了大小写

通过表格发现,即使是不同的容器在使用 HttpHeaders 获取请求头是都是调用了 Spring 的 LinkedCaseInsensitiveMap 获取 header,并且内部忽略了大小写,这里比较推荐使用。

同样使用 HttpServletRequest 的方式获取也比较推荐。

结束语

本文主要是分析生产遇到的一个问题,然后开始探究原因,开始的时候发现是 Spring 的原因,因为使用 Map 接收时, headerName 什么格式就是什么格式。

在自己写 demo 时又发现,原来和 Spring 的关系并不大,是容器的原因。不同的容器处理方式不同。所以总结出来相关文章,供大家参考,不足之处,欢迎指正。

相关资料

  1. 本文源码地址:https://github.com/liuzhihang/header-demo
posted @ 2020-11-08 22:26  程序员小航  阅读(482)  评论(0编辑  收藏  举报