SpringBoot Web接口@RequestBody接受多種類型參數實現

一、背景
SpringBoot版本2.1.1-RELEASE。在工作中遇到了這樣一個特殊的需求:需要接收前臺傳入的參數,接收參數並封裝對象之後進行後續的處理。根據現有邏輯,前臺請求http接口的Content-Type有兩種,application/json和application/x-www-form-urlencoded。現要求兩種請求方式都能夠進行參數綁定。想到通過自定義一個HandlerMethodArgumentResolver來實現。

二、參數綁定的原理

測試代碼1:

@RestController
@RequestMapping("/test")
@Slf4j
public class TestWebController {
 
    @RequestMapping("/form")
    public Person testFormData(Person person, HttpServletRequest request) {
        String contentType = request.getHeader("content-type");
        log.info("Content-Type:" + contentType);
        log.info("入參值:{}", JSON.toJSONString(person));
        return person;
    }
}
請求參數:

curl --request POST \
  --url http://localhost:8080/test/form \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'name=test&age=19'
控制檯輸出結果:

TestWebController     : Content-Type:application/x-www-form-urlencoded
TestWebController     : 入參值:{"age":19,"name":"test"}
可以看出表單提交的參數根據字段名被自動綁定到了Person這個對象。

通過查看源代碼可以發現,我們的http請求進入DispatcherServlet的doDispatch方法,通過方法

HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
獲取了當前請求的RequestMappingHandlerAdapter對象ha,隨後,執行了方法

mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
在該方法中,執行了AbstractHandlerMethodAdapter抽象類的默認方法handle,默認方法又調用了ha的handleInternal方法。隨後通過方法形參傳入的HandlerMethod對象(HandlerMethod對象其實就是我們Controller裏自己寫的testFormData的Method對象),獲取可執行的方法InvocableHandlerMethod。隨後執行了可執行方法對象的getMethodArgumentValues方法。

MethodParameter[] parameters = getMethodParameters();
在方法中,獲取了當前方法的所有的形參。然後循環遍歷這些形參,通過HandlerMethodArgumentResolver接口的一個實現類來處理這個形參。這裏我們發現,當前的resolvers是一個組合對象。這個組合對象也實現了這個接口,並且這個對象有一個私有的成員變量:一個接口的實現類的集合。在處理參數的時候,遍歷當前resolver的集合,通過接口方法supportsParameter來對當前形參的MethodParameter對象進行校驗。當返回了true的時候,證明當前的resolver支持當前的形參,選取當前的resolver對當前的形參進行處理。在第一次匹配到相應的resolver之後,還會進行一個內存級別的緩存。後續對同樣類型的形參進行resolver選擇的時候,就不再對集合進行遍歷選擇。

    /**
     * Find a registered {@link HandlerMethodArgumentResolver} that supports
     * the given method parameter.
     */
    @Nullable
    private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
        HandlerMethodArgumentResolver result = this.argumentResolverCache.get(parameter);
        if (result == null) {
            for (HandlerMethodArgumentResolver methodArgumentResolver : this.argumentResolvers) {
                if (methodArgumentResolver.supportsParameter(parameter)) {
                    result = methodArgumentResolver;
                    this.argumentResolverCache.put(parameter, result);
                    break;
                }
            }
        }
        return result;
    }
選擇到相應的resolver之後,通過方法傳入的request對象,執行resolver的resolveArgument方法,封裝形參的值。

通過觀察組合對象,發現有26個內置的對象,分別負責不同場景下的形參的處理。這裏也解釋了爲什麼在controller的形參位置會自動注入HttpServletRequest、HttpServletResponse等對象。

通過觀察執行過程,發現當Content-Type爲application/x-www-form-urlencoded時,處理形參的resolver是ServletModelAttributeMethodProcessor。

測試代碼2:

@RequestMapping("/entity")
    public Person testFromEntity(@RequestBody Person person, HttpServletRequest request) {
        String contentType = request.getHeader("content-type");
        log.info("Content-Type:" + contentType);
        log.info("入參值:{}", JSON.toJSONString(person));
        return person;
    }
請求參數:

curl --request GET \
  --url http://localhost:8080/test/entity \
  --header 'Content-Type: application/json' \
可以發現,當形參被@RequestBody註解標註時,如果沒有傳入請求體,則會報錯。通過上面同樣的步驟,不難發現,當形參被標註@RequestBody註解的時候,SpringBoot選用的resolver爲RequestResponseBodyMethodProcessor。

當通過請求體傳入合適的json時:

curl --request GET \
  --url http://localhost:8080/test/entity \
  --header 'Content-Type: application/json' \
  --data '{\n    "name": "json",\n    "age": 20\n}'
 可以觀察到

TestWebController     : Content-Type:application/json
TestWebController     : 入參值:{"age":20,"name":"json"}
控制檯輸出了成功綁定的參數。 

並且,通過觀察argumentResolvers集合,發現RequestResponseBodyMethodProcessor的順序要比ServletModelAttributeMethodProcessor高很多,ServletModelAttributeMethodProcessor是最後一個resolver。

所以被標註@RequestBody註解的形參不會有機會通過ServletModelAttributeMethodProcessor去實現數據綁定,即使在url後通過地址拼接參數傳遞對方式請求服務器。在傳入空或無法解析的json時,會直接響應400的錯誤。

三、自定義PostEntityHandlerMethodArgumentResolver

通過以上測試不難發現,處理形參參數綁定的resolver都是HandlerMethodArgumentResolver接口的實現類。於是我想到,通過自定義一個這樣的實現類,來對我們需要處理的形參進行參數綁定處理。

新建自定義的resolver並實現接口後,發現需要實現其中的兩個方法:supportsParameter和resolveArgument。

supportsParameter方法爲resolver組合對象在通過形參選擇resolver的時候進行判斷的方法。如果該方法返回了true,代表此解析器可以處理這個類型的形參。否則就返回false,循環繼續進行下一輪的選擇。所以,我們需要對我們自定義的形參進行標記,以便在這裏可以成功的捕捉到。

我的做法是自定義一個空的接口

public interface PostEntity {
}
讓我們的實體類實現這個接口,但是什麼都不需要做。 在supportsParameter方法中判斷傳入的類型是不是PostEntity的實現類。如果是實現類,就返回true,否則返回false,不影響其他類型的形參的值的注入。

關於resolveArgument方法,我們只需要根據Content-Type不同來直接調用上文提到的兩個resolver即可,不需要自己去實現這個邏輯。同時也可以保證參數處理全局的一致性。由於判斷依賴Content-Type的值,所以要求調用方必須傳入Content-Type。

@Slf4j
@AllArgsConstructor
public class PostEntityHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
 
    private RequestResponseBodyMethodProcessor requestResponseBodyMethodProcessor;
 
    private ServletModelAttributeMethodProcessor servletModelAttributeMethodProcessor;
 
    private static final String APPLICATION_JSON = "application/json";
 
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        Class<?> parameterType = parameter.getParameterType();
        String parameterName = parameter.getParameterName();
        if (PostEntity.class.isAssignableFrom(parameterType)) {
            log.info("name:{},type:{}", parameterName, parameterType.getName());
            log.info("matched");
            return true;
        }
        return false;
    }
 
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class);
        assert request != null;
        String contentType = request.getContentType();
        log.debug("Content-Type:{}", contentType);
        if (APPLICATION_JSON.equalsIgnoreCase(contentType)) {
            return requestResponseBodyMethodProcessor.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
        } else {
            return servletModelAttributeMethodProcessor.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
        }
    }
}
四、註冊自定義HandlerMethodArgumentResolver

構造好了之後就需要把我們自定義的resolver添加到resolver的組合對象中。所有的預加載resolvers在啓動過程中被設置到RequestMappingHandlerAdapter對象中。

定義一個配置類實現WebMvcConfigurer接口,在成員變量位置注入RequestMappingHandlerAdapter對象。確保注入成功之後,定義一個@PostConstruct的init方法,首先通過getArgumentResolvers方法獲取所有的resolvers,隨後遍歷這個集合,獲取我們需要的兩個resolvers。拿到所需參數之後構造我們自定義的PostEntityHandlerMethodArgumentResolver。

通過查看獲取resolver集合的方法源代碼可以發現:

return Collections.unmodifiableList(this.argumentResolvers);
這個方法返回的集合是一個不可變的集合,沒有辦法爲其添加新的元素。所以,我們需要構造一個新的集合,大小爲原有集合大小+1,並且把我們自定義的resolver添加到集合的第一位,再通過ha對象重新設置回去。這樣就完成了我們自定義resolver的註冊。

@Configuration
@Slf4j
public class WebMvcConfiguration implements WebMvcConfigurer {
 
    @Autowired
    private RequestMappingHandlerAdapter ha;
 
    private ServletModelAttributeMethodProcessor servletModelAttributeMethodProcessor = null;
    private RequestResponseBodyMethodProcessor requestResponseBodyMethodProcessor = null;
 
    @PostConstruct
    private void init() {
        List<HandlerMethodArgumentResolver> argumentResolvers = ha.getArgumentResolvers();
        for (HandlerMethodArgumentResolver argumentResolver : argumentResolvers) {
            if (argumentResolver instanceof ServletModelAttributeMethodProcessor) {
                servletModelAttributeMethodProcessor = (ServletModelAttributeMethodProcessor) argumentResolver;
            } else if (argumentResolver instanceof RequestResponseBodyMethodProcessor) {
                requestResponseBodyMethodProcessor = (RequestResponseBodyMethodProcessor) argumentResolver;
            }
            if (servletModelAttributeMethodProcessor != null && requestResponseBodyMethodProcessor != null) {
                break;
            }
        }
        PostEntityHandlerMethodArgumentResolver postEntityHandlerMethodArgumentResolver = new PostEntityHandlerMethodArgumentResolver(requestResponseBodyMethodProcessor, servletModelAttributeMethodProcessor);
        List<HandlerMethodArgumentResolver> newList = new ArrayList<>(argumentResolvers.size() + 1);
        newList.add(postEntityHandlerMethodArgumentResolver);
        newList.addAll(argumentResolvers);
        ha.setArgumentResolvers(newList);
    }
 
}
 

五、結論

通過測試可以發現,兩種請求方式都已經實現了同一個方法形參的參數綁定。雖然此功能也無需這麼複雜的實現方式也可以做到,但是通過對這個問題的研究,閱讀了一些spring的源碼,更清楚的知道了參數綁定數據的流轉過程。

如果需要綁定的形參是外部依賴的vo,無法實現自定義的接口,還可以實現一個自定義的註解,在自定義的resolver中也是可以捕捉到的,並進行自定義的處理。

還有一個可能有用的場景,就是通過此方式,也可以自定義一種Content-Type,來實現一些不知道爲什麼你要這麼做的需求~~

Demo的代碼:https://github.com/daegis/multi-content-type-demo
--------------------- 
作者:AEGISA 
來源:CSDN 
原文:https://blog.csdn.net/daegis/article/details/86478129 
版權聲明:本文爲博主原創文章,轉載請附上博文鏈接!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章