Spring Boot优雅地处理404异常

背景#

在使用 SpringBoot 的过程中,你肯定遇到过 404 错误。比如下面的代码:

@RestController
@RequestMapping(value = "/hello")
public class HelloWorldController {
    @RequestMapping("/test")
    public Object getObject1(HttpServletRequest request){
        Response response = new Response();
        response.success("请求成功...");
        response.setResponseTime();
        return response;
    }
}

当我们使用错误的请求地址(POST http://127.0.0.1:8888/hello/test1?id=98)进行请求时,会报下面的错误:

{
  "timestamp": "2020-11-19T08:30:48.844+0000",
  "status": 404,
  "error": "Not Found",
  "message": "No message available",
  "path": "/hello/test1"
}

虽然上面的返回很清楚,但是我们的接口需要返回统一的格式,比如:

{
    "rtnCode":"9999",
    "rtnMsg":"404 /hello/test1 Not Found"
}

这时候你可能会想有 Spring 的统一异常处理,在 Controller 类上加 @RestControllerAdvice 注解。但是这种做法并不能统一处理 404 错误。

404 错误产生的原因#

产生 404 的原因是我们调了一个不存在的接口,但是为什么会返回下面的 json 报错呢?我们先从 Spring 的源代码分析下。

{
  "timestamp": "2020-11-19T08:30:48.844+0000",
  "status": 404,
  "error": "Not Found",
  "message": "No message available",
  "path": "/hello/test1"
}

为了代码简单起见,这边直接从 DispatcherServlet 的 doDispatch 方法开始分析。(如果不知道为什么要从这边开始,你还要熟悉下 SpringMVC 的源代码)。

... 省略部分代码....
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
... 省略部分代码

Spring MVC 会根据请求 URL 的不同,配置的 RequestMapping 的不同,为请求匹配不同的 HandlerAdapter。

对于上面的请求地址:http://127.0.0.1:8888/hello/test1?id=98匹配到的 HandlerAdapter 是 HttpRequestHandlerAdapter。

我们直接进入到 HttpRequestHandlerAdapter 中看下这个类的 handle 方法。

@Override
@Nullable
public ModelAndView handle(HttpServletRequest request, HttpServletResponse response, Object handler)
    throws Exception {
    ((HttpRequestHandler) handler).handleRequest(request, response);
    return null;
}

这个方法没什么内容,直接是调用了 HttpRequestHandler 类的 handleRequest(request, response) 方法。所以直接进入这个方法看下吧。

@Override
public void handleRequest(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
<span class="hljs-comment">// For very general mappings (e.g. "/") we need to check 404 first</span>
<span class="hljs-type">Resource</span> <span class="hljs-variable">resource</span> <span class="hljs-operator">=</span> getResource(request);
<span class="hljs-keyword">if</span> (resource == <span class="hljs-literal">null</span>) {
    logger.trace(<span class="hljs-string">"No matching resource found - returning 404"</span>);
    <span class="hljs-comment">// 这个方法很简单,就是设置404响应码,然后将Response的errorState状态从0设置成1</span>
    response.sendError(HttpServletResponse.SC_NOT_FOUND);
    <span class="hljs-comment">// 直接返回</span>
    <span class="hljs-keyword">return</span>;
}
... 省略部分方法

}

这个方法很简单,就是设置 404 响应码,将 Response 的 errorState 状态从 0 设置成 1,然后就返回响应了。整个过程并没有发生任何异常,所以不能触发 Spring 的全局异常处理机制

到这边还有一个问题没有解决:就是下面的 404 提示信息是怎么返回的。

{
  "timestamp": "2020-11-19T08:30:48.844+0000",
  "status": 404,
  "error": "Not Found",
  "message": "No message available",
  "path": "/hello/test1"
}

我们继续往下看。Response 响应被返回,进入 org.apache.catalina.core.StandardHostValve 类的 invoke 方法进行处理。(不要问我为什么知道是在这里?Debug 的能力是需要自己摸索出来的,自己调试多了,你也就会了)

@Override
public final void invoke(Request request, Response response)
    throws IOException, ServletException {
<span class="hljs-type">Context</span> <span class="hljs-variable">context</span> <span class="hljs-operator">=</span> request.getContext();
<span class="hljs-keyword">if</span> (context == <span class="hljs-literal">null</span>) {
    response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
                       sm.getString(<span class="hljs-string">"standardHost.noContext"</span>));
    <span class="hljs-keyword">return</span>;
}

<span class="hljs-keyword">if</span> (request.isAsyncSupported()) {
    request.setAsyncSupported(context.getPipeline().isAsyncSupported());
}

<span class="hljs-type">boolean</span> <span class="hljs-variable">asyncAtStart</span> <span class="hljs-operator">=</span> request.isAsync();
<span class="hljs-type">boolean</span> <span class="hljs-variable">asyncDispatching</span> <span class="hljs-operator">=</span> request.isAsyncDispatching();

<span class="hljs-keyword">try</span> {
    context.bind(Globals.IS_SECURITY_ENABLED, MY_CLASSLOADER);
    <span class="hljs-keyword">if</span> (!asyncAtStart &amp;&amp; !context.fireRequestInitEvent(request.getRequest())) {
        <span class="hljs-keyword">return</span>;
    }
    <span class="hljs-keyword">try</span> {
        <span class="hljs-keyword">if</span> (!asyncAtStart || asyncDispatching) {
            context.getPipeline().getFirst().invoke(request, response);
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-keyword">if</span> (!response.isErrorReportRequired()) {
                <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">IllegalStateException</span>(sm.getString(<span class="hljs-string">"standardHost.asyncStateError"</span>));
            }
        }
    } <span class="hljs-keyword">catch</span> (Throwable t) {
        ExceptionUtils.handleThrowable(t);
        container.getLogger().error(<span class="hljs-string">"Exception Processing "</span> + request.getRequestURI(), t);
        <span class="hljs-keyword">if</span> (!response.isErrorReportRequired()) {
            request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, t);
            throwable(request, response, t);
        }
    }
    response.setSuspended(<span class="hljs-literal">false</span>);

    <span class="hljs-type">Throwable</span> <span class="hljs-variable">t</span> <span class="hljs-operator">=</span> (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);
    <span class="hljs-keyword">if</span> (!context.getState().isAvailable()) {
        <span class="hljs-keyword">return</span>;
    }
    <span class="hljs-comment">// 在这里判断请求是不是发生了错误,错误的话就进入StandardHostValve的status(Request request, Response response)方法。</span>
    <span class="hljs-comment">// Look for (and render if found) an application level error page</span>
    <span class="hljs-keyword">if</span> (response.isErrorReportRequired()) {
        <span class="hljs-keyword">if</span> (t != <span class="hljs-literal">null</span>) {
            throwable(request, response, t);
        } <span class="hljs-keyword">else</span> {
            status(request, response);
        }
    }

    <span class="hljs-keyword">if</span> (!request.isAsync() &amp;&amp; !asyncAtStart) {
        context.fireRequestDestroyEvent(request.getRequest());
    }
} <span class="hljs-keyword">finally</span> {
    <span class="hljs-comment">// Access a session (if present) to update last accessed time, based</span>
    <span class="hljs-comment">// on a strict interpretation of the specification</span>
    <span class="hljs-keyword">if</span> (ACCESS_SESSION) {
        request.getSession(<span class="hljs-literal">false</span>);
    }
    context.unbind(Globals.IS_SECURITY_ENABLED, MY_CLASSLOADER);
}

}

这个方法会根据返回的响应判断是不是发生了错了,如果发生了 error,则进入 StandardHostValve 的 status(Request request, Response response) 方法。这个方法“兜兜转转”又进入了 StandardHostValve 的 custom(Request request, Response response,ErrorPage errorPage) 方法。这个方法中将请求重新 forward 到了 "/error" 接口。

 private boolean custom(Request request, Response response,
                             ErrorPage errorPage) {
    <span class="hljs-keyword">if</span> (container.getLogger().isDebugEnabled()) {
        container.getLogger().debug(<span class="hljs-string">"Processing "</span> + errorPage);
    }
    <span class="hljs-keyword">try</span> {
        <span class="hljs-comment">// Forward control to the specified location</span>
        <span class="hljs-type">ServletContext</span> <span class="hljs-variable">servletContext</span> <span class="hljs-operator">=</span>
            request.getContext().getServletContext();
        <span class="hljs-type">RequestDispatcher</span> <span class="hljs-variable">rd</span> <span class="hljs-operator">=</span>
            servletContext.getRequestDispatcher(errorPage.getLocation());
        <span class="hljs-keyword">if</span> (rd == <span class="hljs-literal">null</span>) {
            container.getLogger().error(
                sm.getString(<span class="hljs-string">"standardHostValue.customStatusFailed"</span>, errorPage.getLocation()));
            <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
        }
        <span class="hljs-keyword">if</span> (response.isCommitted()) {
            rd.include(request.getRequest(), response.getResponse());
        } <span class="hljs-keyword">else</span> {
            <span class="hljs-comment">// Reset the response (keeping the real error code and message)</span>
            response.resetBuffer(<span class="hljs-literal">true</span>);
            response.setContentLength(-<span class="hljs-number">1</span>);
            <span class="hljs-comment">// 1: 重新forward请求到/error接口</span>
            rd.forward(request.getRequest(), response.getResponse());
            response.setSuspended(<span class="hljs-literal">false</span>);
        }
        <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>;
    } <span class="hljs-keyword">catch</span> (Throwable t) {
        ExceptionUtils.handleThrowable(t);
        container.getLogger().error(<span class="hljs-string">"Exception Processing "</span> + errorPage, t);
        <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>;
    }
}

上面标号 1 处的代码重新将请求 forward 到了 /error 接口。所以如果我们开着 Debug 日志的话,你会在后台看到下面的日志。

[http-nio-8888-exec-7] DEBUG org.springframework.web.servlet.DispatcherServlet:891 - DispatcherServlet with name 'dispatcherServlet' processing POST request for [/error]
2020-11-19 19:04:04.280 [http-nio-8888-exec-7] DEBUG org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping:313 - Looking up handler method for path /error
2020-11-19 19:04:04.281 [http-nio-8888-exec-7] DEBUG org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping:320 - Returning handler method [public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController.error(javax.servlet.http.HttpServletRequest)]
2020-11-19 19:04:04.281 [http-nio-8888-exec-7] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory:255 - Returning cached instance of singleton bean 'basicErrorController'

上面是 /error 的请求日志。到这边还是没说明为什么能返回 json 格式的 404 返回格式。我们继续往下看。

到这边为止,我们好像没有任何线索了。但是如果仔细看上面日志的话,你会发现这个接口的处理方法是:

org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController.error(javax.servlet.http.HttpServletRequest)]

我们打开 BasicErrorController 这个类的源代码,一切豁然开朗。

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
    @RequestMapping(produces = "text/html")
    public ModelAndView errorHtml(HttpServletRequest request,
            HttpServletResponse response) {
        HttpStatus status = getStatus(request);
        Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes(
                request, isIncludeStackTrace(request, MediaType.TEXT_HTML)));
        response.setStatus(status.value());
        ModelAndView modelAndView = resolveErrorView(request, response, status, model);
        return (modelAndView == null ? new ModelAndView("error", model) : modelAndView);
    }
<span class="hljs-meta">@RequestMapping</span>
<span class="hljs-meta">@ResponseBody</span>
<span class="hljs-keyword">public</span> ResponseEntity&lt;Map&lt;String, Object&gt;&gt; <span class="hljs-title function_">error</span><span class="hljs-params">(HttpServletRequest request)</span> {
    Map&lt;String, Object&gt; body = getErrorAttributes(request,
            isIncludeStackTrace(request, MediaType.ALL));
    <span class="hljs-type">HttpStatus</span> <span class="hljs-variable">status</span> <span class="hljs-operator">=</span> getStatus(request);
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ResponseEntity</span>&lt;Map&lt;String, Object&gt;&gt;(body, status);
}
... 省略部分方法

}

BasicErrorController 是 Spring 默认配置的一个 Controller,默认处理 /error 请求。BasicErrorController 提供两种返回错误一种是页面返回、当你是页面请求的时候就会返回页面,另外一种是 json 请求的时候就会返回 json 错误。

自定义 404 错误处理类#

我们先看下 BasicErrorController 是在哪里进行配置的。

在 IDEA 中,查看 BasicErrorController 的 usage,我们发现这个类是在 ErrorMvcAutoConfiguration 中自动配置的。

@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({Servlet.class, DispatcherServlet.class})
// Load before the main WebMvcAutoConfiguration so that the error View is available
@AutoConfigureBefore(WebMvcAutoConfiguration.class)
@EnableConfigurationProperties({ServerProperties.class, ResourceProperties.class})
public class ErrorMvcAutoConfiguration {
<span class="hljs-meta">@Bean</span>
<span class="hljs-meta">@ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT)</span>
<span class="hljs-keyword">public</span> BasicErrorController <span class="hljs-title function_">basicErrorController</span><span class="hljs-params">(ErrorAttributes errorAttributes)</span> {
	<span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">BasicErrorController</span>(errorAttributes, <span class="hljs-built_in">this</span>.serverProperties.getError(),
			<span class="hljs-built_in">this</span>.errorViewResolvers);
}
... 省略部分代码

}

从上面的配置中可以看出来,只要我们自己配置一个 ErrorController,就可以覆盖掉 BasicErrorController 的行为。

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class CustomErrorController extends BasicErrorController {
<span class="hljs-meta">@Value("${server.error.path:${error.path:/error}}")</span>
<span class="hljs-keyword">private</span> String path;

<span class="hljs-keyword">public</span> <span class="hljs-title function_">CustomErrorController</span><span class="hljs-params">(ServerProperties serverProperties)</span> {
    <span class="hljs-built_in">super</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">DefaultErrorAttributes</span>(), serverProperties.getError());
}

<span class="hljs-comment">/**
 * 覆盖默认的JSON响应
 */</span>
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> ResponseEntity&lt;Map&lt;String, Object&gt;&gt; <span class="hljs-title function_">error</span><span class="hljs-params">(HttpServletRequest request)</span> {

    <span class="hljs-type">HttpStatus</span> <span class="hljs-variable">status</span> <span class="hljs-operator">=</span> getStatus(request);
    Map&lt;String, Object&gt; map = <span class="hljs-keyword">new</span> <span class="hljs-title class_">HashMap</span>&lt;String, Object&gt;(<span class="hljs-number">16</span>);
    Map&lt;String, Object&gt; originalMsgMap = getErrorAttributes(request, isIncludeStackTrace(request, MediaType.ALL));
    <span class="hljs-type">String</span> <span class="hljs-variable">path</span> <span class="hljs-operator">=</span> (String)originalMsgMap.get(<span class="hljs-string">"path"</span>);
    <span class="hljs-type">String</span> <span class="hljs-variable">error</span> <span class="hljs-operator">=</span> (String)originalMsgMap.get(<span class="hljs-string">"error"</span>);
    <span class="hljs-type">String</span> <span class="hljs-variable">message</span> <span class="hljs-operator">=</span> (String)originalMsgMap.get(<span class="hljs-string">"message"</span>);
    <span class="hljs-type">StringJoiner</span> <span class="hljs-variable">joiner</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">StringJoiner</span>(<span class="hljs-string">","</span>,<span class="hljs-string">"["</span>,<span class="hljs-string">"]"</span>);
    joiner.add(path).add(error).add(message);
    map.put(<span class="hljs-string">"rtnCode"</span>, <span class="hljs-string">"9999"</span>);
    map.put(<span class="hljs-string">"rtnMsg"</span>, joiner.toString());
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">ResponseEntity</span>&lt;Map&lt;String, Object&gt;&gt;(map, status);
}

<span class="hljs-comment">/**
 * 覆盖默认的HTML响应
 */</span>
<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> ModelAndView <span class="hljs-title function_">errorHtml</span><span class="hljs-params">(HttpServletRequest request, HttpServletResponse response)</span> {
    <span class="hljs-comment">//请求的状态</span>
    <span class="hljs-type">HttpStatus</span> <span class="hljs-variable">status</span> <span class="hljs-operator">=</span> getStatus(request);
    response.setStatus(getStatus(request).value());
    Map&lt;String, Object&gt; model = getErrorAttributes(request,
            isIncludeStackTrace(request, MediaType.TEXT_HTML));
    <span class="hljs-type">ModelAndView</span> <span class="hljs-variable">modelAndView</span> <span class="hljs-operator">=</span> resolveErrorView(request, response, status, model);
    <span class="hljs-comment">//指定自定义的视图</span>
    <span class="hljs-keyword">return</span> (modelAndView == <span class="hljs-literal">null</span> ? <span class="hljs-keyword">new</span> <span class="hljs-title class_">ModelAndView</span>(<span class="hljs-string">"error"</span>, model) : modelAndView);
}

}

默认的错误路径是 /error,我们可以通过以下配置进行覆盖:

server:
  error:
    path: /xxx

更详细的内容请参考 Spring Boot 的章节。

简单总结#

  • 如果在过滤器(Filter)中发生异常,或者调用的接口不存在,Spring 会直接将 Response 的 errorStatus 状态设置成 1,将 http 响应码设置为 500 或者 404,Tomcat 检测到 errorStatus 为 1 时,会将请求重现 forward 到 /error 接口;
  • 如果请求已经进入了 Controller 的处理方法,这时发生了异常,如果没有配置 Spring 的全局异常机制,那么请求还是会被 forward 到 /error 接口,如果配置了全局异常处理,Controller 中的异常会被捕获;
  • 继承 BasicErrorController 就可以覆盖原有的错误处理方式。