小BUG大原理:重寫WebMvcConfigurationSupport後SpringBoot自動配置失效
- 2020 年 5 月 25 日
- 筆記
- spring, springboot, SpringMVC
一、背景
公司的項目前段時間發版上線後,測試回饋用戶的批量刪除功能報錯。正常情況下看起來應該是個小 BUG,可怪就怪在上個版本正常,且此次發版未涉及用戶功能的改動。因為這個看似小 BUG 我了解到不少未知的東西,在這裡和你們分享下。
先聲明下具體原因為了避免耽誤找解決問題方法的小夥伴們的寶貴時間,因為項目重寫了 WebMvcConfigurationSupport,如果你的項目沒有重寫這個配置類,趕緊到別處找找,祝你很快找到解決 BUG 獲取經驗值升級。
二、問題描述
用戶批量刪除功能:前台傳遞用戶 ID 數組,後台使用@RequestParam 解析參數為 list
錯誤提示:
Required List parameter 'ids[]' is not present
前台程式碼:
$.ajax({
url: "/users",
type: "delete",
data: {ids: ids},
success: function (response) {
if (response.code === 0) {
layer.msg("刪除成功");
}
}
})
後台程式碼:
@DeleteMapping("/users")
@ResponseBody
public Result delete(@RequestParam(value = "ids[]") List<Long> ids) {
boolean status = sysUserService.deleteByIds(ids);
return Result.status(status);
}
知識點:
-
後台為什麼使用@RequestParam 解析?
ajax 如果不指定上傳數據類型 Content-Type,默認的是 application/x-www-form-urlencoded,這種編碼格式後台需要通過 RequestParam 來處理。
-
後台為什麼參數名稱是 ids[]?
三、問題分析和猜想驗證
1. 問題分析
前台確實傳遞了 ids[],後台接收不到 ids[],程式碼邏輯在上個版本是可行的,未對用戶模組更新。思來想去得出的結論,此次的全局性的改動引發出來的問題。去其他頁面功能點擊批量刪除,確實都不可用了。
想到全局性的改動,記得自己當時為了全局配置日期格式轉換還有 Long 傳值到前台精度丟失的問題重寫了 WebMvcConfigurationSupport,程式碼如下:
import com.alibaba.fastjson.serializer.SerializeConfig;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.alibaba.fastjson.serializer.ToStringSerializer;
import com.alibaba.fastjson.support.config.FastJsonConfig;
import com.alibaba.fastjson.support.spring.FastJsonHttpMessageConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
//1、定義一個convert轉換消息的對象
FastJsonHttpMessageConverter fastConverter = new FastJsonHttpMessageConverter();
//2、添加FastJson的配置資訊
FastJsonConfig fastJsonConfig = new FastJsonConfig();
//Long類型轉String類型
SerializeConfig serializeConfig = SerializeConfig.globalInstance;
serializeConfig.put(BigInteger.class, ToStringSerializer.instance);
serializeConfig.put(Long.class, ToStringSerializer.instance);
// serializeConfig.put(Long.TYPE, ToStringSerializer.instance); //不轉long值
fastJsonConfig.setSerializeConfig(serializeConfig);
fastJsonConfig.setSerializerFeatures(
SerializerFeature.WriteMapNullValue, // 保留map空的欄位
SerializerFeature.WriteNullStringAsEmpty, // 將String類型的null轉成""
SerializerFeature.WriteNullNumberAsZero, // 將Number類型的null轉成0
SerializerFeature.WriteNullListAsEmpty, // 將List類型的null轉成[]
SerializerFeature.WriteNullBooleanAsFalse, // 將Boolean類型的null轉成false
SerializerFeature.WriteDateUseDateFormat, //日期格式轉換
SerializerFeature.DisableCircularReferenceDetect // 避免循環引用
);
//3、在convert中添加配置資訊
fastConverter.setFastJsonConfig(fastJsonConfig);
//4、解決響應數據非json和中文響應亂碼
List<MediaType> jsonMediaTypes = new ArrayList<>();
jsonMediaTypes.add(MediaType.APPLICATION_JSON);
fastConverter.setSupportedMediaTypes(jsonMediaTypes);
//5、將convert添加到converters中
converters.add(fastConverter);
//6、追加默認轉換器
super.addDefaultHttpMessageConverters(converters);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations(
"classpath:/static/");
registry.addResourceHandler("swagger-ui.html").addResourceLocations(
"classpath:/META-INF/resources/");
registry.addResourceHandler("/webjars/**").addResourceLocations(
"classpath:/META-INF/resources/webjars/");
super.addResourceHandlers(registry);
}
}
想到這,二話不說把@Configuration 注釋掉,讓 Spring 啟動不載入這個配置類,結果如猜想,可以傳值了。
那麼問題就出現在這個配置類中,毫無頭緒的我只想找個背鍋的。我第一篇有關項目問題的總結就是 FastJSON,嘿嘿,然後就把消息轉換器的程式碼 configureMessageConverters 注釋了,然而並沒有啥用。
其實主要問題在於對 SpringMVC 讀取請求參數的流程不清楚,如果把流程梳理清楚了,應該就知道參數在哪丟了?!
附:SpringMVC 請求處理流程(可略過)
聲明這裡單純的因為自己對 Spring 請求處理的流程不熟悉,可能和下文引出的問題產生原因並無直接關聯,東西有點多不感興趣的童鞋可以直接跳過。後面我會單獨整理篇有關 SpringMVC 請求處理流程,這裡就問題案例來進行的流程分析。
接下來在源碼的角度層面來認識 SpringMVC 處理請求的過程。
SpringMVC 處理請求流程從 DispatcherServlet#doService 方法作為入口,請求處理核心部分委託給 doDispatch 方法。
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
...
try {
try {
ModelAndView mv = null;
Object dispatchException = null;
try {
processedRequest = this.checkMultipart(request);
multipartRequestParsed = processedRequest != request;
// 獲取 HandlerExecutionChain處理器執行鏈,由handler處理器和interceptor攔截器組成
mappedHandler = this.getHandler(processedRequest);
...
// 根據handler獲取對應的handlerAdapter去執行這個handler(Controller的方法)
HandlerAdapter ha = this.getHandlerAdapter(mappedHandler.getHandler());
...
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
return;
}
this.applyDefaultViewName(processedRequest, mv);
mappedHandler.applyPostHandle(processedRequest, response, mv);
}
...
this.processDispatchResult(processedRequest, response, mappedHandler, mv, (Exception)dispatchException);
}
...
}
根據請求資訊的請求路徑和方法類型(get/post/put/delete)從 HandlerMapping 映射集合獲取
HandlerExecutionChain 處理器執行鏈(包含 handler 和 interceptor)。
通過獲得的 handler 類型去適配 handlerAdapter 執行對應的邏輯。那怎麼去找適配器呢?首先你至少知道你的 handler 是什麼類型吧。在此之前,引入一個概念 HandlerMethod,簡單點說就是你控制器 Controller 里用來處理請求的方法的資訊、還有方法參數資訊等。
調試時發現這裡使用的是 AbstractHandlerMethodAdapter,看下內部用來做適配的 supports 方法。handler instanceof HandlerMethod 這個判斷點明了一切。
public abstract class AbstractHandlerMethodAdapter extends WebContentGenerator implements HandlerAdapter, Ordered {
public final boolean supports(Object handler) {
return handler instanceof HandlerMethod && this.supportsInternal((HandlerMethod)handler);
}
protected abstract boolean supportsInternal(HandlerMethod var1);
@Nullable
public final ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
return this.handleInternal(request, response, (HandlerMethod)handler);
}
@Nullable
protected abstract ModelAndView handleInternal(HttpServletRequest var1, HttpServletResponse var2, HandlerMethod var3) throws Exception;
}
找到適配器後,執行其 handle 方法,調用內部方法 handleInternal,交由其子類 RequestMappingHandlerAdapter 實現,我們平時開發最常用的也就是這個適配器了。來看下 RequestMappingHandlerAdapter#handleInternal 方法。
protected ModelAndView handleInternal(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
ModelAndView mav;
...
// 調用RequestMappingHandlerAdapter#invokeHandlerMethod方法
mav = this.invokeHandlerMethod(request, response, handlerMethod);
...
return mav;
}
調用內部方法 RequestMappingHandlerAdapter#invokeHandlerMethod,繼續走。
protected ModelAndView invokeHandlerMethod(HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) throws Exception {
...
Object result;
try {
...
// 生成一個可調用的方法invocableMethod
ServletInvocableHandlerMethod invocableMethod = this.createInvocableHandlerMethod(handlerMethod);
if (this.argumentResolvers != null) {
// 綁定參數解析器
invocableMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);
}
if (this.returnValueHandlers != null) {
invocableMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);
}
invocableMethod.setDataBinderFactory(binderFactory);
invocableMethod.setParameterNameDiscoverer(this.parameterNameDiscoverer);
...
// 核心 通過調用ServletInvocableHandlerMethod的invokeAndHandle方法執行Controller里處理請求的方法
invocableMethod.invokeAndHandle(webRequest, mavContainer, new Object[0]);
...
}
return (ModelAndView)result;
}
將 HandlerMethod 轉化成 ServletInvocableHandlerMethod,可以說這個 ServletInvocableHandlerMethod 是 SpringMVC 最最核心的部分了。至於為什麼這麼說?
-
綁定了 HandlerMethodArgumentResolver 參數解析器 -
綁定了 HandlerMethodReturnValueHandler 返回值處理器 -
核心方法 invokeAndHandle 囊括了從請求到響應幾乎整個 SpringMVC 生命周期
public void invokeAndHandle(ServletWebRequest webRequest, ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {
// 調用請求
Object returnValue = this.invokeForRequest(webRequest, mavContainer, providedArgs);
...
try {
// 處理返回值
this.returnValueHandlers.handleReturnValue(returnValue, this.getReturnValueType(returnValue), mavContainer, webRequest);
}
...
}
本篇的 BUG 也就在於處理請求階段的問題,所以我們來看下 ServletInvocableHandlerMethod#invokeForRequest 方法。
public Object invokeForRequest(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {
Object[] args = this.getMethodArgumentValues(request, mavContainer, providedArgs);
...
return this.doInvoke(args);
}
方法內部通過調用父類 InvocableHandlerMethod#getMethodArgumentValues 方法獲取請求參數。
protected Object[] getMethodArgumentValues(NativeWebRequest request, @Nullable ModelAndViewContainer mavContainer, Object... providedArgs) throws Exception {
MethodParameter[] parameters = this.getMethodParameters();
if (ObjectUtils.isEmpty(parameters)) {
return EMPTY_ARGS;
} else {
Object[] args = new Object[parameters.length];
for(int i = 0; i < parameters.length; ++i) {
MethodParameter parameter = parameters[i];
parameter.initParameterNameDiscovery(this.parameterNameDiscoverer);
args[i] = findProvidedArgument(parameter, providedArgs);
if (args[i] == null) {
...
try {
// 調用HandlerMethodArgumentResolverComposite的resolveArgument解析參數獲取返回值
args[i] = this.resolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
}
...
}
}
return args;
}
}
this.resolvers 是 HandlerMethodArgumentResolverComposite(相當於組合模式的變種),同時實現了 HandlerMethodArgumentResolver 介面,內部又包含所有參數解析器的列表。
public class HandlerMethodArgumentResolverComposite implements HandlerMethodArgumentResolver {
// SpringMVC參數解析器的集合列表
private final List<HandlerMethodArgumentResolver> argumentResolvers = new LinkedList();
...
public boolean supportsParameter(MethodParameter parameter) {
return this.getArgumentResolver(parameter) != null;
}
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
HandlerMethodArgumentResolver resolver = this.getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unsupported parameter type [" + parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
} else {
return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}
}
@Nullable
private HandlerMethodArgumentResolver getArgumentResolver(MethodParameter parameter) {
HandlerMethodArgumentResolver result = (HandlerMethodArgumentResolver)this.argumentResolverCache.get(parameter);
if (result == null) {
Iterator var3 = this.argumentResolvers.iterator();
// 遍歷尋找適配的參數解析器
while(var3.hasNext()) {
HandlerMethodArgumentResolver resolver = (HandlerMethodArgumentResolver)var3.next();
if (resolver.supportsParameter(parameter)) {
result = resolver;
this.argumentResolverCache.put(parameter, resolver);
break;
}
}
}
return result;
}
}
MethodParameter 是處理器方法(HandlerMethod)的一個 HandlerMethodParameter 處理器方法參數資訊,這裡面其中就包含了描述方法參數的註解資訊(eg:@RequestParam)。然後需要根據參數資訊從參數解析器列表查找適配的參數解析器。
終於,在 27 個參數解析器中找到了 RequestParamMapMethodArgumentResolver 解析器,那我們去看下這個解析器做的適配方法 supportsParameter。
public boolean supportsParameter(MethodParameter parameter) {
if (parameter.hasParameterAnnotation(RequestParam.class)) {
if (!Map.class.isAssignableFrom(parameter.nestedIfOptional().getNestedParameterType())) {
return true;
} else {
RequestParam requestParam = (RequestParam)parameter.getParameterAnnotation(RequestParam.class);
return requestParam != null && StringUtils.hasText(requestParam.name());
}
} else if (parameter.hasParameterAnnotation(RequestPart.class)) {
return false;
} else {
parameter = parameter.nestedIfOptional();
if (MultipartResolutionDelegate.isMultipartArgument(parameter)) {
return true;
} else {
return this.useDefaultResolution ? BeanUtils.isSimpleProperty(parameter.getNestedParameterType()) : false;
}
}
}
可以看到 RequestParamMapMethodArgumentResolver 支援被註解@RequestParam、@RequestPart 修飾的方法參數。
在確定了參數解析器後,使用解析器的 resolveArgument 方法解析參數。RequestParamMapMethodArgumentResolver 自身沒有 resolveArgument 方法,而是使用父類 AbstractNamedValueMethodArgumentResolver 的 resolveArgument 的方法。
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer, NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
// 提取註解的屬性鍵值(eg: 註解RequestParam的name、required、defaultValue)
AbstractNamedValueMethodArgumentResolver.NamedValueInfo namedValueInfo = this.getNamedValueInfo(parameter);
MethodParameter nestedParameter = parameter.nestedIfOptional();
// 獲取處理器方法參數名
Object resolvedName = this.resolveStringValue(namedValueInfo.name);
if (resolvedName == null) {
throw new IllegalArgumentException("Specified name must not resolve to null: [" + namedValueInfo.name + "]");
} else {
// 根據參數名從request請求對象獲取值
Object arg = this.resolveName(resolvedName.toString(), nestedParameter, webRequest);
if (arg == null) {
if (namedValueInfo.defaultValue != null) {
arg = this.resolveStringValue(namedValueInfo.defaultValue);
} else if (namedValueInfo.required && !nestedParameter.isOptional()) {
this.handleMissingValue(namedValueInfo.name, nestedParameter, webRequest);
}
arg = this.handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
} else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
arg = this.resolveStringValue(namedValueInfo.defaultValue);
}
if (binderFactory != null) {
WebDataBinder binder = binderFactory.createBinder(webRequest, (Object)null, namedValueInfo.name);
try {
arg = binder.convertIfNecessary(arg, parameter.getParameterType(), parameter);
} catch (ConversionNotSupportedException var11) {
throw new MethodArgumentConversionNotSupportedException(arg, var11.getRequiredType(), namedValueInfo.name, parameter, var11.getCause());
} catch (TypeMismatchException var12) {
throw new MethodArgumentTypeMismatchException(arg, var12.getRequiredType(), namedValueInfo.name, parameter, var12.getCause());
}
}
this.handleResolvedValue(arg, namedValueInfo.name, parameter, mavContainer, webRequest);
return arg;
}
}
這樣一個一個的解析處理器方法參數,直到把方法所有的參數都從 request 拿到對應的值之後,返回 args。對應邏輯在 ServletInvocableHandlerMethod#invokeForRequest,最後返回參數數組 args。這樣整個參數解析完成之後執行後面的邏輯 this.doInvoke(args)。
至此,SpringMVC 請求處理流程就結束了。
總結下整個 SpringMVC 請求處理的流程:
-
請求由 DispatcherServlet 攔截處理。 -
根據 request 資訊從 HandlerMapping 映射關係獲取對應的 HandlerExecutionChain 執行鏈(包含了處理器 handler 和 interceptor)。 -
從 HandlerExecutionChain 獲取處理器 handler,根據 handler 類型去匹配對應的適配器。我們平時最常寫的 Controller 用來處理請求的方法對應的 handler 類型是 HandlerMethod,匹配的適配器是 RequestMappingHandlerAdapter。 -
請求處理委託給 RequestMappingHandlerAdapter 處理,適配器將拿到的 handler 轉換成 ServletInvocableHandlerMethod,其內部綁定了參數解析器 HandlerMethodArgumentResolver 和返回值處理器 HandlerMethodReturnValueHandler。 -
執行 ServletInvocableHandlerMethod 的 invokeAndHandle 方法。整個方法包含了請求調用和響應處理,請求中包含了參數的解析過程。
2.猜想驗證
其實上面扯了這麼多,還沒說到關鍵點為什麼重寫了 WebMvcConfigurationSupport 會導致後台接收不了 FormData?按照之前的分析我們需要的 FormData 數據可能在哪個階段丟了。前台傳過來的數據肯定會存在 request 對象中,既然這樣,笨辦法是不是可以想比較下沒有重寫和重寫的情景,看看兩次的 request 對象是否有差異不就行了。
我們把斷點打在 InvocableHandlerMethod#getMethodArgumentValues 方法中,因為這裡是從 request 對象中提取出參數的方法。我們要做的只需觀察兩次的 request 對象的差異即可。
果不其然,重寫過 WebMvcConfigurationSupport 後,少了 formParams 這個屬性,而 formParams 包含了我們想要的參數 ids[]。
至於為什麼重寫 WebMvcConfigurationSupport 會丟失 formParams?是不是毫無頭緒?別急,我們先看下這個 formParams 是什麼。
從上圖可以看得到 formParams 是 FormContentFilter 中靜態內部類 FomContentRequestWrapper 的一個屬性。猜想 formParams 應該是使用 FormContentFilter 過濾器從 request 對象提取出來的,那現在少了 formParams 應該是過濾器 FormContentFilter 沒有載入。
重寫配置類之前沒有配置過 FormContentFilter 過濾器,所以這個過濾器應該是 SpringBoot 自動配置並載入的。來看下 SpringBoot 的 WebMvc 自動配置類 WebMvcAutoConfiguration。這個類配置在 spring.factories 里,SpringBoot 啟動時自動載入配置在裡面的類,是 SpringBoot 的擴展機制,類似 java 的 SPI。
FormContentFilter 如我們所料在 SpringBoot 的 WebMvc 自動配置類中,隨著 SpringBoot 啟動自動裝配。那至於為什麼重寫了 WebMvcConfigurationSupport 就會導致自動配置失效了呢?再看下 WebMvcAutoConfiguration 的頭部註解描述。
@ConditionalOnMissingBean({WebMvcConfigurationSupport.class}),意思就是 Spring 容器中沒有 WebMvcConfigurationSupport 類相關 bean 自動配置才會生效。而我這裡重寫 WebMvcConfigurationSupport 並載入到 Spring 容器中,顯然導致 SpringBoot 自動配置不能生效,最終表現出來的現象是後台接收不到前台 FromData 傳值。
四、解決方案
-
既然自動配置失效,手動配置吧
@Bean
public FormContentFilter formContentFilter(){
FormContentFilter formContentFilter=new OrderedFormContentFilter();
return formContentFilter;
}