springboot 中如何正確的在非同步執行緒中使用request

mysgk 發表於 2022-07-14
Spring

起因:

有後端同事反饋在非同步執行緒中獲取了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

springboot 中如何正確的在非同步執行緒中使用request

看到這裡大家可以猜一下是為什麼.

我的第一反應是不可能,肯定是前端同學寫的程式碼有問題,這麼簡單的一個介面怎麼可能有問題,然而等同事復現後就只能默默debug了.

大概追了一下原始碼,發現

spring 在做引數解析的時候沒有獲取到引數,方法如下:

org.springframework.web.method.annotation.RequestParamMethodArgumentResolver#resolveName

而且很奇怪,queryString 不是null ,獲取到了正確的引數, 但是 parameterMap 卻是空的.

正常來說 parameterMap 裡面應該存放有 queryString 解析後的引數.

如圖:

springboot 中如何正確的在非同步執行緒中使用request

發現有人踩過坑,但是沒解決

搜尋了一下,發現有人碰到過類似的情況

偶現的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 裡面有引數,那豈不是自己再解析一次不就完美了嗎?

那這個時候我們只要

  1. 繼承原始的引數解析器,當它獲取不到的時候嘗試從 queryString 尋找,queryString 中存在我們就返回 queryString 中的引數.
  2. 替換掉原始的引數解析器,具體做法就是 在 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;
}

這個方案實現以後給專案組上的同事整合後看起來是沒什麼問題了.

引數也能獲取到了,業務也跑通了,也不會報錯了.

但是其實這是一個治標不治本的方案
還存在一些問題:

  1. 只能解決介面引數繫結的問題,不能解決後續從request中獲取引數的問題.
  2. 通過壓測, postTest 和 getParams 這兩個介面, 發現 age3/name3 大概會出現null, age2/name2 也可能獲取到null, 只有介面引數中的 name 和age 能正確獲取到.

還是甩給官方

這個時候我已經沒什麼好的辦法了,於是給spring 提了一個issue:

in asynchronous tasks use request.getParameter(), It may cause the next "get request" to fail to obtain parameters

等待回覆是痛苦的,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

  1. 使用非同步前先獲取 AsyncContext
  2. 使用執行緒池處理任務
  3. 任務完成後呼叫asyncContext.complete()

原文連結:https://www.cnblogs.com/mysgk/p/16470336.html