本文對Whitelabel Error Page進行源碼分析,詳細說明出現Whitelabel Error Page頁面的核心流程,並給出了自定義拓展的方案。 ...
深入理解Whitelabel Error Page底層源碼
(一)伺服器請求處理錯誤則轉發請求url
StandardHostValve的invoke()方法將根據請求的url選擇正確的Context來進行處理。在發生錯誤的情況下,內部將調用status()或throwable()來進行處理。具體而言,當出現HttpStatus錯誤時,則將由status()進行處理。當拋出異常時,則將由throwable()進行處理。status()和throwable()的內部均是通過Context來查找對應的ErrorPage,並最終調用custom()來進行處理。custom()用於將請求轉發到ErrorPage錯誤頁面中。
在SpringBoot項目中,如果伺服器處理請求失敗,則會通過上述的過程將請求轉發到/error中。
final class StandardHostValve extends ValveBase {
private void status(Request request, Response response) {
// ...
Context context = request.getContext();
// ...
// 從Context中查找ErrorPag
ErrorPage errorPage = context.findErrorPage(statusCode);
// ...
// 調用custom()
custom(request, response, errorPage);
// ...
}
protected void throwable(Request request, Response response,
Throwable throwable) {
// ...
// 從Context查找ErrorPage
ErrorPage errorPage = context.findErrorPage(throwable);
// ...
// 調用custom()
custom(request, response, errorPage);
// ...
}
private boolean custom(Request request, Response response,
ErrorPage errorPage) {
// ...
// 請求轉發
rd.forward(request.getRequest(), response.getResponse());
// ...
}
}
(二)路徑為/error的ErrorPage
為了能在Context中查找到ErrorPage,則必須先通過addErrorPage()來添加ErrorPage。在運行時,Context具體由StandardContext進行處理。
public class StandardContext extends ContainerBase implements Context, NotificationEmitter {
private final ErrorPageSupport errorPageSupport = new ErrorPageSupport();
@Override
public void addErrorPage(ErrorPage errorPage) {
// Validate the input parameters
if (errorPage == null)
throw new IllegalArgumentException
(sm.getString("standardContext.errorPage.required"));
String location = errorPage.getLocation();
if ((location != null) && !location.startsWith("/")) {
if (isServlet22()) {
if(log.isDebugEnabled())
log.debug(sm.getString("standardContext.errorPage.warning",
location));
errorPage.setLocation("/" + location);
} else {
throw new IllegalArgumentException
(sm.getString("standardContext.errorPage.error",
location));
}
}
errorPageSupport.add(errorPage);
fireContainerEvent("addErrorPage", errorPage);
}
}
addErrorPage()具體由是由TomcatServletWebServerFactory的configureContext()方法來調用的。
public class TomcatServletWebServerFactory extends AbstractServletWebServerFactory
implements ConfigurableTomcatWebServerFactory, ResourceLoaderAware {
protected void configureContext(Context context, ServletContextInitializer[] initializers) {
TomcatStarter starter = new TomcatStarter(initializers);
if (context instanceof TomcatEmbeddedContext) {
TomcatEmbeddedContext embeddedContext = (TomcatEmbeddedContext) context;
embeddedContext.setStarter(starter);
embeddedContext.setFailCtxIfServletStartFails(true);
}
context.addServletContainerInitializer(starter, NO_CLASSES);
for (LifecycleListener lifecycleListener : this.contextLifecycleListeners) {
context.addLifecycleListener(lifecycleListener);
}
for (Valve valve : this.contextValves) {
context.getPipeline().addValve(valve);
}
for (ErrorPage errorPage : getErrorPages()) {
org.apache.tomcat.util.descriptor.web.ErrorPage tomcatErrorPage = new org.apache.tomcat.util.descriptor.web.ErrorPage();
tomcatErrorPage.setLocation(errorPage.getPath());
tomcatErrorPage.setErrorCode(errorPage.getStatusCode());
tomcatErrorPage.setExceptionType(errorPage.getExceptionName());
context.addErrorPage(tomcatErrorPage);
}
for (MimeMappings.Mapping mapping : getMimeMappings()) {
context.addMimeMapping(mapping.getExtension(), mapping.getMimeType());
}
configureSession(context);
new DisableReferenceClearingContextCustomizer().customize(context);
for (TomcatContextCustomizer customizer : this.tomcatContextCustomizers) {
customizer.customize(context);
}
}
}
先調用getErrorPages()獲取所有錯誤頁面,然後再調用Context的addErrorPage()來添加ErrorPage錯誤頁面。
getErrorPages()中的錯誤頁面是通過AbstractConfigurableWebServerFactory的addErrorPages()來添加的。
public abstract class AbstractConfigurableWebServerFactory implements ConfigurableWebServerFactory {
@Override
public void addErrorPages(ErrorPage... errorPages) {
Assert.notNull(errorPages, "ErrorPages must not be null");
this.errorPages.addAll(Arrays.asList(errorPages));
}
}
addErrorPages()實際上是由ErrorMvcAutoConfiguration的ErrorPageCustomizer的registerErrorPages()調用的。
static class ErrorPageCustomizer implements ErrorPageRegistrar, Ordered {
private final ServerProperties properties;
private final DispatcherServletPath dispatcherServletPath;
protected ErrorPageCustomizer(ServerProperties properties, DispatcherServletPath dispatcherServletPath) {
this.properties = properties;
this.dispatcherServletPath = dispatcherServletPath;
}
@Override
public void registerErrorPages(ErrorPageRegistry errorPageRegistry) {
ErrorPage errorPage = new ErrorPage(
this.dispatcherServletPath.getRelativePath(this.properties.getError().getPath()));
errorPageRegistry.addErrorPages(errorPage);
}
@Override
public int getOrder() {
return 0;
}
}
在registerErrorPages()中,先從ServerProperties中獲取ErrorProperties,又從ErrorProperties中獲取path,而path預設為/error。可通過在配置文件中設置server.error.path來進行配置。
@ConfigurationProperties(prefix = "server", ignoreUnknownFields = true)
public class ServerProperties {
public class ErrorProperties {
// ...
@Value("${error.path:/error}")
private String path = "/error";
// ...
}
}
然後調用DispatcherServletPath的getRelativePath()來構建錯誤頁面的完整路徑。getRelativePath()調用getPrefix()用於獲取路徑首碼,getPrefix()又調用getPath()來獲取路徑。
@FunctionalInterface
public interface DispatcherServletPath {
default String getRelativePath(String path) {
String prefix = getPrefix();
if (!path.startsWith("/")) {
path = "/" + path;
}
return prefix + path;
}
default String getPrefix() {
String result = getPath();
int index = result.indexOf('*');
if (index != -1) {
result = result.substring(0, index);
}
if (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
}
DispatcherServletPath實際上是由DispatcherServletRegistrationBean進行處理的。而DispatcherServletRegistrationBean的path欄位值由構造函數給出。
public class DispatcherServletRegistrationBean extends ServletRegistrationBean<DispatcherServlet>
implements DispatcherServletPath {
private final String path;
public DispatcherServletRegistrationBean(DispatcherServlet servlet, String path) {
super(servlet);
Assert.notNull(path, "Path must not be null");
this.path = path;
super.addUrlMappings(getServletUrlMapping());
}
}
而DispatcherServletRegistrationBean實際上是在DispatcherServletAutoConfiguration中的DispatcherServletRegistrationConfiguration創建的。
@Configuration(proxyBeanMethods = false)
@Conditional(DispatcherServletRegistrationCondition.class)
@ConditionalOnClass(ServletRegistration.class)
@EnableConfigurationProperties(WebMvcProperties.class)
@Import(DispatcherServletConfiguration.class)
protected static class DispatcherServletRegistrationConfiguration {
@Bean(name = DEFAULT_DISPATCHER_SERVLET_REGISTRATION_BEAN_NAME)
@ConditionalOnBean(value = DispatcherServlet.class, name = DEFAULT_DISPATCHER_SERVLET_BEAN_NAME)
public DispatcherServletRegistrationBean dispatcherServletRegistration(DispatcherServlet dispatcherServlet,
WebMvcProperties webMvcProperties, ObjectProvider<MultipartConfigElement> multipartConfig) {
DispatcherServletRegistrationBean registration = new DispatcherServletRegistrationBean(dispatcherServlet, webMvcProperties.getServlet().getPath());
registration.setName(DEFAULT_DISPATCHER_SERVLET_BEAN_NAME);
registration.setLoadOnStartup(webMvcProperties.getServlet().getLoadOnStartup());
multipartConfig.ifAvailable(registration::setMultipartConfig);
return registration;
}
}
因此創建DispatcherServletRegistrationBean時,將從WebMvcProperties中獲取path。預設值為/,可在配置文件中設置spring.mvc.servlet.path來配置。也就是說getPrefix()返回值就是/。
@ConfigurationProperties(prefix = "spring.mvc")
public class WebMvcProperties {
// ...
private final Servlet servlet = new Servlet();
// ...
public static class Servlet {
// ...
private String path = "/";
}
// ...
}
最終在ErrorMvcAutoConfiguration的ErrorPageCustomizer的registerErrorPages()中註冊的錯誤頁面路徑為將由兩個部分構成,首碼為spring.mvc.servlet.path,而尾碼為server.error.path。前者預設值為/,後者預設值為/error。因此,經過處理後最終返回的ErrorPath的路徑為/error。
SpringBoot會通過上述的過程在StandardContext中添加一個路徑為/error的ErrorPath。當伺服器發送錯誤時,則從StandardContext中獲取到路徑為/error的ErrorPath,然後將請求轉發到/error中,然後由SpringBoot自動配置的預設Controller進行處理,返回一個Whitelabel Error Page頁面。
(三)Whitelabel Error Page視圖
SpringBoot自動配置ErrorMvcAutoConfiguration。併在@ConditionalOnMissingBean的條件下創建DefaultErrorAttributes、DefaultErrorViewResolver、BasicErrorController和View(名稱name為error)的Bean組件。
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ Servlet.class, DispatcherServlet.class })
@AutoConfigureBefore(WebMvcAutoConfiguration.class)
@EnableConfigurationProperties({ ServerProperties.class, ResourceProperties.class, WebMvcProperties.class })
public class ErrorMvcAutoConfiguration {
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
public DefaultErrorAttributes errorAttributes() {
return new DefaultErrorAttributes();
}
@Bean
@ConditionalOnMissingBean(value = ErrorController.class, search = SearchStrategy.CURRENT)
public BasicErrorController basicErrorController(ErrorAttributes errorAttributes,
ObjectProvider<ErrorViewResolver> errorViewResolvers) {
return new BasicErrorController(errorAttributes, this.serverProperties.getError(),
errorViewResolvers.orderedStream().collect(Collectors.toList()));
}
@Bean
@ConditionalOnBean(DispatcherServlet.class)
@ConditionalOnMissingBean(ErrorViewResolver.class)
DefaultErrorViewResolver conventionErrorViewResolver() {
return new DefaultErrorViewResolver(this.applicationContext, this.resourceProperties);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "server.error.whitelabel", name = "enabled", matchIfMissing = true)
@Conditional(ErrorTemplateMissingCondition.class)
protected static class WhitelabelErrorViewConfiguration {
private final StaticView defaultErrorView = new StaticView();
@Bean(name = "error")
@ConditionalOnMissingBean(name = "error")
public View defaultErrorView() {
return this.defaultErrorView;
}
}
}
BasicErrorController是一個控制器組件,映射值為${server.error.path:${error.path:/error}},與在StandardContext中註冊的ErrorPage的路徑一致。BasicErrorController提供兩個請求映射的處理方法errorHtml()和error()。errorHtml()用於處理瀏覽器訪問時返回的HTML頁面。方法內部調用getErrorAttributes()和resolveErrorView()。當無法從resolveErrorView()中獲取任何ModelAndView時,將預設返回一個名稱為error的ModelAndView。error()用於處理ajax請求時返回的響應體數據。方法內部調用getErrorAttributes()並將返回值作為響應體返回到客戶端中。
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
@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);
}
@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);
}
}
在BasicErrorController的errorHtml()中返回的是名稱為error的ModelAndView,因此Whitelabel Error Page頁面就是由於名稱為error的View提供的。在ErrorMvcAutoConfiguration已經自動配置一個名稱為error的View,具體為ErrorMvcAutoConfiguration.StaticView,它的render()方法輸出的就是Whitelabel 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());
}
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());
}
}
SpringBoot會通過上述的過程在Context中添加一個路徑為/error的ErrorPath。當伺服器發送錯誤時,則從Context中獲取到路徑為/error的ErrorPath,然後將請求轉發到/error中,然後由SpringBoot自動配置的BasicErrorController進行處理,返回一個Whitelabel Error Page頁面,並且在頁面中通常還包含timestamp、error、status、message、trace欄位信息。
(四)Whitelabel Error Page欄位
在BasicErrorController的errorHtml()和error()中,內部均調用了AbstractErrorController的ErrorAttributes欄位的getErrorAttributes()。
public abstract class AbstractErrorController implements ErrorController {
private final ErrorAttributes errorAttributes;
protected Map<String, Object> getErrorAttributes(HttpServletRequest request, ErrorAttributeOptions options) {
WebRequest webRequest = new ServletWebRequest(request);
return this.errorAttributes.getErrorAttributes(webRequest, options);
}
}
在ErrorMvcAutoConfiguration中自動配置了ErrorAttributes的Bean,即DefaultErrorAttributes。在DefaultErrorAttributes中通過getErrorAttributes()來獲取所有響應欄位。getErrorAttributes()先添加timestamp欄位,然後又調用addStatus()、addErrorDetails()、addPath()來添加其他欄位。
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DefaultErrorAttributes implements ErrorAttributes, HandlerExceptionResolver, Ordered {
@Override
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;
}
@Override
@Deprecated
public Map<String, Object> getErrorAttributes(WebRequest webRequest, boolean includeStackTrace) {
Map<String, Object> errorAttributes = new LinkedHashMap<>();
errorAttributes.put("timestamp", new Date());
addStatus(errorAttributes, webRequest);
addErrorDetails(errorAttributes, webRequest, includeStackTrace);
addPath(errorAttributes, webRequest);
return errorAttributes;
}
private void addStatus(Map<String, Object> errorAttributes, RequestAttributes requestAttributes) {
Integer status = getAttribute(requestAttributes, RequestDispatcher.ERROR_STATUS_CODE);
if (status == null) {
errorAttributes.put("status", 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);
}
}
private void addErrorDetails(Map<String, Object> errorAttributes, WebRequest webRequest,
boolean includeStackTrace) {
Throwable error = getError(webRequest);
if (error != null) {
while (error instanceof ServletException && error.getCause() != null) {
error = error.getCause();
}
errorAttributes.put("exception", error.getClass().getName());
if (includeStackTrace) {
addStackTrace(errorAttributes, error);
}
}
addErrorMessage(errorAttributes, webRequest, error);
}
private void addPath(Map<String, Object> errorAttributes, RequestAttributes requestAttributes) {
String path = getAttribute(requestAttributes, RequestDispatcher.ERROR_REQUEST_URI);
if (path != null) {
errorAttributes.put("path", path);
}
}
}
因此SpringBoot會通過上述過程,向BasicErrorController註入DefaultErrorAttributes的Bean,然後調用其getErrorAttributes()來獲取所有的欄位信息,最後通過StaticView的render()將欄位信息輸出到Whitelablel Error Page頁面中,這就是為什麼Whitelabel Error Page會出現timestamp、error、status、message、trace欄位信息的原因。
(五)底層源碼核心流程
底層源碼核心流程
- SpringBoot通過ErrorMvcAutoConfiguration的ErrorPageCustomizer的registerErrorPages()向StandardContext中添加一個路徑為/error為ErrorPage。
- 當伺服器處理請求失敗(HttpStatus錯誤、拋出異常)時,將通過StandardHostValve的custom()將請求轉發到路徑為/error的ErrorPage中。
- /error請求由BasicErrorController進行處理,通過errorHtml()返回一個StaticView,即Whitelabel Error Page。
向StandardContext添加的ErrorPage路徑和BasicErrorController處理的請求路徑均是從配置文件server.error.path中讀取的。
(六)自定義拓展
- 修改server.error.path來實現自定義的錯誤轉發路徑。
server.error.path用於配置請求處理錯誤時轉發的路徑,預設值為/error。因此我們可以修改server.error.path的值來自定義錯誤轉發路徑,然後再通過自定義的Controller來對錯誤轉發路徑進行處理。
- 繼承DefaultErrorAttributes並重寫getErrorAttributes()來實現自定義異常屬性。
在ErrorMvcAutoConfiguration中創建ErrorAttributes的Bean時使用了的@ConditionalOnMissBean註解,因此我們可以自定義一個ErrorAttributes的Bean來覆蓋預設的DefaultErrorAttributes。通常的做法是繼承DefaultErrorAttributes並重寫getErrorAttributes()來實現自定義異常屬性。
由於BasicErrorController的errorHtml()和error()內部均會調用ErrorAttributes的getErrorAttributes(),因此BasicErrorController將會調用我們自定義的ErrorAttributes的Bean的getErrorAttributes()來獲取錯誤屬性欄位。
- 繼承DefaultErrorViewResolver並重寫resolveErrorView()來實現自定義異常視圖。
BasicErrorController會調用ErrorViewResolver的resolveErrorView()來尋找合適的錯誤視圖。DefaultErrorViewResolver預設會從resources目錄中查找4xx.html、5xx.html頁面。當無法找到合適的錯誤視圖時,將自動返回一個名稱為error的視圖,此視圖由StaticView解析,也就是Whitelabel Error Page。
在ErrorMvcAutoConfiguration中創建ErrorViewResolver的Bean時使用了@ConditionalOnMissBean註解,因此我們可以自定義一個ErrorViewResolver來覆蓋預設的DefaultErrorViewResolver。通常的做法是繼承DefaultErrorViewResolver並重寫resolveErrorView()來實現自定義異常視圖。
- 實現ErrorController介面來自定義錯誤映射處理。不推薦直接繼承BasicErrorController。
在ErrorMvcAutoConfiguration中創建ErrorController的Bean時使用了@ConditionalOnMissBean註解,因此我們可以自定義一個ErrorController來覆蓋預設的BasicErrorController。通常的做法是實現ErrorController介面來自定義錯誤映射處理。具體實現時可參考AbstractErrorController和BasicErrorController。
當伺服器處理請求失敗後,底層會將請求預設轉發到/error映射中,因此我們必須提供一個處理/error請求映射的方法來保證對錯誤的處理。
在前後端分離項目中,前端與後端的交互通常是通過json字元串進行的。當伺服器請求處理異常時,我們不能返回一個Whitelabel Error Page的HTML頁面,而是返回一個友好的、統一的json字元串。為了實現這個目的,我們必須覆蓋BasicErrorController來實現在錯誤時的自定義數據返回。
// 統一響應類
@AllArgsConstructor
@Data
public static class Response<T> {
private Integer code;
private String message;
private T data;
}
// 自定義的ErrorController參考BasicErrorController、AbstractErrorController實現
@RestController
@RequestMapping("${server.error.path:${error.path:/error}}")
@RequiredArgsConstructor
@Slf4j
public static class MyErrorController implements ErrorController {
private final DefaultErrorAttributes defaultErrorAttributes;
@Override
public String getErrorPath() {
// 忽略
return null;
}
@GetMapping
public Response<Void> error(HttpServletRequest httpServletRequest) {
// 獲取預設的錯誤信息並列印異常日誌
log.warn(String.valueOf(errorAttributes(httpServletRequest)));
// 返回統一響應類
return new Response<>(-1, "error", null);
}
private Map<String, Object> errorAttributes(HttpServletRequest httpServletRequest) {
return defaultErrorAttributes.getErrorAttributes(
new ServletWebRequest(httpServletRequest),
ErrorAttributeOptions.of(
ErrorAttributeOptions.Include.EXCEPTION,
ErrorAttributeOptions.Include.STACK_TRACE,
ErrorAttributeOptions.Include.MESSAGE,
ErrorAttributeOptions.Include.BINDING_ERRORS)
);
}
}