You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot 3.2如何替换Tomcat默认404页面(含上下文路径外场景)

问题:Spring Boot 3.2 + Tomcat 10 上下文路径外404返回默认HTML页面

我正在开发一个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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.24 03:53:11