一、背景
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
版權聲明:本文爲博主原創文章,轉載請附上博文鏈接!