前言
在 SpringBoot 项目中,默认情况下,使用浏览器访问一个不存在的地址会返回如下错误页面:
而当客户端未非浏览器时,错误信息则会以 json 数据返回,如下:
会出现如上效果的原因是 SpringBoot 针对错误消息做了自动配置,对应自动配置类为 org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration 。
自定义错误页
查看错误自动配置类会发现在该类中注册了如下组件:
ErrorPageCustomizer
@Bean public ErrorPageCustomizer errorPageCustomizer() { return new ErrorPageCustomizer(this.serverProperties); }
查看该组件类:
1 private static class ErrorPageCustomizer implements ErrorPageRegistrar, Ordered { 2 3 private final ServerProperties properties; 4 5 protected ErrorPageCustomizer(ServerProperties properties) { 6 this.properties = properties; 7 } 8 9 @Override 10 public void registerErrorPages(ErrorPageRegistry errorPageRegistry) { 11 ErrorPage errorPage = new ErrorPage(this.properties.getServletPrefix() 12 + this.properties.getError().getPath()); 13 errorPageRegistry.addErrorPages(errorPage); 14 } 15 16 @Override 17 public int getOrder() { 18 return 0; 19 } 20 21 }
在第 10 行的 registerErrorPages 方法中,注册了一个错误页,错误页路径为 this.properties.getError().getPath() ,该值为
@Value("${error.path:/error}") private String path = "/error";
即,一旦出现了 4xx 或 5xx 错误,该组件就会生效,可用其定制系统发生错误时的转发路径,默认情况下当前请求会转发到 /error 路径。
BasicErrorController
@Bean @ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT) public BasicErrorController basicErrorController(ErrorAttributes errorAttributes) { return new BasicErrorController(errorAttributes, this.serverProperties.getError(), this.errorViewResolvers); }
查看该组件类:
1 package org.springframework.boot.autoconfigure.web; 2 3 import java.util.Collections; 4 import java.util.List; 5 import java.util.Map; 6 7 import javax.servlet.http.HttpServletRequest; 8 import javax.servlet.http.HttpServletResponse; 9 10 import org.springframework.boot.autoconfigure.web.ErrorProperties.IncludeStacktrace; 11 import org.springframework.boot.context.embedded.AbstractEmbeddedServletContainerFactory; 12 import org.springframework.http.HttpStatus; 13 import org.springframework.http.MediaType; 14 import org.springframework.http.ResponseEntity; 15 import org.springframework.stereotype.Controller; 16 import org.springframework.util.Assert; 17 import org.springframework.web.bind.annotation.RequestMapping; 18 import org.springframework.web.bind.annotation.ResponseBody; 19 import org.springframework.web.servlet.ModelAndView; 20 21 @Controller 22 @RequestMapping("${server.error.path:${error.path:/error}}") 23 public class BasicErrorController extends AbstractErrorController { 24 25 private final ErrorProperties errorProperties; 26 27 public BasicErrorController(ErrorAttributes errorAttributes, 28 ErrorProperties errorProperties) { 29 this(errorAttributes, errorProperties, 30 Collections.<ErrorViewResolver>emptyList()); 31 } 32 33 public BasicErrorController(ErrorAttributes errorAttributes, 34 ErrorProperties errorProperties, List<ErrorViewResolver> errorViewResolvers) { 35 super(errorAttributes, errorViewResolvers); 36 Assert.notNull(errorProperties, "ErrorProperties must not be null"); 37 this.errorProperties = errorProperties; 38 } 39 40 @Override 41 public String getErrorPath() { 42 return this.errorProperties.getPath(); 43 } 44 45 @RequestMapping(produces = "text/html") 46 public ModelAndView errorHtml(HttpServletRequest request, 47 HttpServletResponse response) { 48 HttpStatus status = getStatus(request); 49 Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes( 50 request, isIncludeStackTrace(request, MediaType.TEXT_HTML))); 51 response.setStatus(status.value()); 52 ModelAndView modelAndView = resolveErrorView(request, response, status, model); 53 return (modelAndView != null) ? modelAndView : new ModelAndView("error", model); 54 } 55 56 @RequestMapping 57 @ResponseBody 58 public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { 59 Map<String, Object> body = getErrorAttributes(request, 60 isIncludeStackTrace(request, MediaType.ALL)); 61 HttpStatus status = getStatus(request); 62 return new ResponseEntity<Map<String, Object>>(body, status); 63 } 64 65 protected boolean isIncludeStackTrace(HttpServletRequest request, 66 MediaType produces) { 67 IncludeStacktrace include = getErrorProperties().getIncludeStacktrace(); 68 if (include == IncludeStacktrace.ALWAYS) { 69 return true; 70 } 71 if (include == IncludeStacktrace.ON_TRACE_PARAM) { 72 return getTraceParameter(request); 73 } 74 return false; 75 } 76 77 protected ErrorProperties getErrorProperties() { 78 return this.errorProperties; 79 } 80 81 }
可以看到该组件实际上是一个控制器,用来处理路径为配置中定义的 "${server.error.path:${error.path:/error}}" 请求,如果 server.error 和 error.path 都没有配置,则默认处理路径为 /error 的请求。
控制器中有两个响应方法,分别为第 46 行的 errorHtml 方法和第 58 行的 error 方法,它们都是用来处理路径为 /error 的请求,但 errorHtml 方法返回的错误消息是一个 html 页面,而 error 方法是返回的错误消息是一个 json 数据。通过 @RequestMapping 注解中的 produces 属性来区分客户端需要的错误消息类型,即根据客户端的 accept 请求头区分。具体以哪个页面作为错误页则可看到在第 52 行的 resolveErrorView 方法:
1 protected ModelAndView resolveErrorView(HttpServletRequest request, 2 HttpServletResponse response, HttpStatus status, Map<String, Object> model) { 3 for (ErrorViewResolver resolver : this.errorViewResolvers) { 4 ModelAndView modelAndView = resolver.resolveErrorView(request, status, model); 5 if (modelAndView != null) { 6 return modelAndView; 7 } 8 } 9 return null; 10 }
可以看到,该方法时遍历容器中所有的错误视图解析器,如果解析器解析当前请求返回的 modelAndView 不为空,则以该 modelAndView 作为错误页的响应。即:以哪个页面作为错误页是由错误视图解析器的 resolveErrorView 方法的返回值决定。
DefaultErrorViewResolver
@Bean @ConditionalOnBean(DispatcherServlet.class) @ConditionalOnMissingBean public DefaultErrorViewResolver conventionErrorViewResolver() { return new DefaultErrorViewResolver(this.applicationContext, this.resourceProperties); }
这是默认配置的错误视图解析器,查看它的 resolveErrorView 方法:
1 @Override 2 public ModelAndView resolveErrorView(HttpServletRequest request, HttpStatus status, 3 Map<String, Object> model) { 4 // 传入字符串形式的状态码 5 ModelAndView modelAndView = resolve(String.valueOf(status), model); 6 if (modelAndView == null && SERIES_VIEWS.containsKey(status.series())) { 7 modelAndView = resolve(SERIES_VIEWS.get(status.series()), model); 8 } 9 return modelAndView; 10 } 11 12 private ModelAndView resolve(String viewName, Map<String, Object> model) { 13 String errorViewName = "error/" + viewName; // 如:error/404 14 TemplateAvailabilityProvider provider = this.templateAvailabilityProviders 15 .getProvider(errorViewName, this.applicationContext); 16 if (provider != null) { // 如果模板引擎解析器可解析则返回模板视图 17 return new ModelAndView(errorViewName, model); 18 } 19 // 模板引擎不可解析时 20 return resolveResource(errorViewName, model); 21 } 22 23 private ModelAndView resolveResource(String viewName, Map<String, Object> model) { 24 for (String location : this.resourceProperties.getStaticLocations()) { // 遍历静态资源文件夹 25 try { 26 Resource resource = this.applicationContext.getResource(location); // 获取静态资源 27 resource = resource.createRelative(viewName + ".html"); // 如:error/404.html 28 if (resource.exists()) { // 判断对应资源是否存在 29 return new ModelAndView(new HtmlResourceView(resource), model); // 如果存在则返回对应 html 视图 30 } 31 } 32 catch (Exception ex) { 33 } 34 } 35 return null; 36 }
通过上述代码可以看到,当请求出现错误时,错误视图解析器会在模板路径及静态文件夹路径下寻找以该错误对应状态码命名的 html 页面作为错误响应视图。比如错误代码为 404,那么默认情况下将会寻找在 templates 和 static 等静态资源文件夹下的 error/404.html 页面作为响应页。我们还可以通过使用 4xx.html 和 5xx.html 作为模板页或静态页分别来匹配以 4 开头和 5 开头的错误让其作为该错误的响应页。从 org.springframework.boot.autoconfigure.web.BasicErrorController#errorHtml 方法的返回值可以看到,如果在模板文件夹和静态文件夹下都没有找到对应的错误页,那么将会返回 new ModelAndView("error", model) 对象,而这个 error 视图在错误自动配置类中中已经配置好了:
1 @Configuration 2 @ConditionalOnProperty(prefix = "server.error.whitelabel", name = "enabled", matchIfMissing = true) 3 @Conditional(ErrorTemplateMissingCondition.class) 4 protected static class WhitelabelErrorViewConfiguration { 5 6 private final SpelView defaultErrorView = new SpelView( 7 "<html><body><h1>Whitelabel Error Page</h1>" 8 + "<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>" 9 + "<div id='created'>${timestamp}</div>" 10 + "<div>There was an unexpected error (type=${error}, status=${status}).</div>" 11 + "<div>${message}</div></body></html>"); 12 13 @Bean(name = "error") 14 @ConditionalOnMissingBean(name = "error") 15 public View defaultErrorView() { 16 return this.defaultErrorView; 17 } 18 19 @Bean 20 @ConditionalOnMissingBean(BeanNameViewResolver.class) 21 public BeanNameViewResolver beanNameViewResolver() { 22 BeanNameViewResolver resolver = new BeanNameViewResolver(); 23 resolver.setOrder(Ordered.LOWEST_PRECEDENCE - 10); 24 return resolver; 25 } 26 }
DefaultErrorAttributes
@Bean @ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT) public DefaultErrorAttributes errorAttributes() { return new DefaultErrorAttributes(); }
如上我们只说明了错误页的显示规则,那错误页的消息又是从何而来呢?回头看到 org.springframework.boot.autoconfigure.web.BasicErrorController#errorHtml 方法:
1 @RequestMapping(produces = "text/html") 2 public ModelAndView errorHtml(HttpServletRequest request, 3 HttpServletResponse response) { 4 HttpStatus status = getStatus(request); 5 Map<String, Object> model = Collections.unmodifiableMap(getErrorAttributes( 6 request, isIncludeStackTrace(request, MediaType.TEXT_HTML))); 7 response.setStatus(status.value()); 8 ModelAndView modelAndView = resolveErrorView(request, response, status, model); 9 return (modelAndView != null) ? modelAndView : new ModelAndView("error", model); 10 }
可以看到返回的 model 的数据为 getErrorAttributes(request, isIncludeStackTrace(request, MediaType.TEXT_HTML)) 方法的返回值,查看该方法:
1 protected Map<String, Object> getErrorAttributes(HttpServletRequest request, 2 boolean includeStackTrace) { 3 RequestAttributes requestAttributes = new ServletRequestAttributes(request); 4 return this.errorAttributes.getErrorAttributes(requestAttributes, 5 includeStackTrace); 6 }
继续查看 this.errorAttributes.getErrorAttributes(requestAttributes, includeStackTrace) 方法:
1 @Override 2 public Map<String, Object> getErrorAttributes(RequestAttributes requestAttributes, 3 boolean includeStackTrace) { 4 Map<String, Object> errorAttributes = new LinkedHashMap<String, Object>(); 5 errorAttributes.put("timestamp", new Date()); 6 addStatus(errorAttributes, requestAttributes); 7 addErrorDetails(errorAttributes, requestAttributes, includeStackTrace); 8 addPath(errorAttributes, requestAttributes); 9 return errorAttributes; 10 } 11 12 private void addStatus(Map<String, Object> errorAttributes, 13 RequestAttributes requestAttributes) { 14 Integer status = getAttribute(requestAttributes, 15 "javax.servlet.error.status_code"); 16 if (status == null) { 17 errorAttributes.put("status", 999); 18 errorAttributes.put("error", "None"); 19 return; 20 } 21 errorAttributes.put("status", status); 22 try { 23 errorAttributes.put("error", HttpStatus.valueOf(status).getReasonPhrase()); 24 } 25 catch (Exception ex) { 26 errorAttributes.put("error", "Http Status " + status); 27 } 28 } 29 30 private void addErrorDetails(Map<String, Object> errorAttributes, 31 RequestAttributes requestAttributes, boolean includeStackTrace) { 32 Throwable error = getError(requestAttributes); 33 if (error != null) { 34 while (error instanceof ServletException && error.getCause() != null) { 35 error = ((ServletException) error).getCause(); 36 } 37 errorAttributes.put("exception", error.getClass().getName()); 38 addErrorMessage(errorAttributes, error); 39 if (includeStackTrace) { 40 addStackTrace(errorAttributes, error); 41 } 42 } 43 Object message = getAttribute(requestAttributes, "javax.servlet.error.message"); 44 if ((!StringUtils.isEmpty(message) || errorAttributes.get("message") == null) 45 && !(error instanceof BindingResult)) { 46 errorAttributes.put("message", 47 StringUtils.isEmpty(message) ? "No message available" : message); 48 } 49 } 50 51 private void addErrorMessage(Map<String, Object> errorAttributes, Throwable error) { 52 BindingResult result = extractBindingResult(error); 53 if (result == null) { 54 errorAttributes.put("message", error.getMessage()); 55 return; 56 } 57 if (result.getErrorCount() > 0) { 58 errorAttributes.put("errors", result.getAllErrors()); 59 errorAttributes.put("message", 60 "Validation failed for object='" + result.getObjectName() 61 + "'. Error count: " + result.getErrorCount()); 62 } 63 else { 64 errorAttributes.put("message", "No errors"); 65 } 66 } 67 68 private void addStackTrace(Map<String, Object> errorAttributes, Throwable error) { 69 StringWriter stackTrace = new StringWriter(); 70 error.printStackTrace(new PrintWriter(stackTrace)); 71 stackTrace.flush(); 72 errorAttributes.put("trace", stackTrace.toString()); 73 } 74 75 private void addPath(Map<String, Object> errorAttributes, 76 RequestAttributes requestAttributes) { 77 String path = getAttribute(requestAttributes, "javax.servlet.error.request_uri"); 78 if (path != null) { 79 errorAttributes.put("path", path); 80 }
通过上述代码我们可以知道在错误页中我们可以使用如下错误信息:
timestamp:时间戳
status:状态码
error:错误提示
exception:异常对象
message:异常信息
errors:JSR303 数据校验错误信息
自定义错误信息
上述已经描述了我们如何使用自定义的错误页,但是使用的错误信息还依旧是 SpringBoot 默认配置的,如果我们想要自己定制错误信息,则可通过如下方式。
方便下面测试先编写如下异常类及控制器:
package com.springboot.webdev2.ex; public class MyException extends RuntimeException { public MyException() { super("运行期间出异常了"); } }
package com.springboot.webdev2.controller; import com.springboot.webdev2.ex.MyException; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; @Controller public class TestController { @RequestMapping("test") public void test1(){ throw new MyException(); } }
方式一:自定义异常处理器
1 package com.springboot.webdev2.component; 2 3 import com.springboot.webdev2.ex.MyException; 4 import org.springframework.web.bind.annotation.ControllerAdvice; 5 import org.springframework.web.bind.annotation.ExceptionHandler; 6 import org.springframework.web.bind.annotation.ResponseBody; 7 8 import java.util.HashMap; 9 import java.util.Map; 10 11 @ControllerAdvice 12 public class MyExceptionHandler { 13 14 @ResponseBody 15 @ExceptionHandler(MyException.class) 16 public Map<String,Object> handleException(Exception e){ 17 Map<String, Object> map = new HashMap<>(); 18 map.put("code", "myCode"); 19 map.put("msg", "自定义的异常"); 20 return map; 21 } 22 }
该方式是 SpringMVC 提供的异常处理方式,缺点:使用该方式失去了 SpringBoot 本身的根据客户端的不同自适应响应数据类型的功能。
方式二:转发到错误处理路径
我们已经知道默认情况下出现异常 SpringBoot 会将请求转发到 /error ,那么如果我们通过异常处理器手动转发到该路径,并可手动将我们需要的错误信息放入请求域,我们就可以解决方式一的缺点并且可以在错误页使用我们自己的错误信息了。
package com.springboot.webdev2.component; import com.springboot.webdev2.ex.MyException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; @ControllerAdvice public class MyExceptionHandler { @ExceptionHandler(MyException.class) public String handleException(Exception e, HttpServletRequest request){ // SpringBoot 默认使用的状态码就是请求域中的 javax.servlet.error.status_code request.setAttribute("javax.servlet.error.status_code", 400); Map<String, Object> map = new HashMap<>(); map.put("code", "myCode"); map.put("msg", "自定义的异常"); request.setAttribute("ext", map); return "forward:/error"; } }
<!DOCTYPE html> <html lang="cn"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>[[${status}]]</h1> <h2>[[${message}]]</h2> <!--请求域中取错误信息--> <h2>[[${ext.msg}]]</h2> </body> </html>
可以发现,该方式依旧有一个缺点:放入请求域中的数据未被序列化,所以只可在转发到的模板页中取到,而在客户端是非浏览器时是拿不到自定义的错误信息的。
方式三:自定义错误处理控制器
出现异常时 SpringBoot 会将请求转发到 /error ,而处理该请求的控制器为 BaseErrorController ,查看该控制器注册信息我们也可以知道,当我们自己定义一个 org.springframework.boot.autoconfigure.web.ErrorController 组件注册到容器中时,那么默认的 BasicErrorController 就不生效了,所以我们可以在自定义的错误处理控制器中根据我们的需要取到我们合适的信息返回。该方式比较复杂,明显不合适,了解即可,略过。
方式四:自定义ErrorAttributes
通过查看 org.springframework.boot.autoconfigure.web.BasicErrorController 我们已经知道,不管是响应 html 还是 json 错误信息,它们的错误信息都是通过 this.errorAttributes.getErrorAttributes(requestAttributes, includeStackTrace) 方法取到,而 this.errorAttributes 对应的组件实际上在错误自动配置类中已经注册,即 DefaultErrorAttributes ,所以我们可以自定义一个的 org.springframework.boot.autoconfigure.web.ErrorAttributes 组件注册到容器中,重写它的 getErrorAttributes 方法,通过手动取得自定义的错误信息返回即可。
package com.springboot.webdev2.component; import org.springframework.boot.autoconfigure.web.DefaultErrorAttributes; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; import java.util.Map; @Component public class MyErrorAttributes extends DefaultErrorAttributes { @Override public Map<String, Object> getErrorAttributes(RequestAttributes requestAttributes, boolean includeStackTrace) { Map<String, Object> errorAttributes = super.getErrorAttributes(requestAttributes, includeStackTrace); errorAttributes.put("ext", requestAttributes.getAttribute("ext", RequestAttributes.SCOPE_REQUEST)); return errorAttributes; } }
package com.springboot.webdev2.component; import com.springboot.webdev2.ex.MyException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; @ControllerAdvice public class MyExceptionHandler { @ExceptionHandler(MyException.class) public String handleException(Exception e, HttpServletRequest request){ // SpringBoot 默认使用的状态码就是请求域中的 javax.servlet.error.status_code request.setAttribute("javax.servlet.error.status_code", 400); Map<String, Object> map = new HashMap<>(); map.put("code", "myCode"); map.put("msg", "自定义的异常"); request.setAttribute("ext", map); return "forward:/error"; } }
<!DOCTYPE html> <html lang="cn"> <head> <meta charset="UTF-8"> <title>Title</title> </head> <body> <h1>[[${status}]]</h1> <h2>[[${message}]]</h2> <!--请求域中取错误信息--> <h2>[[${ext.msg}]]</h2> </body> </html>