起因:
有後端同事反饋在非同步執行緒中獲取了request中的引數,然後下一個請求是get請求的話,發現會偶爾出現引數丟失的問題.
示例程式碼:
@GetMapping("/getParams")
public String getParams(String a, int b) {
return "get success";
}
@PostMapping("/postTest")
public String postTest(HttpServletRequest request,String age, String name) {
new Thread(new Runnable() {
@Override
public void run() {
String age2 = request.getParameter("age");
String name2 = request.getParameter("name");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
String age3 = request.getParameter("age");
String name3 = request.getParameter("name");
System.out.println("age1: " + age + " , name1: " + name + " , age2: " + age2 + " , name2: " + name2 + " , age3: " + age3 + " , name3: " + name3);
}
}).start();
return "post success";
}
異常資訊如下
java.lang.IllegalStateException:
Optional int parameter 'b' is present but cannot be translated into a null value due to being declared as a primitive type.
Consider declaring it as object wrapper for the corresponding primitive type
看到這裡大家可以猜一下是為什麼.
我的第一反應是不可能,肯定是前端同學寫的程式碼有問題,這麼簡單的一個介面怎麼可能有問題,然而等同事復現後就只能默默debug了.
大概追了一下原始碼,發現
spring 在做引數解析的時候沒有獲取到引數,方法如下:
org.springframework.web.method.annotation.RequestParamMethodArgumentResolver#resolveName
而且很奇怪,queryString 不是null ,獲取到了正確的引數, 但是 parameterMap 卻是空的.
正常來說 parameterMap 裡面應該存放有 queryString 解析後的引數.
如圖:
發現有人踩過坑,但是沒解決
搜尋了一下,發現有人碰到過類似的情況
偶現的MissingServletRequestParameterException,誰動了我的引數?
由於Tomcat中,Request以及Response物件都是會被迴圈使用的,因此這個時候也是整個Request被重置的時候。
所以根本原因是,在Parameter被重置了之後,didQueryParameters又被置成了true,導致新的請求引數沒有被正確解析,就報錯了(此時的parameterMap已經被重置,為空)。
而didQueryParameters只有在一種情況下才會被置為true,也就是handleQueryParameters方法被呼叫時。
而handleQueryParameters會在多個場景中被呼叫,其中一個就是getParameterValues,獲取請求引數的值。
大概就是說 tomcat 會複用Request物件,在非同步中使用request中的引數可能會影響下一次 請求的引數解析過程.
最後文章作者的結論就是
不要將HttpServletRequest傳遞到任何非同步方法中!
嘗試尋找官方支援
看到這裡我還是有點不信,心想tomcat不會這麼拉吧,非同步都不支援,不可能吧...
於是我就去 tomcat的 bugzilla 搜了一下,居然沒搜尋到相關的問題.
然後我還是有點不甘心,tomcat 沒有 ,spring框架出來這麼久難道就沒人碰到過這種問題提出疑問嗎?
又去 spring的 issue 裡面去搜,可能是我的關鍵詞沒搜對,還是沒找到什麼有用資訊.
這時我就有點洩氣了,官方都沒解決這個問題我咋個辦?
嘗試自己解決
不過我又突然想到既然引數解析的時候 queryString 裡面有引數,那豈不是自己再解析一次不就完美了嗎?
那這個時候我們只要
- 繼承原始的引數解析器,當它獲取不到的時候嘗試從 queryString 尋找,queryString 中存在我們就返回 queryString 中的引數.
- 替換掉原始的引數解析器,具體做法就是 在 RequestMappingHandlerAdapter 初始化後,拿到 argumentResolvers,遍歷所有的引數解析器,找到 RequestParamMethodArgumentResolver ,換成我們的即可.
這裡有兩個問題需要注意就是 :
- argumentResolvers 是一個 UnmodifiableList,不能直接set
- RequestParamMethodArgumentResolver 有兩個,其中一個 useDefaultResolution 屬性值為 true,另外一個 屬性值為 false,解析get請求 url中引數的是 useDefaultResolution 屬性值為 true 的那一個.
spring原始碼對應位置:
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter#getDefaultInitBinderArgumentResolvers
private List<HandlerMethodArgumentResolver> getDefaultInitBinderArgumentResolvers() {
List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>(20);
// Annotation-based argument resolution
resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false));
resolvers.add(new RequestParamMapMethodArgumentResolver());
resolvers.add(new PathVariableMethodArgumentResolver());
resolvers.add(new PathVariableMapMethodArgumentResolver());
resolvers.add(new MatrixVariableMethodArgumentResolver());
resolvers.add(new MatrixVariableMapMethodArgumentResolver());
resolvers.add(new ExpressionValueMethodArgumentResolver(getBeanFactory()));
resolvers.add(new SessionAttributeMethodArgumentResolver());
resolvers.add(new RequestAttributeMethodArgumentResolver());
// Type-based argument resolution
resolvers.add(new ServletRequestMethodArgumentResolver());
resolvers.add(new ServletResponseMethodArgumentResolver());
// Custom arguments
if (getCustomArgumentResolvers() != null) {
resolvers.addAll(getCustomArgumentResolvers());
}
// Catch-all
resolvers.add(new PrincipalMethodArgumentResolver());
resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true));
return resolvers;
}
這個方案實現以後給專案組上的同事整合後看起來是沒什麼問題了.
引數也能獲取到了,業務也跑通了,也不會報錯了.
但是其實這是一個治標不治本的方案
還存在一些問題:
- 只能解決介面引數繫結的問題,不能解決後續從request中獲取引數的問題.
- 通過壓測, postTest 和 getParams 這兩個介面, 發現 age3/name3 大概會出現null, age2/name2 也可能獲取到null, 只有介面引數中的 name 和age 能正確獲取到.
還是甩給官方
這個時候我已經沒什麼好的辦法了,於是給spring 提了一個issue:
等待回覆是痛苦的,issue提了以後
等了三天,開發者叫我提交一個復現的 demo (大家也可以嘗試復現一下).
又等了兩天,我想著這樣等也不是個辦法
主要是我看到 issue 還有 1.2k,輪到我的時候估計都猴年馬月了
而且就算修復了估計也是新版本, 在專案上升級 springboot 版本 估計也不太現實(版本不相容)
解決
於是我開始看原始碼.直到我看到了一個
org.apache.coyote.Request#setHook
它裡面有個 ActionCode,是一個列舉型別,其中有一個列舉值是
ASYNC_START
這玩意看著就和非同步有關.於是開始搜尋相關資料
最後終於在
RequestLoggingFilter: afterRequest is executed before Async servlet finishes
中找到答案.
結合我的程式碼改造如下
@PostMapping("/postTest")
public String postTest(HttpServletRequest request, HttpServletResponse response, String age, String name) {
AsyncContext asyncContext =
request.isAsyncStarted()
? request.getAsyncContext()
: request.startAsync(request, response);
asyncContext.start(new Runnable() {
@Override
public void run() {
String age2 = request.getParameter("age");
String name2 = request.getParameter("name");
try {
Thread.sleep(10);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
String age3 = request.getParameter("age");
String name3 = request.getParameter("name");
System.out.println("age1: " + age + " , name1: " + name + " , age2: " + age2 + " , name2: " + name2 + " , age3: " + age3 + " , name3: " + name3);
asyncContext.complete();
}
});
return "post success";
}
ps: 此處應該用執行緒池提交任務,不想改了
壓測一把發現沒啥問題
結論
springboot 中如何正確的在非同步執行緒中使用request
- 使用非同步前先獲取 AsyncContext
- 使用執行緒池處理任務
- 任務完成後呼叫asyncContext.complete()