自定義SpringMVC攔截器中HandlerMethod類型轉換問題調研

摘要

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

問題

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

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/eurekajava

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

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行。這裏的代碼是這樣的:web

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。spring

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. apache

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.tomcat

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

@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把一個非靜態資源識別成了靜態資源,並了調用靜態資源處理器?app

方案

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

方案一:修改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>
    <!-- 省略其它攔截器配置,與第1、第二種大同小異 -->
</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這種自動化配置(還有一些約定大於配置的「半自動化配置」),確實可以節約不少開發時間、精力。可是,若是對其中一些「默認配置」、「自動配置」、「約定值」沒有了解,很容易出問題,並且出了問題還不知道什麼緣由。因此,仍是要知其因此然。

相關文章
相關標籤/搜索