一、问题引入:自定义Undertow错误页的诡异坑

你有没有过这种经历?用Undertow做Web服务时,明明按网上教程写了自定义的404、500错误页,结果测试时要么返回默认的JSON错误信息,要么干脆还是原来的空白页?我之前踩过这个坑,折腾了大半天,最后发现问题根本不是“自定义代码写错了”,而是没搞懂Undertow内部的异常处理器注册顺序和状态码覆盖规则——这俩东西就像餐厅的上菜顺序,你以为你加的新厨师能覆盖原来的,结果原来的厨师优先级更高,还是按旧规则做。

先给大家补个基础:Undertow是Java生态里的轻量级Web服务器,很多框架(比如Spring Boot、Vert.x)底层都会用它,它的核心是用“处理器链”来处理请求——就像流水线,请求从最前面的处理器开始,一层一层往下传,每个处理器负责不同的逻辑(比如权限校验、业务处理、异常处理)。

二、核心概念拆解:异常处理器与注册顺序

要搞懂这个坑,得先把两个核心概念掰碎了说,用生活化的例子类比:异常处理器就像“投诉处理窗口”,注册顺序就像“窗口的优先级”——优先级高的窗口,会先处理投诉,优先级低的只能捡漏。

2.1 异常处理器的本质:Undertow的“投诉处理窗口”

Undertow的异常处理器有好几种,最常用的是两种:

  • 全局异常处理器:对应“总投诉窗口”,不管哪个业务出问题,都会先找它;
  • 状态码专属异常处理器:对应“专项投诉窗口”,比如专门处理404的窗口、专门处理500的窗口。

这俩窗口的区别,就像“总服务台”和“某品牌专柜的投诉窗口”——总服务台能处理所有问题,但专柜窗口只能处理自己品牌的问题,不过如果专柜窗口优先级更高,用户投诉时会先找专柜,而不是总服务台。

2.2 注册顺序的优先级:后注册的处理器优先级更高?

很多人以为“先注册的处理器优先级高”,但Undertow的处理器链是“后进先出”的——也就是后注册的处理器会排在处理器链的前面,优先级更高。这个规则是最容易踩坑的点,我用一个餐厅的例子类比: 假设餐厅的流水线是“切菜→炒菜→上菜”,如果我后来加了一个“试菜”的步骤,加在“炒菜”后面,那么流水线就变成“切菜→炒菜→试菜→上菜”;但如果我是“插队”加的(后注册),就会变成“试菜→切菜→炒菜→上菜”——试菜步骤优先级最高,先执行。

Undertow的处理器链也是这个逻辑:你后注册的异常处理器,会排在所有已注册处理器的前面,先被调用。

三、完整示例:复现“自定义错误页不生效”的问题

为了让大家更清楚,我做一个完整的示例,技术栈统一用“Undertow + Java 8”,所有代码都带注释。

3.1 基础代码:先写一个简单的Undertow服务

先写一个最基础的Undertow服务,模拟业务处理:

import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.util.Headers;
import io.undertow.util.StatusCodes;

public class UndertowErrorDemo {
    public static void main(String[] args) {
        // 构建Undertow服务,监听8080端口
        Undertow server = Undertow.builder()
                .addHttpListener(8080, "localhost")
                // 先注册基础业务处理器:处理正常请求和异常
                .setHandler(new HttpHandler() {
                    @Override
                    public void handleRequest(HttpServerExchange exchange) throws Exception {
                        // 模拟两种情况:1. 正常请求;2. 触发404异常
                        String path = exchange.getRequestPath();
                        if ("/test".equals(path)) {
                            // 正常请求:返回成功内容
                            exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain");
                            exchange.getResponseSender().send("请求成功!");
                        } else {
                            // 模拟触发404:设置状态码,然后抛出异常
                            exchange.setStatusCode(StatusCodes.NOT_FOUND);
                            throw new RuntimeException("页面不存在!");
                        }
                    }
                })
                .build();
        server.start();
        System.out.println("服务启动,访问http://localhost:8080/test测试,访问其他路径触发404");
    }
}

这段代码的逻辑很简单:访问/test返回成功,访问其他路径会触发404异常。现在运行这段代码,访问其他路径,会得到什么结果? ——你会得到Undertow默认的JSON错误页,比如:

{"errorMessage":"页面不存在!","errorCode":404}

3.2 尝试自定义错误页:错误的注册顺序

现在我们尝试自定义404错误页,比如返回一个简单的HTML页面。很多人会这么写(错误的注册顺序):

import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.error.SimpleErrorPageHandler;
import io.undertow.util.Headers;
import io.undertow.util.StatusCodes;

public class UndertowErrorDemo {
    public static void main(String[] args) {
        Undertow server = Undertow.builder()
                .addHttpListener(8080, "localhost")
                // 错误1:先注册自定义错误处理器
                .setHandler(new SimpleErrorPageHandler()
                        // 为404状态码自定义错误内容
                        .setErrorPage(StatusCodes.NOT_FOUND, "<h1>我是自定义的404错误页</h1>"))
                // 错误2:再注册基础业务处理器(相当于把业务处理器排在自定义错误处理器前面)
                .setHandler(new HttpHandler() {
                    @Override
                    public void handleRequest(HttpServerExchange exchange) throws Exception {
                        String path = exchange.getRequestPath();
                        if ("/test".equals(path)) {
                            exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain");
                            exchange.getResponseSender().send("请求成功!");
                        } else {
                            exchange.setStatusCode(StatusCodes.NOT_FOUND);
                            throw new RuntimeException("页面不存在!");
                        }
                    }
                })
                .build();
        server.start();
        System.out.println("服务启动,访问http://localhost:8080/test测试,访问其他路径触发404");
    }
}

现在运行这段代码,访问其他路径,你会发现——还是返回默认的JSON错误页!这是为什么? 因为Undertow的setHandler方法是“覆盖”的吗?不对,其实是Undertow内部的处理器链构建逻辑:当你调用setHandler时,如果你传入的是一个异常处理器,Undertow会把它加到处理器链的前面;如果你传入的是业务处理器,会把它加到后面?不对,更准确的是:Undertow的处理器链是一个单向链表,每次调用setHandler,都会把新的处理器作为“根处理器”,原来的处理器作为新处理器的“下一个处理器”。

哦,原来如此!也就是说,你第二次调用setHandler时,传入的业务处理器会成为新的根处理器,原来的自定义错误处理器会被“挤到后面”。所以当请求触发异常时,会先经过业务处理器,业务处理器抛出异常后,会沿着处理器链往后找异常处理器,结果自定义错误处理器在后面,所以还是会触发Undertow默认的异常处理器。

3.3 正确的注册顺序:自定义错误处理器排在业务处理器前面

那怎么改?很简单,把自定义错误处理器和业务处理器的注册顺序反过来,并且明确指定业务处理器是自定义错误处理器的“下一个处理器”:

import io.undertow.Undertow;
import io.undertow.server.HttpHandler;
import io.undertow.server.HttpServerExchange;
import io.undertow.server.handlers.error.SimpleErrorPageHandler;
import io.undertow.util.Headers;
import io.undertow.util.StatusCodes;

public class UndertowErrorDemo {
    public static void main(String[] args) {
        // 第一步:先定义基础业务处理器
        HttpHandler businessHandler = new HttpHandler() {
            @Override
            public void handleRequest(HttpServerExchange exchange) throws Exception {
                String path = exchange.getRequestPath();
                if ("/test".equals(path)) {
                    exchange.getResponseHeaders().put(Headers.CONTENT_TYPE, "text/plain");
                    exchange.getResponseSender().send("请求成功!");
                } else {
                    exchange.setStatusCode(StatusCodes.NOT_FOUND);
                    throw new RuntimeException("页面不存在!");
                }
            }
        };

        // 第二步:定义自定义错误处理器,把业务处理器作为它的下一个处理器
        SimpleErrorPageHandler customErrorHandler = new SimpleErrorPageHandler(businessHandler);
        // 为404状态码自定义错误内容,注意要设置Content-Type为text/html
        customErrorHandler.setErrorPage(StatusCodes.NOT_FOUND, "<h1>我是自定义的404错误页</h1>");
        // 可选:设置所有异常的默认自定义错误页(如果某个状态码没单独定义,就用这个)
        customErrorHandler.setDefaultErrorPage("<h1>服务器出错啦,请稍后再试</h1>");

        // 第三步:把自定义错误处理器作为Undertow的根处理器
        Undertow server = Undertow.builder()
                .addHttpListener(8080, "localhost")
                .setHandler(customErrorHandler)
                .build();
        server.start();
        System.out.println("服务启动,访问http://localhost:8080/test测试,访问其他路径触发404");
    }
}

现在运行这段代码,访问其他路径,你会得到自定义的404错误页:<h1>我是自定义的404错误页</h1>,而不是默认的JSON了!

3.4 状态码覆盖规则:自定义状态码的优先级高于默认

刚才的示例中,我们为404单独定义了错误页,那如果某个状态码没有单独定义,会怎么样?比如我们触发500异常:

// 把业务处理器里的else部分改成触发500
else {
    exchange.setStatusCode(StatusCodes.INTERNAL_SERVER_ERROR);
    throw new RuntimeException("服务器内部错误!");
}

这时候访问其他路径,会得到自定义的默认错误页:<h1>服务器出错啦,请稍后再试</h1>,因为500没有单独定义,所以用了setDefaultErrorPage的内容。

那如果我们同时定义了全局异常处理器和状态码专属异常处理器,哪个会生效?比如我们定义一个全局异常处理器(处理所有状态码),再定义一个404的专属异常处理器,那么404的专属异常处理器会生效,因为状态码专属的处理器优先级高于全局处理器——就像“专柜投诉窗口”优先级高于“总投诉窗口”。

四、应用场景、优缺点与注意事项

4.1 应用场景

  • 对外服务的Web应用:需要统一的错误页风格,提升用户体验;
  • 内部管理系统:需要隐藏技术细节(比如默认JSON里的堆栈信息),避免泄露敏感信息;
  • 微服务网关:需要统一的错误响应格式,方便前端处理。

4.2 技术优缺点

  • 优点:
    1. 灵活:可以为不同的状态码定义不同的错误内容,甚至可以根据异常类型定义不同的错误页;
    2. 安全:可以隐藏默认响应中的敏感信息(比如堆栈跟踪、内部错误码);
    3. 统一:可以全局统一错误处理逻辑,不用在每个业务方法里单独处理异常。
  • 缺点:
    1. 学习成本:需要理解Undertow的处理器链和注册顺序,容易踩坑;
    2. 调试难度:如果自定义错误页不生效,需要排查注册顺序、状态码匹配、处理器链结构等多个问题;
    3. 兼容性:如果用的是基于Undertow的框架(比如Spring Boot),可能会有框架自带的异常处理器,需要调整框架的配置来覆盖。

4.3 注意事项

  • 注册顺序:自定义错误处理器必须排在业务处理器的前面,否则不会生效;
  • 状态码匹配:自定义错误页时,要确保状态码和业务中设置的状态码一致,比如业务中设置的是404,自定义时也要对应404;
  • 内容类型:如果自定义的是HTML错误页,要设置Content-Type为text/html,否则浏览器可能会把它当成文本显示;
  • 框架集成:如果用的是Spring Boot,需要注意Spring Boot自带的ErrorMvcAutoConfiguration,可以通过配置server.error.whitelabel.enabled=false来关闭,然后自定义自己的错误处理器;
  • 异常类型:如果需要根据异常类型自定义错误页(比如业务异常返回友好提示,系统异常返回统一错误),可以用ExceptionHandler来实现,而不是只靠状态码。

五、文章总结

自定义Undertow错误页时返回默认JSON的问题,本质上是没搞懂两个核心规则:

  1. 注册顺序:Undertow的处理器链是后进先出的,自定义错误处理器必须排在业务处理器的前面,才能在业务抛出异常时被调用;
  2. 状态码覆盖:状态码专属的异常处理器优先级高于全局处理器,全局处理器优先级高于Undertow默认的异常处理器。

解决这个问题的步骤可以总结为:

  1. 先定义业务处理器;
  2. 再定义自定义错误处理器,把业务处理器作为它的下一个处理器;
  3. 把自定义错误处理器作为Undertow的根处理器;
  4. 为需要的状态码定义专属错误页,没有定义的用默认错误页。

只要搞懂了这两个规则,再遇到自定义错误页不生效的问题,就可以一步步排查:先看注册顺序对不对,再看状态码匹配对不对,最后看有没有其他优先级更高的处理器(比如框架自带的)在起作用。