Bug實錄 | 第二篇:重寫WebMvcConfigurationSupport后SpringBoot自動配置失效


一、背景

公司的項目前段時間發版上線后,測試反饋用戶的批量刪除功能報錯。正常情況下看起來應該是個小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);
}

知識點:

  1. 后台為什么使用@RequestParam解析?

    ajax如果不指定上傳數據類型Content-Type,默認的是application/x-www-form-urlencoded,這種編碼格式后台需要通過RequestParam來處理。

  2. 后台為什么參數名稱是ids[]?

image

三、問題分析和猜想驗證

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最最核心的部分了。至於為什么這么說?

  1. 綁定了HandlerMethodArgumentResolver參數解析器
  2. 綁定了HandlerMethodReturnValueHandler返回值處理器
  3. 核心方法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請求處理的流程:

  1. 請求由DispatcherServlet攔截處理。
  2. 根據request信息從HandlerMapping映射關系獲取對應的HandlerExecutionChain執行鏈(包含了處理器handler和interceptor)。
  3. 從HandlerExecutionChain獲取處理器handler,根據handler類型去匹配對應的適配器。我們平時最常寫的Controller用來處理請求的方法對應的handler類型是HandlerMethod,匹配的適配器是RequestMappingHandlerAdapter。
  4. 請求處理委托給RequestMappingHandlerAdapter處理,適配器將拿到的handler轉換成ServletInvocableHandlerMethod,其內部綁定了參數解析器HandlerMethodArgumentResolver和返回值處理器HandlerMethodReturnValueHandler。
  5. 執行ServletInvocableHandlerMethod的invokeAndHandle方法。整個方法包含了請求調用和響應處理,請求中包含了參數的解析過程。

2. 猜想驗證

其實上面扯了這么多,還沒說到關鍵點為什么重寫了WebMvcConfigurationSupport會導致后台接收不了FormData?按照之前的分析我們需要的FormData數據可能在哪個階段丟了。前台傳過來的數據肯定會存在request對象中,既然這樣,笨辦法是不是可以想比較下沒有重寫和重寫的情景,看看兩次的request對象是否有差異不就行了。

我們把斷點打在InvocableHandlerMethod#getMethodArgumentValues方法中,因為這里是從request對象中提取出參數的方法。我們要做的只需觀察兩次的request對象的差異即可。

image

果不其然,重寫過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傳值。

四、解決方案

  1. 既然自動配置失效,手動配置吧
    @Bean
    public FormContentFilter formContentFilter(){
        FormContentFilter formContentFilter=new OrderedFormContentFilter();
        return  formContentFilter;
    }

這種方案問題在於還是重寫了WebMvcConfigurationSupport,除了本篇說到的問題還有其他自動配置同樣會失效,感覺終究還是不是太好的方案。

  1. 繼承實現WebMvcConfigurer接口,會在支持原有默認配置的情況下新增配置(推薦)。
  2. 其他方案,要是你知道的話歡迎下方留言。

五、總結

其實就是項目中批量刪除失敗看似一個很小的BUG,引出SpringMVC有關請求參數處理原理和SpringBoot自動裝配原理。因為時間和篇幅的原因,SpringMVC和SpringBoot原理后面會開專欄細說。最后說一下老生常談的問題,最好的學習方式是結合工作項目中學,個人完全贊同這種說法。而我們大多數人終究逃不了那循環CRUD的命,但千萬別浪費時間去抱怨,有時候所處的環境無法改變時,要相信自己可以創造環境。哪怕重復的CRUD、哪怕再小的BUG,我們也可以做到獨具慧眼看到深藏在它們背后的東西,而我們要的就是這些藏在這背后的東西。廢話了很多,所有都得看自己,有幫助的話希望點個關注❤


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM