SpringCloud OpenFeign 服务调用传递 token

·  阅读 2426

业务场景

通常微服务对于用户认证信息解析有两种方案

  • gateway 就解析用户的 token 然后路由的时候把 userId 等相关信息添加到 header 中传递下去。
  • gateway 直接把 token 传递下去,每个子微服务自己在过滤器解析 token

现在有一个从 A 服务调用 B 服务接口的内部调用业务场景,无论是哪种方案我们都需要把 header 从 A 服务传递到 B 服务。

RequestInterceptor

OpenFeign 给我们提供了一个请求拦截器 RequestInterceptor ,我们可以实现这个接口重写 apply 方法将当前请求的 header 添加到请求中去,传递给下游服务,RequestContextHolder 可以获得当前线程绑定的 Request 对象

/** Feign 调用的时候传token到下游 */
public class FeignRequestInterceptor implements RequestInterceptor {
  @Override
  public void apply(RequestTemplate template) {
    // 从header获取X-token
    RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
    ServletRequestAttributes attr = (ServletRequestAttributes) requestAttributes;
    HttpServletRequest request = attr.getRequest();
    String token = request.getHeader("x-auth-token");//网关传过来的 token
    if (StringUtils.hasText(token)) {
      template.header("X-AUTH-TOKEN", token);
    }
  }
}
复制代码

然后在 @FeignClient 中使用

@FeignClient(
    ...
    configuration = {FeignClientDecoderConfiguration.class, FeignRequestInterceptor.class})
public interface AuthCenterClient {
复制代码

多线程环境下传递 header(一)

上面是单线程的情况,假如我们在当前线程中又开启了子线程去进行 Feign 调用,那么是无法从 RequestContextHolder 获取到 header 的,原因很简单,看下 RequestContextHolder 源码就知道了,它里面是一个 ThreadLocal ,线程都变了,那肯定获取不到主线程请求里面的 requestAttribute 了。

原因已经清楚了,现在想办法去解决它。观察 RequestContextHolder.getRequestAttributes() 方法源码

public static RequestAttributes getRequestAttributes() {
   RequestAttributes attributes = requestAttributesHolder.get();
   if (attributes == null) {
      attributes = inheritableRequestAttributesHolder.get();
   }
   return attributes;
}
复制代码

注意到如果当前线程拿不到 RequestAttributes ,他会从 inheritableRequestAttributesHolder 里面拿,再仔细观察发现源码设置 RequestAttributesThreadLocal 的时候有这样一个重载方法

/**
 * 给当前线程绑定属性
 * @param inheritable 是否要将属性暴露给子线程
 */
public static void setRequestAttributes(@Nullable RequestAttributes attributes, boolean inheritable) {
   //......
}
复制代码

这特喵的完美符合我们的需求,现在我们的问题就是子线程没有拿到主线程的 RequestContextHolder 里面的属性。在业务代码中:

RequestContextHolder.setRequestAttributes(RequestContextHolder.getRequestAttributes(), true);
log.info("主线程任务....");

new Thread(() -> {
    log.info("子线程任务开始...");
    UserResponse response = client.getById(3L);
}).start();
复制代码

开发环境测试之后发现子线程已经能够从 RequestContextHolder 拿到主线程的请求对象了。

分析 inheritableRequestAttributesHolder 原理

观察源码我们可以看到这个属性的类型是 NamedInheritableThreadLocal 它继承了 InheritableThreadLocal 。还记得去年我第一次遇到开启多线程跨服务请求的时候始终不能理解为什么这玩意能把当前线程绑定的对象暴露给子线程。前几天 debug 了一下 InheritableThreadLocal.set() 方法恍然大悟。

其实这个东西对 Thread、ThreadLocal 有了解就会知道,在 Thread 的构造方法里面有这样一段代码

//...
Thread parent = currentThread(); //创建子线程的时候先拿父线程
//...
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
 this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals;
//...
复制代码

其实我们创建子线程的时候会先拿父线程,判断父线程里面的 inheritableThreadLocals 是不是有值,由于上面 RequestContextHolder.setRequestAttributes(xxx,true) 设置了 true ,所以父线程的 inheritableThreadLocals 是有 requestAttributes 的。这样创建子线程后,子线程的 inheritableThreadLocals 也有值了。所以后面我们在子线程中获取 requestAttributes 是能获取到的。

这样真的解决问题了吗?从非 web 层面来看,的确是解决了这个问题,但是在我们的 web 场景中并非如此。经过反复的测试,我们会发现子线程并不是每次都能获取到 header ,进而我们发现了这与父子线程的结束顺序有关,如果父线程早与子线程结束,那么子线程就获取不到 header ,反之子线程能获取到 header

IX.png

分析 inheritableRequestAttributesHolder 失效原因

其实标题并不严谨,因为子线程获取不到请求的 header 并不是因为 inheritableRequestAttributesHolder 失效。这个原因当初我也很奇怪,于是我从网上看到一篇文章,它是这么写的。

在源码中ThreadLocal对象保存的是RequestAttributes attributes;这个是保存的对象的引用一旦父线程销毁了,那RequestAttributes也会被销毁,那RequestAttributes的引用地址的值就为null**;**虽然子线程也有RequestAttributes的引用,但是引用的值为null了。

真的是这样吗??我怎么看怎么感觉不对......于是我自己验证了下

@GetMapping("/test")
public void test(HttpServletRequest request) {
    RequestAttributes attr = RequestContextHolder.getRequestAttributes();
    log.info("父线程:RequestAttributes:{}", attr);
    RequestContextHolder.setRequestAttributes(attr, true);
    log.info("父线程:SpringMVC:request:{}",request);
    log.info("父线程:x-auth-token:{}",request.getHeader("x-auth-token"));
    ServletRequestAttributes attr1 = (ServletRequestAttributes) attr;
    HttpServletRequest request1 = attr1.getRequest();
    log.info("父线程:request:{}",request1);
    new Thread(
            () -> {
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                RequestAttributes childAttr = RequestContextHolder.getRequestAttributes();
                log.info("子线程:RequestAttributes:{}",childAttr);
                ServletRequestAttributes childServletRequestAttr = (ServletRequestAttributes) childAttr;
                HttpServletRequest childRequest = childServletRequestAttr.getRequest();
                log.info("子线程:childRequest:{}",childRequest);
                String childToken = childRequest.getHeader("x-auth-token");
                log.info("子线程:x-auth-token:{}",childToken);
            }).start();
}
复制代码

观察日志

父线程:RequestAttributes:org.apache.catalina.connector.RequestFacade@ea25271
父线程:SpringMVC:request:org.apache.catalina.connector.RequestFacade@ea25271
父线程:x-auth-token:null
父线程:request:org.apache.catalina.connector.RequestFacade@ea25271

子线程:RequestAttributes:org.apache.catalina.connector.RequestFacade@ea25271
子线程:childRequest:org.apache.catalina.connector.RequestFacade@ea25271
子线程:x-auth-token:{}:null
复制代码

很明显子线程拿到了 RequestAttitutes 对象,而且和父线程是同一个,这就推翻了上面的说法,并不是引用变为 null 了导致的。那么到底是什么原因导致父线程结束后,子线程就拿不到 request 对象里面的 header 属性了呢?

我们可以猜测一下,既然父线程和子线程拿到的 request 对象是同一个,并且在子线程代码中 request 对象还不是 null,但是属性没了,那应该是请求结束之后某个地方对 request 对象进行了属性移除。我们跟随 RequestFacade 类去寻找真理,寻找寻找再寻找......终于我发现了真相在 org.apache.coyote.Request

QQ图片20220722112112.png

Tomcat 内部,请求结束后会对 request 对象重置,把 header 等属性移除,是因为这样如果父线程提前结束,我们在子线程中才无法获取 request 对象的 header

或许你可以再思考一下 Tomcat 为什么要这么做?

多线程环境下传递 header(二)

既然 RequestContextHolder.setRequestAttributes(attr, true); 也不能完全实现子线程能够获取父线程的 header ,那么我们如何解决呢?

控制主线程在子线程结束后再结束

这是最简单的方法,我把父线程挂起来,等子线程任务都执行完了,再结束父线程,这样就不会出现子线程获取不到 header 的情况了。最简单的,我们可以用 ExecutorCompletionService 实现。

重新保存 request 的 header

上面我们已经知道了获取不到 header 是因为 request 对象的 header 属性被移除了,那么我们只需要自己定义一个数据结构 ThreadLocal 重新在内存中保存一份 header 属性即可。我们可以定义一个请求拦截器,在拦截器中获取 headers 放到自定义的结构中。

定义结构

public class RequestHeaderHolder {
    private static final ThreadLocal<Map<String,String>> REQUEST_HEADER_HOLDER = new InheritableThreadLocal<>(){
        @Override
        protected Map<String, String> initialValue() {
            return new HashMap<>();
        }
    };
    //...省略部分方法
}
复制代码

拦截器

public class RequestHeaderInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Enumeration<String> headerNames = request.getHeaderNames();

        while (headerNames.hasMoreElements()){
            String s = headerNames.nextElement();
            RequestHeaderHolder.set(s,request.getHeader(s));
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        RequestHeaderHolder.remove(); //注意一定要remove
    }
}
复制代码

然后将这个拦截器添加到 InterceptorRegistry 即可。这样我们在子线程中就可以通过 RequestHeaderHolder 获取请求到 header

结语

本篇文章简单介绍 OpenFeign 调用传递 header ,以及多线程环境下可能会出现的问题。其中涉及到 ThreadLocal 的相关知识,如果有同学对 ThreadLocal、InheritableThreadLocal 不清楚的可以留言,后面出一篇 ThreadLocal 的文章。

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!

分类:
后端
收藏成功!
已添加到「」, 点击更改