摘要

在将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拦截器配置

那个接口怎么着也不是一个静态资源啊。所以我一开始认为是拦截器的配置有问题。于是我看了一下它的配置,发现确实与别的拦截器不一样:



    
        
        
        
        
        
    
    
    
        
        
    
    
    
        
        
    
    

于是我先后做了两次调整:把SpeedctrlForUserInterceptor拦截器的配置改成;把SpeedctrlForUserInterceptor拦截器的顺序调整为第一位。
都没起作用。当然都不起作用。这段配置一直在线上正常运行;用war包发布到tomcat上也不报错。说明问题并不在这里。修改这段配置当然不会起作用。

方案二:检查内置tomcat配置

既然问题只在使用embeded tomcat发布时出现,那么多半是它的配置上的问题了。
于是我又查了一下,发现tomcat有一个defaultServlet,用于处理一些静态资源。并且我在外置tomcat的web.xml中也确实发现了这个配置:






  
    
        default
        org.apache.catalina.servlets.DefaultServlet
        
            debug
            0
        
        
            listings
            false
        
        1
    
    
    
        default
        /
    

难道是内置tomcat没有显式开启这个servlet导致的?我尝试着在spring-servlet-common.xml中增加了一个配置:



加上配置之后,还是不起作用。当然不起作用。从注释上看,它的作用是增加一个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 matches = new ArrayList();
    List 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 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中写入一行配置:

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