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
裡面拿,再仔細觀察發現源碼設置 RequestAttributes
到 ThreadLocal
的時候有這樣一個重載方法
/** * 給當前線程綁定屬性 * @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!
推薦閱讀:
- Feign調用中的兩種Header傳參方式小結
- 解決Spring Cloud feign GET請求無法用實體傳參的問題
- Spring Cloud Feign請求添加headers的實現方式
- 使用springcloud+oauth2攜帶token去請求其他服務
- Spring中使用自定義ThreadLocal存儲導致的坑及解決