百木园-与人分享,
就是让自己快乐。

深入理解Whitelabel Error Page底层源码

深入理解Whitelabel Error Page底层源码

(一)服务器请求处理错误则转发请求url

StandardHostValveinvoke()方法将根据请求的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()具体由是由TomcatServletWebServerFactoryconfigureContext()方法来调用的。

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()获取所有错误页面,然后再调用ContextaddErrorPage()来添加ErrorPage错误页面。

getErrorPages()中的错误页面是通过AbstractConfigurableWebServerFactoryaddErrorPages()来添加的。

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()实际上是由ErrorMvcAutoConfigurationErrorPageCustomizerregisterErrorPages()调用的。

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\";
        // ...
    }
}

然后调用DispatcherServletPathgetRelativePath()来构建错误页面的完整路径。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的条件下创建DefaultErrorAttributesDefaultErrorViewResolverBasicErrorControllerView(名称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的ModelAndViewerror()用于处理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);
	}
}

BasicErrorControllererrorHtml()中返回的是名称为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字段

BasicErrorControllererrorHtml()error()中,内部均调用了AbstractErrorControllerErrorAttributes字段的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字段信息的原因。

(五)底层源码核心流程

底层源码核心流程

  1. SpringBoot通过ErrorMvcAutoConfiguration的ErrorPageCustomizer的registerErrorPages()向StandardContext中添加一个路径为/error为ErrorPage。
  2. 当服务器处理请求失败(HttpStatus错误、抛出异常)时,将通过StandardHostValve的custom()将请求转发到路径为/error的ErrorPage中。
  3. /error请求由BasicErrorController进行处理,通过errorHtml()返回一个StaticView,即Whitelabel Error Page。

向StandardContext添加的ErrorPage路径和BasicErrorController处理的请求路径均是从配置文件server.error.path中读取的。

(六)自定义拓展

  1. 修改server.error.path来实现自定义的错误转发路径。

server.error.path用于配置请求处理错误时转发的路径,默认值为/error。因此我们可以修改server.error.path的值来自定义错误转发路径,然后再通过自定义的Controller来对错误转发路径进行处理。

  1. 继承DefaultErrorAttributes并重写getErrorAttributes()来实现自定义异常属性。

在ErrorMvcAutoConfiguration中创建ErrorAttributes的Bean时使用了的@ConditionalOnMissBean注解,因此我们可以自定义一个ErrorAttributes的Bean来覆盖默认的DefaultErrorAttributes。通常的做法是继承DefaultErrorAttributes并重写getErrorAttributes()来实现自定义异常属性。

由于BasicErrorController的errorHtml()和error()内部均会调用ErrorAttributes的getErrorAttributes(),因此BasicErrorController将会调用我们自定义的ErrorAttributes的Bean的getErrorAttributes()来获取错误属性字段。

  1. 继承DefaultErrorViewResolver并重写resolveErrorView()来实现自定义异常视图。

BasicErrorController会调用ErrorViewResolver的resolveErrorView()来寻找合适的错误视图。DefaultErrorViewResolver默认会从resources目录中查找4xx.html、5xx.html页面。当无法找到合适的错误视图时,将自动返回一个名称为error的视图,此视图由StaticView解析,也就是Whitelabel Error Page。

在ErrorMvcAutoConfiguration中创建ErrorViewResolver的Bean时使用了@ConditionalOnMissBean注解,因此我们可以自定义一个ErrorViewResolver来覆盖默认的DefaultErrorViewResolver。通常的做法是继承DefaultErrorViewResolver并重写resolveErrorView()来实现自定义异常视图。

  1. 实现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)
        );
    }
}

来源:https://www.cnblogs.com/kkelin/p/16978260.html
本站部分图文来源于网络,如有侵权请联系删除。

未经允许不得转载:百木园 » 深入理解Whitelabel Error Page底层源码

相关推荐

  • 暂无文章