默认规则

Spring Boot 提供了一个默认的 /error 路径来处理所有错误的映射。但是,同样的错误在不同的客户端下显示的效果是完全不一样的。

比如,在做前后端分离的应用程序时,使用 Postman、OkHttp 等这类工具。通过它来发送一个不存在的路径时,它返回的错误信息是 JSON 格式的。

image-20210710104356541

如果用的是浏览器来访问一个不正确的页面,那么它是这样的。它将会返回一个

image-20210710104540951

自定义错误页

自定义错误页非常的简单,将错误页放到项目路径 /resources/templates/error 或者放到 /resources/public/error 这个文件夹下即可。

就拿之前的模拟的后台系统来说吧,有一个 404 错误页和 500 的错误页,我们将其放进来,放到 templates/error 这个目录下吧。放完之后,如下所示。且文件名必须是与错误码对应的 html 文件,如果想用这个 html 文件代表一类错误码,比如 5xx.html(5 开头的代表着服务器端的错误)

image-20210710204038982

注意,放到 templates/error 错误页时,由于 templates 是加载模板引擎,放入 html 文件时,需要在 html 标签对中声明一下模板引擎 xmlns:th="http://www.thymeleaf.org"

测试 404 页面

我们访问一个路径不存在的页面,运行效果如下。

image-20210710204450972

测试 500 页面

我们在登录页面故意引发一个算数异常。

@GetMapping({"/login", "/"})
public String login() {
    int k = 2 / 0;
    return "login";
}

然后我在运行的时候,运行在 Tomcat 中出现的错误页面,居然是在打成 war 包时出现的错误页面。能够在 Spring Boot 中做出现这样的页面也真的是蛮幸运的。

image-20210710205338581

这总要找找原因吧,这个问题后面猜测估计是拦截器写的有问题。以下是我原来的拦截器。

@Configuration
public class LoginStatusConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/", "/css/**", "/fonts/**", "/images/**",
                        "/js/**", "/login", "/login_do");
    }
}

我把拦截器的写法改成这样,也就是排除了将根目录下请求路径(不包括子路径),也就是 /*

@Configuration
public class LoginStatusConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/*", "/css/**", "/fonts/**", "/images/**",
                        "/js/**", "/login", "/login_do");
    }
}

现在我们重新运行一下,看到下面的效果就知道了。

image-20210710210219440

异常处理的原理

异常处理中,有一个自动配置类,叫做 ErrorMvcAutoConfiguration ,它帮助我们配置好了异常处理的规则。同时有一些属性是从 ServerProperties , WebMvcProperties ResourceProperties 中获取来的。

@EnableConfigurationProperties({ ServerProperties.class, ResourceProperties.class, WebMvcProperties.class })
public class ErrorMvcAutoConfiguration {

   private final ServerProperties serverProperties;

   public ErrorMvcAutoConfiguration(ServerProperties serverProperties) {
      this.serverProperties = serverProperties;
   }
}

自动配置原理

在该自动配置类中,注册了如下组件:

  • DefaultErrorAttributes
  • BasicErrorController

BasicErrorController

先来谈谈 BasicErrorController 这个类,Controller 作为控制层的类,当然就是作为请求处理的类了。以下是它的部分源码。

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
	private final ErrorProperties errorProperties;
}

其中,server.error.path 可以在配置文件中手动配置错误的映射路径,如果这个没有去配置,那么它会去找error.path 这个配置项是否已经配置,如果还是没有,则使用默认的错误处理请求路径 /error,这个错误处理请求路径在页面中的 whitelabel 中可以看到。

This application has no explicit mapping for /error, so you are seeing this as a fallback.

然后,该类中还有这两个地方标注了 RequestMapping 注解,一个利用是浏览器是访问错误路径时所返回的内容,另外一个就是使用 Postman 等这种工具访问错误页时,返回的是 JSON 格式的错误信息。

/**
 * 这个方法是浏览器访问错误页时,返回 html 的
 */
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
    HttpStatus status = getStatus(request);
    Map<String, Object> model = Collections
        .unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
    response.setStatus(status.value());
    ModelAndView modelAndView = resolveErrorView(request, response, status, model);
    return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
}

/**
 * 这个方法是用 Postman 等请求工具试,通过 JSON 的格式返回错误信息
 */
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
    HttpStatus status = getStatus(request);
    if (status == HttpStatus.NO_CONTENT) {
        return new ResponseEntity<>(status);
    }
    Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
    return new ResponseEntity<>(body, status);
}

默认的错误页是在自动配置类中,有一个 StaticView 的内部类,这个内部类中里面有默认的错误页。

/**
  * Simple {@link View} implementation that writes a default HTML error page.
  */
private static class StaticView implements View {

    private static final MediaType TEXT_HTML_UTF8 = new MediaType("text", "html", StandardCharsets.UTF_8);

    private static final Log logger = LogFactory.getLog(StaticView.class);

    @Override
    public void render(Map<String, ?> model, HttpServletRequest request, HttpServletResponse response)
        throws Exception {
        if (response.isCommitted()) {
            String message = getMessage(model);
            logger.error(message);
            return;
        }
        response.setContentType(TEXT_HTML_UTF8.toString());
        StringBuilder builder = new StringBuilder();
        Object timestamp = model.get("timestamp"); // 拿到时间戳
        Object message = model.get("message"); // 拿到错误信息
        Object trace = model.get("trace"); // 拿到堆栈信息
        if (response.getContentType() == null) {
            response.setContentType(getContentType());
        }
        // 错误页的 html 字符串拼接部分
        builder.append("<html><body><h1>Whitelabel Error Page</h1>").append(
            "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>")
            .append("<div id='created'>").append(timestamp).append("</div>")
            .append("<div>There was an unexpected error (type=").append(htmlEscape(model.get("error")))
            .append(", status=").append(htmlEscape(model.get("status"))).append(").</div>");
        // 如果有错误信息,则展示出来
        if (message != null) {
            builder.append("<div>").append(htmlEscape(message)).append("</div>");
        }
        // 如果错误中包含着堆栈的信息,则将堆栈信息展示出来
        if (trace != null) {
            builder.append("<div style='white-space:pre-wrap;'>").append(htmlEscape(trace)).append("</div>");
        }
        builder.append("</body></html>");
        response.getWriter().append(builder.toString());
    }

    private String htmlEscape(Object input) {
        return (input != null) ? HtmlUtils.htmlEscape(input.toString()) : null;
    }

    private String getMessage(Map<String, ?> model) {
        Object path = model.get("path");
        String message = "Cannot render error page for request [" + path + "]";
        if (model.get("message") != null) {
            message += " and exception [" + model.get("message") + "]";
        }
        message += " as the response has already been committed.";
        message += " As a result, the response may have the wrong status code.";
        return message;
    }

    @Override
    public String getContentType() {
        return "text/html";
    }

}

DefaultErrorAttributes

就是使用 Postman 这个工具类的时候,返回的错误信息是 JSON 格式的。错误信息都放在 DefaultErrorAttributes 这个类中。

这里面有个 getErrorAttributes 的属性,里面就放着于 JSON 中对应的一些属性。

public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
    Map<String, Object> errorAttributes = getErrorAttributes(webRequest, options.isIncluded(Include.STACK_TRACE));
    if (Boolean.TRUE.equals(this.includeException)) {
        options = options.including(Include.EXCEPTION);
    }
    if (!options.isIncluded(Include.EXCEPTION)) {
        errorAttributes.remove("exception");
    }
    if (!options.isIncluded(Include.STACK_TRACE)) {
        errorAttributes.remove("trace");
    }
    if (!options.isIncluded(Include.MESSAGE) && errorAttributes.get("message") != null) {
        errorAttributes.put("message", "");
    }
    if (!options.isIncluded(Include.BINDING_ERRORS)) {
        errorAttributes.remove("errors");
    }
    return errorAttributes;
}

堆栈信息

private void addStackTrace(Map<String, Object> errorAttributes, Throwable error) {
    StringWriter stackTrace = new StringWriter();
    error.printStackTrace(new PrintWriter(stackTrace));
    stackTrace.flush();
    errorAttributes.put("trace", stackTrace.toString());
}

如果是直接访问 /error 这个错误页,那么它便会返回 999 状态码。对应的源码如下。

private void addStatus(Map<String, Object> errorAttributes, RequestAttributes requestAttributes) {
    Integer status = getAttribute(requestAttributes, RequestDispatcher.ERROR_STATUS_CODE);
    if (status == null) {
        errorAttributes.put("status", 999); // 999 状态码
        errorAttributes.put("error", "None");
        return;
    }
    errorAttributes.put("status", status);
    try {
        errorAttributes.put("error", HttpStatus.valueOf(status).getReasonPhrase());
    }
    catch (Exception ex) {
        // Unable to obtain a reason
        errorAttributes.put("error", "Http Status " + status);
    }
}