Spring Boot 3.2如何替换Tomcat默认404页面(含上下文路径外场景)
我正在开发一个Spring Boot 3.2(基于Apache Tomcat 10)应用,已设置自定义上下文路径/api。目标是将Tomcat默认的HTML错误响应替换为JSON/仅状态码响应,但访问上下文路径外的URL(比如基础URL)时,仍返回Tomcat默认的404 HTML页面:
... <body> <h1>HTTP Status 404 – Not Found</h1> <hr class="line" /> <p><b>Type</b> Status Report</p> <p><b>Description</b> The origin server did not find a current representation for the target resource or is not willing to disclose that one exists.</p> <hr class="line" /> <h3>Apache Tomcat/10.1.19</h3> </body> ...
现有配置
全局异常处理器
@Slf4j @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) public ResponseEntity<?> handleGlobalException(Exception ex) { log.error("An error occurred: {}", ex.getMessage(), ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ApiResponse(false, ex.getMessage())); } @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity<?> handleNoResourceFoundException(NoResourceFoundException ignored) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } }
安全配置
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http.csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .exceptionHandling(exception -> exception.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.NOT_FOUND))) .authorizeHttpRequests(auth -> auth.requestMatchers(PERMIT_ALL_URL_PATTERNS.toArray(new String[0])) .permitAll() .anyRequest() .authenticated()) .sessionManagement(manager -> manager.sessionCreationPolicy(STATELESS)) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .oauth2Login(loginConfigurer -> loginConfigurer .userInfoEndpoint(endpointConfig -> endpointConfig.userService(customOAuth2UserService)) .successHandler(oAuth2AuthenticationSuccessHandler) .authorizationEndpoint(authEndPoint -> authEndPoint.authorizationRequestRepository(authorizationRequestRepository)) .failureHandler(oAuth2AuthenticationFailureHandler)) .build(); }
遇到的问题
- 上下文路径内的URL,错误处理符合预期;
- 上下文路径外的URL,返回Tomcat默认的HTML错误页面。
已尝试的方案
spring.mvc.throw-exception-if-no-handler-found:该配置已废弃,新版本Spring Boot不再抛出NoHandlerFoundException;error.whitelabel.enabled=false:无效,应用内的错误已通过全局异常处理器处理。
已编写的自定义类
CustomErrorController
import org.springframework.boot.web.servlet.error.ErrorController; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.Map; @Controller public class CustomErrorController implements ErrorController { @RequestMapping("/error") public ResponseEntity<Map<String, String>> handleError(HttpServletRequest request) { Object status = request.getAttribute("javax.servlet.error.status_code"); HttpStatus httpStatus = HttpStatus.valueOf(Integer.parseInt(status.toString())); Map<String, String> response = new HashMap<>(); response.put("error", httpStatus.getReasonPhrase()); response.put("message", "The requested URL was not found on this server."); return new ResponseEntity<>(response, httpStatus); } @Override public String getErrorPath() { return "/error"; } }
自定义ErrorReportValve
@Slf4j public class CustomTomcatErrorValve extends ErrorReportValve { @Override protected void report(Request request, Response response, Throwable throwable) { if (!response.setErrorReported()) return; if (log.isDebugEnabled()) log.debug( "Tomcat failed to prepare the request for spring (set response code to {}).", response.getStatus(), throwable); HttpStatus status = HttpStatus.valueOf(response.getStatus()); try { response.setContentType("application/problem+json"); Writer writer = response.getReporter(); writer.write(String.format( """ { "title": "%s", "status": %d }""", status.getReasonPhrase(), status.value())); response.finishResponse(); } catch (IOException ignored) { } } }
异常处理器过滤器
@Slf4j @Component public class UnhandledExceptionHandlerFilter extends OncePerRequestFilter { private static class StatusCodeCaptureWrapper extends HttpServletResponseWrapper { @Getter private Integer statusCode; @Getter private final HttpServletRequest request; @Getter private final HttpServletResponse response; public StatusCodeCaptureWrapper(HttpServletRequest request, HttpServletResponse response) { super(response); this.request = request; this.response = response; } } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException { StatusCodeCaptureWrapper responseWrapper = new StatusCodeCaptureWrapper(request, response); Throwable exception = null; try { chain.doFilter(request, responseWrapper); } catch (ServletException e) { exception = e.getRootCause(); } catch (Throwable e) { exception = e; } if (exception != null && !"ClientAbortException".equals(exception.getClass().getSimpleName())) { ensureErrorStatusCodeSet(responseWrapper); response.setStatus(responseWrapper.getStatusCode()); handleException(request, response, responseWrapper.getStatusCode(), exception); } response.flushBuffer(); } private void ensureErrorStatusCodeSet(StatusCodeCaptureWrapper responseWrapper) { if (responseWrapper.getStatusCode() == null) { responseWrapper.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } private void handleException( HttpServletRequest request, HttpServletResponse response, int statusCode, Throwable throwable) throws IOException { log.error("Sending error response status {} for {} because of", statusCode, request.getRequestURI(), throwable); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); Map<String, String> responseBody = new HashMap<>(); responseBody.put("error", HttpStatus.valueOf(statusCode).getReasonPhrase()); responseBody.put("message", throwable.getMessage()); response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody)); } }
解决方案
核心原因
上下文路径外的请求不会经过Spring MVC的DispatcherServlet,直接由Tomcat处理,所以Spring的异常处理器、ErrorController都无法拦截这类请求,只能通过Tomcat层面的配置来覆盖默认错误响应。
方案1:注册自定义ErrorReportValve到Tomcat
将你编写的CustomTomcatErrorValve注册到Tomcat容器,替代默认错误处理阀:
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TomcatConfig { @Bean public WebServerFactoryCustomizer<TomcatServletWebServerFactory> customTomcatErrorValve() { return factory -> { factory.addContextValves(new CustomTomcatErrorValve()); }; } }
该配置会将自定义错误阀添加到Tomcat上下文,所有Tomcat层面处理的错误(包括上下文路径外的404)都会生成JSON响应。
方案2:配置Tomcat全局错误页面映射
将Tomcat错误状态码映射到Spring上下文内的处理路径,让Spring接管错误响应:
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; import org.springframework.boot.web.server.ErrorPage; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpStatus; @Configuration public class TomcatConfig { @Bean public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatErrorPageCustomizer() { return factory -> { factory.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND, "/api/error")); factory.addErrorPages(new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/api/error")); // 可添加其他需要处理的状态码 }; } }
此配置将Tomcat错误请求转发到Spring上下文内的/api/error路径,由CustomErrorController处理并返回JSON响应。
方案3:调整Filter捕获所有错误状态
修改UnhandledExceptionHandlerFilter,使其能捕获无异常但返回错误状态码的请求,并提升Filter优先级:
import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.HashMap; import java.util.Map; import com.fasterxml.jackson.databind.ObjectMapper; @Slf4j @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class UnhandledExceptionHandlerFilter extends OncePerRequestFilter { private static class StatusCodeCaptureWrapper extends HttpServletResponseWrapper { @Getter private Integer statusCode; @Getter private final HttpServletRequest request; @Getter private final HttpServletResponse response; public StatusCodeCaptureWrapper(HttpServletRequest request, HttpServletResponse response) { super(response); this.request = request; this.response = response; } @Override public void setStatus(int sc) { super.setStatus(sc); this.statusCode = sc; } @Override public void sendError(int sc) throws IOException { super.sendError(sc); this.statusCode = sc; } @Override public void sendError(int sc, String msg) throws IOException { super.sendError(sc, msg); this.statusCode = sc; } } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { StatusCodeCaptureWrapper responseWrapper = new StatusCodeCaptureWrapper(request, response); Throwable exception = null; try { chain.doFilter(request, responseWrapper); // 处理无异常但返回错误状态码的情况 Integer status = responseWrapper.getStatusCode(); if (status != null && status >= 400) { handleErrorStatus(request, response, status); } } catch (ServletException e) { exception = e.getRootCause(); } catch (Throwable e) { exception = e; } if (exception != null && !"ClientAbortException".equals(exception.getClass().getSimpleName())) { ensureErrorStatusCodeSet(responseWrapper); response.setStatus(responseWrapper.getStatusCode()); handleException(request, response, responseWrapper.getStatusCode(), exception); } response.flushBuffer(); } private void ensureErrorStatusCodeSet(StatusCodeCaptureWrapper responseWrapper) { if (responseWrapper.getStatusCode() == null) { responseWrapper.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } private void handleException( HttpServletRequest request, HttpServletResponse response, int statusCode, Throwable throwable) throws IOException { log.error("Sending error response status {} for {} because of", statusCode, request.getRequestURI(), throwable); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); Map<String, String> responseBody = new HashMap<>(); responseBody.put("error", HttpStatus.valueOf(statusCode).getReasonPhrase()); responseBody.put("message", throwable.getMessage()); response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody)); } private void handleErrorStatus(HttpServletRequest request, HttpServletResponse response, int statusCode) throws IOException { log.error("Sending error response status {} for {}", statusCode, request.getRequestURI()); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); HttpStatus httpStatus = HttpStatus.valueOf(statusCode); Map<String, String> responseBody = new HashMap<>(); responseBody.put("error", httpStatus.getReasonPhrase()); responseBody.put("message", "The requested URL was not found on this server."); response.getWriter().write(new ObjectMapper().writeValueAsString(responseBody)); } }
添加@Order(Ordered.HIGHEST_PRECEDENCE)确保Filter最先执行,覆盖Tomcat默认错误处理逻辑。
验证
修改完成后,访问上下文路径外的URL(如http://localhost:8080/),将返回自定义的JSON格式404响应,而非Tomcat默认HTML页面。
内容的提问来源于stack exchange,提问作者wanderbild

