Spring Boot 404 没那么简单

Last Modified: 2023/01/12

概述

当我们在浏览器中输入了一个不存在的地址,spring boot 会给我们返回一个著名的 “white label page”,其内容如下:

<html>
    <body>
        <h1>Whitelabel Error Page</h1>
        <p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>
        <div id='created'>Thu Jan 12 11:22:14 CST 2023</div>
        <div>There was an unexpected error (type=Not Found, status=404).</div>
    </body>
</html>

但是如果我们借助于 postman 发送一个请求到这个不存在的地址,则返回的内容变成了 json:

{
    "timestamp": "2023-01-12T03:27:36.007+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/hellox"
}

当我们在浏览器中请求了一个不存在的地址时,spring boot 内部究竟是怎么处理的呢?事情并没有那么简单,本文将跟大家一起探索下处理过程。

注:本文以 spring boot + tomcat 为例说明,不适用于其他应用服务器处理流程。// ... 表示此处省略一万行代码。

404 处理流程

当访问一个不存在的路由时,假设这不存在的路由是 /hellox,spring boot 处理的大致过程如下:

  • ①tomcat 接收到请求,并将请求转发给 spring boot 的 DispatcherServlet#doDispatch;
  • ②doDispatch 通过 getHandler(request) 寻找能够处理该请求的 handler,由于没有任何 controller 能够处理 /hellox,但是 AbstractUrlHandlerMapping 中注册了 /** 对应的处理器为 ResourceHttpRequestHandler,这个处理器其实是处理静态资源的,因此这一步大致可认为是找不到 controller 中的处理方法,就将请求处理器设置为静态资源处理器;
  • ③利用 ResourceHttpRequestHandler#handleRequest 处理请求,然而并没有找到名为 hellox 的静态资源,到这里响应的状态码被设置为 404,同时响应的 errorState 被标记为1,标记响应失败
  • ④由于上一步响应已被标记为失败,请求流转到 StandardHostValve#status 方法,该方法根据状态码查找状态码对应的错误页面,找到了错误页面为地址为 /error
  • ⑤请求被转发到 /error,这之后请求又重新到达 DispatcherServlet#doDispatch 方法,然后到达 doDispatch,这次不同于上次,这次找到了 /error 对应的处理器为 BasicErrorController#error 或者 BasicErrorController#errorHtml
  • ⑥我们假设请求在浏览器中打开,此时会包含请求头 Accept: text/html,因此 /error 请求会被分发到 BasicErrorController#errorHtml,此时的响应就是大名鼎鼎的 “white label page”;但是如果我们请求是通过 postman 发送过去的(假设没有设置 Accept),此时请求就会被分发到 BasicErrorController#error,返回 json 响应。

处理过程中的涉及的代码片段

下面让我们看下整个处理过程中涉及到的代码片段,首先是 tomcat 接收请求,最终到达 StandardHostValve#invoke 方法:

StandardHostValve#invoke 方法:

public final void invoke(Request request, Response response)
        throws IOException, ServletException {
  // ...
  if (!response.isErrorReportRequired()) {
    // 最终会调用到 DispatcherServlet#doDispatch,对应上面的步骤①
    context.getPipeline().getFirst().invoke(request, response);
  }
  // ...
  // dispatch 处理完成后,看响应是否报错
  if (response.isErrorReportRequired()) {
    // If an error has occurred that prevents further I/O, don't waste time
    // producing an error report that will never be read
    AtomicBoolean result = new AtomicBoolean(false);
    response.getCoyoteResponse().action(ActionCode.IS_IO_ALLOWED, result);
    if (result.get()) {
      if (t != null) {
        throwable(request, response, t);
      } else {
        // 根据状态码找 error page,对应上面的步骤④
        status(request, response);
      }
    }
  }
}

上面步骤①,请求最终会被转发到 DispatcherServlet#doDispatch,以下是相关的代码片段:

DispatcherServlet#doDispatch

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
  // ...
  // 对应上面的步骤②,找到的 handler 为 ResourceHttpRequestHandler
  mappedHandler = getHandler(processedRequest);
  // ...
  // Determine handler adapter for the current request.
  HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
  // ...
  // Actually invoke the handler.
  // 对应上面的步骤③,经过 ha.handle 之后,响应被标记为失败,同时响应状态码被标记为 404
  mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
}

StandardHostValve#status

经过 DispatcherServlet#doDispatch 之后,来到步骤④的 StandardHostValve#status

private void status(Request request, Response response) {
  // ...
  // 根据响应状态码寻找 error page,找到 page 为 /error
  ErrorPage errorPage = context.findErrorPage(statusCode);
  if (errorPage == null) {
      // Look for a default error page
      errorPage = context.findErrorPage(0);
  }
  // ...
  if (errorPage != null && response.isErrorReportRequired()) {
    // ...
    // 在custom 方法中,请求被转发到 `/error`,对应上面的步骤⑤
    if (custom(request, response, errorPage)) {
        //...
    }
  }
}

StandardHostValve#custom

private boolean custom(Request request, Response response,
                             ErrorPage errorPage) {
  RequestDispatcher rd =
                  servletContext.getRequestDispatcher(errorPage.getLocation());
  
  if (response.isCommitted()) {
    // ...
  } else {
    // Reset the response (keeping the real error code and message)
    response.resetBuffer(true);
    response.setContentLength(-1);
    // 请求被转发到 `/error`,对应上面的步骤⑤
    rd.forward(request.getRequest(), response.getResponse());
    
    // If we forward, the response is suspended again
    response.setSuspended(false);
  }
}

BasicErrorController

巧了,spring boot 内置了 BasicErrorController,这个 controller 正好可以处理 /error,于是请求被 error 或者 errorHtml处理,对应上面的步骤⑥

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
  @RequestMapping
  public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    // ...
  }
  @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
  public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
    // ...
  }
}

未捕获异常处理

上面说到了 404 错误处理,如果 controller 中抛出了异常,又会如何呢,我们这里只讨论 spring boot 的默认处理,也就是不讨论通过 @ExceptionHandler 或者全局异常处理主动处理异常的情况。

这次我们假设 /hellox 定义如下:

@RestController
class HelloController {
  @GetMapping("hellox")
  public void hello() {
    throw new RuntimeException("hellox");
  }
}

当我们在浏览器中打开 /hellox 页面时,仍然会得到著名的 “white label page”:

<html>
    <body>
        <h1>Whitelabel Error Page</h1>
        <p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>
        <div id='created'>Thu Jan 12 15:59:29 CST 2023</div>
        <div>There was an unexpected error (type=Internal Server Error, status=500).</div>
    </body>
</html>

当我们在浏览器中打开 /hellox 页面时,spring boot 处理的大致过程如下:

  • ①tomcat 接收到请求,并将请求转发给 spring boot 的 DispatcherServlet#doDispatch;
  • ②doDispatch 通过 getHandler(request) 找到了 HelloController#hello 方法可以处理 /hellox 请求,于是调用该方法处理请求,但是请求抛出了 RuntimeException;
  • ③调用 DispatcherServlet#processHandlerException 方法处理异常,该方法内部通过 DefaultHandlerExceptionResolver#doResolveException 处理异常,此方法只能处理常见的 ServletException 异常,由于这里抛出的是 RuntimeException,该方法并不能处理,只是简单的返回 null;
  • ④经过上面步骤实际上异常并未被处理,异常被 processHandlerException 方法再次抛出,在 FrameworkServlet 中被捕获并包装为 NestedServletException 后再次抛出,随后在 StandardWrapperValve#invoke 中被捕获并通过 request.setAttribute(RequestDispatcher.ERROR_EXCEPTION, exception) 记录异常,同时将请求的响应码设置为 500,并且将响应标记为失败;
  • ⑤由于上一步响应已被标记为失败,且请求属性中记录了异常,因此知道请求是由于异常导致的响应失败,于是请求流转到 StandardHostValve#throwable 方法,该方法根据异常查找 error page,但是并未找到 error page,于是根据状态码查找 error page,之后请求被转发到 /error,这之后请求又重新到达 DispatcherServlet#doDispatch 方法,然后到达 doDispatch,这次不同于上次,这次找到了 /error 对应的处理器为 BasicErrorController#error 或者 BasicErrorController#errorHtml
  • ⑥我们假设请求在浏览器中打开,此时会包含请求头 Accept: text/html,因此 /error 请求会被分发到 BasicErrorController#errorHtml,此时的响应就是大名鼎鼎的 “white label page”;但是如果我们请求是通过 postman 发送过去的(假设没有设置 Accept),此时请求就会被分发到 BasicErrorController#error,返回 json 响应。

注:DefaultHandlerExceptionResolver#doResolveException 方法处理常见的 ServletException 异常类型,例如 HttpRequestMethodNotSupportedException、HttpRequestMethodNotSupportedException 和 HttpMediaTypeException 等。

以上就是 spring boot 默认的异常处理过程,代码就不贴了!希望对大家有帮助。

有问题吗?点此反馈!

温馨提示:反馈需要登录