摘要

在将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这种自动化配置(还有一些约定大于配置的“半自动化配置”),确实能够节约很多开发时间、精力。但是,如果对其中一些“默认配置”、“自动配置”、“约定值”没有了解,很容易出问题,而且出了问题还不知道什么原因。所以,还是要知其所以然。