SpringCloud OpenFeign 服務調用傳遞 token的場景分析

業務場景

通常微服務對於用戶認證信息解析有兩種方案

  • 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

分析 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

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 的文章。

到此這篇關於SpringCloud OpenFeign 服務調用傳遞 token的場景分析的文章就介紹到這瞭,更多相關SpringCloud OpenFeign傳遞 token內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: