記錄一次debug-自定義SpringMVC攔截器中HandlerMethod類型轉換問題調研(轉)


注:來自51CTO博客作者winters1224的原創作品,https://blog.51cto.com/winters1224/2049425

摘要

在將a模塊遷移到spring boot項目下、使用embeded tomcat啟動項目后,在調用RESTfule接口時,模塊中聲明的一個SpringMVC攔截器"cn.xxx.thread.common.web.speedctrlforuser.SpeedctrlForUserInterceptor"中拋出了ClassCastException。但是使用外置Tomcat啟動就沒有這個問題。在逐行debug后發現是spring boot缺失一項配置導致了這個問題。

問題

在 TECHSTUDY-91 - THREAD模塊接入服務注冊/訂閱服務 進行中 任務中,我為a模塊定義了一個啟動類(注解了@SpringBootApplication),並配置了對應的application.properties。由於目前只需要注冊到eureka上,配置文件中只有如下兩行配置:

applictaion.properties
spring.application.name=a
eureka.client.serviceUrl.defaultZone=http://10.255.33.207:8080/eureka,http://10.255.33.208:8080/eureka,http://10.255.33.209:8080/eureka

在其它配置(如maven依賴關系、xml配置文件引入等)都整理好之后,用eclipse將a模塊發布到tomcat上(即打成war包后發布),調用auth模塊接口(如http://localhost:8080/a/rest/users/admin),一切正常。
但是,在使用啟動類將模塊發布到內置tomcat上(相當於打成jar包后發布),再調用上述auth模塊的接口,會出現以下異常:

17:52:31,864 ERROR [org.apache.juli.logging.DirectJDKLog.log] (http-nio-8080-exec-2) Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.ClassCastException: org.springframework.web.servlet.resource.ResourceHttpRequestHandler cannot be cast to org.springframework.web.method.HandlerMethod] with root cause^|TraceId.-http-nio-8080-exec-2
java.lang.ClassCastException: org.springframework.web.servlet.resource.ResourceHttpRequestHandler cannot be cast to org.springframework.web.method.HandlerMethod
at cn.xxx.thread.common.web.speedctrlforuser.SpeedctrlForUserInterceptor.preHandle(SpeedctrlForUserInterceptor.java:66) ~[classes/:?]
at org.springframework.web.servlet.HandlerExecutionChain.applyPreHandle(HandlerExecutionChain.java:133) ~[spring-webmvc-4.3.10.RELEASE.jar:4.3.10.RELEASE]
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:962) ~[spring-webmvc-4.3.10.RELEASE.jar:4.3.10.RELEASE]
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:901) ~[spring-webmvc-4.3.10.RELEASE.jar:4.3.10.RELEASE]
at org.springframework.web.servlet.FrameworkServlet.proce***equest(FrameworkServlet.java:970)

分析

從上文的異常信息可知,問題出現在SpeedctrlForUserInterceptor的第66行。這里的代碼是這樣的:

public boolean preHandle(HttpServletRequest request,
        HttpServletResponse response, Object handler)
        throws TooManyRequestsException {
    User user = SecurityUtils.getUserFromPrincipal(SecurityContextHolder
        .getContext().getAuthentication());
    if (user == null) {
        return true;
    }
    HandlerMethod method = (HandlerMethod) handler; // 這里是第66行
    // 省略后續代碼
}

在第66行,代碼中做了一個強制類型轉換。根據異常信息,在這里得到的handler是一個ResourceHttpRequestHandler,而不是HandlerMethod。所以會報錯。
這里的ResourceHttpRequestHandler和HandlerMethod分別是什么呢?我們可以簡單的看一下二者的Javadoc。

org.springframework.web.servlet.resource.ResourceHttpRequestHandler
HttpRequestHandler that serves static resources in an optimized way according to the guidelines of Page Speed, YSlow, etc.
The "locations" property takes a list of Spring Resource locations from which static resources are allowed to be served by this handler. Resources could be served from a classpath location, e.g. "classpath:/META-INF/public-web-resources/", allowing convenient packaging and serving of resources such as .js, .css, and others in jar files.
This request handler may also be configured with a resourcesResolver and resourceTransformer chains to support arbitrary resolution and transformation of resources being served. By default a PathResourceResolver simply finds resources based on the configured "locations". An application can configure additional resolvers and transformers such as the VersionResourceResolver which can resolve and prepare URLs for resources with a version in the URL.
This handler also properly evaluates the Last-Modified header (if present) so that a 304 status code will be returned as appropriate, avoiding unnecessary overhead for resources that are already cached by the client.

HandlerMethod
org.springframework.web.method.HandlerMethod
Encapsulates information about a handler method consisting of a method and a bean. Provides convenient access to method parameters, the method return value, method annotations, etc.
The class may be created with a bean instance or with a bean name (e.g. lazy-init bean, prototype bean). Use createWithResolvedBean() to obtain a HandlerMethod instance with a bean instance resolved through the associated BeanFactory.

簡單的說,ResourceHttpRequestHandler是用來處理靜態資源的;而HandlerMethod則是springMVC中用@Controller聲明的一個bean及對應的處理方法。以http://localhost:8080/a/rest/users/admin這個接口為例,它對應的HandlerMethod應該指向這個類的這個方法:

@Controller@RequestMapping("/rest/users")
public class UserRESTController extends AbstractController
{
    @PreAuthorize("hasRole('USER_DETAIL')")
    @RequestMapping(method = RequestMethod.GET, value = "/{id}")
    @ResponseBody
    public User getUserByID(@PathVariable String id) throws InvalidDataException {
        // 省略具體代碼
    }
    // 省略其它方法
}

所以這個問題的核心是:為什么springMVC把一個非靜態資源識別成了靜態資源,並了調用靜態資源處理器?

方案

這里嘗試了好幾種方案。實際上只有最后的方案是可行的。不過前面幾種方案也記錄了一下。

方案一:修改springMVC攔截器配置

那個接口怎么着也不是一個靜態資源啊。所以我一開始認為是攔截器的配置有問題。於是我看了一下它的配置,發現確實與別的攔截器不一樣:

<mvc:interceptors>
<!-- 一種配置是這樣的:攔截所有請求,但過濾掉靜態資源 -->
    <mvc:interceptor>
        <mvc:mapping path="/**"/>
        <mvc:exclude-mapping path="/js/**" />
        <mvc:exclude-mapping path="/html/**" />
        <mvc:exclude-mapping path="/resources/**" />
        <bean class="cn.xxx.thread.common.interceptor.LoginUserInterceptor" />
    </mvc:interceptor>
    <!-- 一種配置是這樣的:只攔截REST請求。 -->
    <mvc:interceptor>
        <mvc:mapping path="/rest/**" />
        <bean class="cn.xxx.thread.common.web.speedcontrol.SpeedControlInterceptor" />
    </mvc:interceptor>
    <!-- 出問題的攔截器是這樣的:攔截所有請求,並且不過濾靜態資源 -->
    <mvc:interceptor>
        <mvc:mapping path="/**" />
        <bean class="cn.xxx.thread.common.web.speedctrlforuser.SpeedctrlForUserInterceptor" />
    </mvc:interceptor>
    <!-- 省略其它攔截器配置,與第一、第二種大同小異 -->
</mvc:interceptors>

於是我先后做了兩次調整:把SpeedctrlForUserInterceptor攔截器的<mvc:mapping />配置改成<mvc:mapping path="/rest/**" />;把SpeedctrlForUserInterceptor攔截器的順序調整為第一位。
都沒起作用。當然都不起作用。這段配置一直在線上正常運行;用war包發布到tomcat上也不報錯。說明問題並不在這里。修改這段配置當然不會起作用。

方案二:檢查內置tomcat配置

既然問題只在使用embeded tomcat發布時出現,那么多半是它的配置上的問題了。
於是我又查了一下,發現tomcat有一個defaultServlet,用於處理一些靜態資源。並且我在外置tomcat的web.xml中也確實發現了這個配置:

<!-- The default servlet for all web applications, that serves static -->
<!-- resources.  It processes all requests that are not mapped to other   -->
<!-- servlets with servlet mappings (defined either here or in your own   -->
<!-- web.xml file).  This servlet supports the following initialization   -->
<!-- parameters (default values are in square brackets):                  -->
<!-- 省略后面的注釋 -->  
    <servlet>
        <servlet-name>default</servlet-name>
        <servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
        <init-param>
            <param-name>debug</param-name>
            <param-value>0</param-value>
        </init-param>
        <init-param>
            <param-name>listings</param-name>
            <param-value>false</param-value>
        </init-param>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <!-- The mapping for the default servlet -->
    <servlet-mapping>
        <servlet-name>default</servlet-name>
        <url-pattern>/</url-pattern>
    </servlet-mapping>

難道是內置tomcat沒有顯式開啟這個servlet導致的?我嘗試着在spring-servlet-common.xml中增加了一個配置:

<!-- 這是增加的配置 -->
<mvc:default-servlet-handler/>
<!-- 官方提供的注釋如下:Element : default-servlet-handlerConfigures a handler for serving static resources by
forwarding to the Servlet container's default Servlet. Use of this handler allows using a "/" mapping with the
DispatcherServlet while still utilizing the Servlet container to serve static resources. This handler will forward all
requests to the default Servlet. Therefore it is important that it remains last in the order of all other URL
HandlerMappings. That will be the case if you use the "annotation-driven" element or alternatively if you are setting up
your customized HandlerMapping instance be sure to set its "order" property to a value lower than that of the
DefaultServletHttpRequestHandler, which is Integer.MAX_VALUE. -->

加上配置之后,還是不起作用。當然不起作用。從注釋上看,它的作用是增加一個handler,在識別出靜態資源之后將請求轉發給容器提供的default servlet。然而我遇到的問題是,springMVC在識別靜態資源上出現了誤判。加這個配置當然不會起作用。
順帶一提,我后來debug時發現,內置tomcat同樣會注冊default servlet。在這一點上,內置、外置沒有區別。

二次分析:先搞清楚問題究竟在哪兒

上面兩個方案,其實都是建立在“推測問題原因”上的。換句話說就是“我猜我猜我猜猜”。初步分析時可以使用這種方法;但由於它對問題原因的分析很不到位,所以再怎么調整、修改也改不到點子上。
所以在拿出方案三之前,我打算祭出最后的法寶,先把病因搞清楚再開方子拿葯。
這個法寶就是:開debug模式,逐行執行代碼。而且在這個問題中,由於外置tomcat能夠正常執行,因此,還可以用正常情況下的運行時數據來與出錯情況做對比。

第一個斷點

第一個斷點打在哪兒?分析異常信息可以發現,異常拋出位置是DispatcherServlet.doDispatch(DispatcherServlet.java:962)。這個方法的代碼如下:

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = request;
    HandlerExecutionChain mappedHandler = null;
    boolean multipartRequestParsed = false;

    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
    try {
        ModelAndView mv = null;
        Exception dispatchException = null;

        try {
            processedRequest = checkMultipart(request);
            multipartRequestParsed = (processedRequest != request);

            // Determine handler for the current request.
            mappedHandler = getHandler(processedRequest);
            if (mappedHandler == null || mappedHandler.getHandler() == null) {
                noHandlerFound(processedRequest, response);
                return;
            }

            // Determine handler adapter for the current request.
            HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler()); // 這里是第940行

            // Process last-modified header, if supported by the handler.
            String method = request.getMethod();
            boolean isGet = "GET".equals(method);
            if (isGet || "HEAD".equals(method)) {
                long lastModified = ha.getLastModified(request, mappedHandler.getHandler());
                if (logger.isDebugEnabled()) {
                    logger.debug("Last-Modified value for [" + getRequestUri(request) + "] is: " + lastModified);
                }
                if (new ServletWebRequest(request, response).checkNotModified(lastModified) && isGet) {
                    return;
                }
            }
            if (!mappedHandler.applyPreHandle(processedRequest, response)) { // 這里是第962行
                return;
            }
            // Actually invoke the handler.
            mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
            if (asyncManager.isConcurrentHandlingStarted()) {
                return;
            }
            applyDefaultViewName(processedRequest, mv);
            mappedHandler.applyPostHandle(processedRequest, response, mv);
        }
        catch (Exception ex) {
            dispatchException = ex;
        }
        catch (Throwable err) {
            // As of 4.3, we're processing Errors thrown from handler methods as well,
            // making them available for @ExceptionHandler methods and other scenarios.
            dispatchException = new NestedServletException("Handler dispatch failed", err);
        }
        processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    }
    catch (Exception ex) {
        triggerAfterCompletion(processedRequest, response, mappedHandler, ex);
    }
    catch (Throwable err) {
        triggerAfterCompletion(processedRequest, response, mappedHandler,
                new NestedServletException("Handler processing failed", err));
    }
    finally {
        if (asyncManager.isConcurrentHandlingStarted()) {
            // Instead of postHandle and afterCompletion
            if (mappedHandler != null) {
                mappedHandler.applyAfterConcurrentHandlingStarted(processedRequest, response);
            }
        }
        else {
            // Clean up any resources used by a multipart request.
            if (multipartRequestParsed) {
                cleanupMultipart(processedRequest);
            }
        }
    }
}

第962行執行了mappedHandler.applyPreHandle(processedRequest, response),而其中的mappedHandler來自第940的mappedHandler = getHandler(processedRequest);。這個getHandler方法的代碼如下:

protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
    for (HandlerMapping hm : this.handlerMappings) {
        if (logger.isTraceEnabled()) {
            logger.trace(
                    "Testing handler map [" + hm + "] in DispatcherServlet with name '" + getServletName() + "'");
        }
        HandlerExecutionChain handler = hm.getHandler(request); // 這里是第1160行
        if (handler != null) {
            return handler
        }
    }
    return null;
}

可以很清楚地看出:這段代碼就是SpringMVC決定使用哪個Handler來處理當前Request的地方。因此,我把第一個斷點打在了第1160行(getHandler方法中HandlerExecutionChain handler = hm.getHandler(request);這一句上)。一來檢查內置/外置tomcat下,SpringMVC生成的handlerMappings是否有不同;二來檢查兩種情況下,SpringMVC分別由哪個HandlerMapping來處理request並生成HandlerExecutionChain。
執行結果的圖我就不貼了。結論是這樣的:兩種tomcat下,handlerMappings中都有9個HandlerMapping的示例,並且兩種情況下列表中的類、順序都是一樣的。但是,外置tomcat下,是下標為1的實例(RequestMappingHandlerMapping)處理了請求、並返回了一個HandlerMethod實例;而內置tomcat中,是下標為5的實例(SimpleUrlHandlerMapping)來處理請求,並返回了一個ResourceHttpRequestHandler實例!而正是這個ResourceHttpRequestHandler,在代碼中強轉HandlerMthod時拋出了異常。
因此,我們可以將問題聚焦為:內置tomcat情況下,為什么下標為1的實例(RequestMappingHandlerMapping)沒能正確處理這個請求?

第二個斷點

但是,雖然我們可以確定問題出現在RequestMappingHandlerMapping這個類中,但通過分析代碼可以發現,getHandler方法的流程並沒有進入這個類中,而是由它的父類(AbstractHandlerMethodMapping/AbstractHandlerMapping)定義的方法處理了。

sequenceDiagram
    DispatcherServlet->>AbstractHandlerMapping: getHandler(request)
    AbstractHandlerMapping->> AbstractHandlerMethodMapping: getHandlerInternal(request)
    AbstractHandlerMethodMapping->>AbstractHandlerMethodMapping: lookupHandlerMehtod(lookupPath,request)
    AbstractHandlerMethodMapping->>AbstractHandlerMapping: return HandlerMethod
    AbstractHandlerMapping->>DispatcherServlet: return HandleExecutionChain

最關鍵的方法是AbstractHandlerMethodMapping.lookupHandlerMethod( String lookupPath, HttpServletRequest request),其代碼如下:

/**
 * Look up the best-matching handler method for the current request.
 * If multiple matches are found, the best match is selected.
 * @param lookupPath mapping lookup path within the current servlet mapping
 * @param request the current request
 * @return the best-matching handler method, or {@code null} if no match
 * @see #handleMatch(Object, String, HttpServletRequest)
 * @see #handleNoMatch(Set, String, HttpServletRequest)
 */
protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
    List<Match> matches = new ArrayList<Match>();
    List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
    if (directPathMatches != null) {
        addMatchingMappings(directPathMatches, matches, request);
    }
    if (matches.isEmpty()) {
        // No choice but to go through all mappings...
        addMatchingMappings(this.mappingRegistry.getMappings().keySet(), matches, request);
    }
    if (!matches.isEmpty()) {
        Comparator<Match> comparator = new MatchComparator(getMappingComparator(request));
        Collections.sort(matches, comparator);
        if (logger.isTraceEnabled()) {
            logger.trace("Found " + matches.size() + " matching mapping(s) for [" +
                    lookupPath + "] : " + matches);
        }
        Match bestMatch = matches.get(0);
        if (matches.size() > 1) {
            if (CorsUtils.isPreFlightRequest(request)) {
                return PREFLIGHT_AMBIGUOUS_MATCH;
            }
            Match secondBestMatch = matches.get(1);
            if (comparator.compare(bestMatch, secondBestMatch) == 0) {
                Method m1 = bestMatch.handlerMethod.getMethod();
                Method m2 = secondBestMatch.handlerMethod.getMethod();
                throw new IllegalStateException("Ambiguous handler methods mapped for HTTP path '" +
                        request.getRequestURL() + "': {" + m1 + ", " + m2 + "}");
            }
        }
        handleMatch(bestMatch.mapping, lookupPath, request);
        return bestMatch.handlerMethod;
    }
    else {
        return handleNoMatch(this.mappingRegistry.getMappings().keySet(), lookupPath, request);
    }
}

SpringMVC用這個方法來將請求路徑(入參lookupPath)匹配到已注冊的handler上。於是我在這個方法的入口處加了個斷點,在內置/外置tomcat下逐步執行后,發現了玄機:
外置tomcat下,directPathMatches不為空;而內置tomcat下,directPathMatches是一個EmptyList,這又進一步導致了matches是一個EmptyList,並使得最終的返回值是null。

可以不用打第三個斷點了。細致一點就能發現:內置tomcat下,lookupPath的值是"/a/rest/users",而外置tomcat下則是"/rest/users"。而無論使用內置/外置tomcat,MappingRegistry中保存的urlPath,都是"/rest/xxxx"格式的。用toString()方法打印出來的話,基本是這樣的:"/rest/dirtyUpload/clean=[{[/rest/dirtyUpload/clean],methods=[GET]}], /{path}=[{[/{path}],methods=[GET]}], /=[{[/],methods=[GET]}], /rest/server/time=[{[/rest/server/time]}], ……"(這些mapping是c模塊下的;a模塊下類似,只是具體路徑不同)。

context-path

為什么使用外置tomcat啟動時,工程名a不會被識別為URI呢?因為當我們使用eclipse將a發布到tomcat中時,eclipse會自動向tomcat的server.xml中寫入一行配置:

<Context docBase="a" path="/a" reloadable="true" source="org.eclipse.jst.jee.server:a"/></Host>

其中的path屬性,就指定了這個項目的context-path是/a。因而,在將URL(protocol://host:port/context-path/URI?queryString)解析為URI時,SpringMVC能夠得到正確的結果。
即使不手動處理server.xml(tomcat官方也並不推薦手動處理server.xml),用war包/文件夾方式發布web項目時,tomcat也會自動將路徑名解析為context-path。
但是使用內置tomcat啟動時,由於項目的application.properties中沒有相關配置,因而context-path默認被指定為“/”。進而,在解析URL時,"protocal://host:port/"后、"?queryString"前的全部字符串都被當做了URI。
前文提出的兩個問題(為什么springMVC把一個非靜態資源識別成了靜態資源,並了調用靜態資源處理器?內置tomcat情況下,為什么下標為1的實例(RequestMappingHandlerMapping)沒能正確處理這個請求?)都是這個原因導致的。

方案三:指定context-path

知道了真正的原因之后,方案就非常簡單了:在application.properties中指定context-path即可:

server.contextPath=/a
spring.application.name=a
eureka.client.serviceUrl.defaultZone=http://10.255.33.207:8080/eureka,http://10.255.33.208:8080/eureka,http://10.255.33.209:8080/eureka

迎刃而解。

小結

在trouble shooting時,首先,你得找到一個對象真正的問題原因。“我猜我猜我猜猜猜”這種方法,可以在動手之初用來縮小排查范圍;但是要解決問題、積累知識,還是要知其所以然。
使用debug逐行跟進這種方式,一開始我是拒絕的。因為線上環境的問題、包括測試環境的問題,基本上都是無法debug的。所以我一直推薦用日志來做trouble shooting不過框架內的bug,這類問題比較bug,不用debug模式基本上是沒法debug的。
類似spring boot這種自動化配置(還有一些約定大於配置的“半自動化配置”),確實能夠節約很多開發時間、精力。但是,如果對其中一些“默認配置”、“自動配置”、“約定值”沒有了解,很容易出問題,而且出了問題還不知道什么原因。所以,還是要知其所以然。


免責聲明!

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



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