基于Spring官方文档完成全局配置化异常处理实践

结合@ControllerAdvice 穷举 @ExceptionHandler 对异常分类处理是当前企业使用 Spring 框架开发 Web 项目的常见做法,但这么做却偏向于硬编码,本文结合官方使用手册予以探究并应用。

一、前言

(一)手册说明

Spring 5.2.10 使用手册 中 1.3.6. Exceptions REST API exceptions 部分内容如图:

其中提到:

Applications that implement global exception handling with error details

in the response body should consider extending ResponseEntityExceptionHandler,

which provides handling for exceptions that Spring MVC raises and provides hooks

to customize the response body. To make use of this, create a subclass of

ResponseEntityExceptionHandler, annotate it with @ControllerAdvice,

override the necessary methods, and declare it as a Spring bean.

直译如下:

在响应主体中实现错误详细信息的全局异常处理的应用程序应该考虑扩展 ResponseEntityExceptionHandler, 它为 Spring MVC 引发的异常提供处理,并提供钩子来自定义响应主体。 要利用这一点,请创建 ResponseEntityExceptionHandler 的子类,用 @ControllerAdvice 对其进行注释, 覆盖必要的方法,并将其声明为 Spring Bean。

综上所述,官网手册推荐的是通过继承ResponseEntityExceptionHandler,并用@ControllerAdvice对其进行注释 来完成对 Web 应用中 RESTFUL 风格接口的全局异常处理。

(二)注释内容

org.springframework:spring-webmvc:5.2.10.RELEASE中我们找到ResponseEntityExceptionHandler类,如下:

其中提到:

This base class provides an @ExceptionHandler method for handling internal Spring MVC exceptions.

直译如下:

该基类提供了一个 @ExceptionHandler 方法,用于处理内部 Spring MVC 异常。

这个@ExceptionHandler方法,便是在其方法handleException()上修饰,其中还专门以final字段修饰,且在最后留下了注释:

Unknown exception, typically a wrapper with a common MVC exception as cause (since @ExceptionHandler type declarations also match first-level causes): We only deal with top-level MVC exceptions here, so let's rethrow the given exception for further processing through the HandlerExceptionResolver chain.

直译如下:

未知异常,通常是一个以常见 MVC 异常为原因的包装(因为 @ExceptionHandler 类型声明也与一级原因匹配): 我们在这里只处理顶级 MVC 异常,所以让我们重新抛出给定的异常,以便通过 HandlerExceptionResolver 链进一步处理。

综上所述,我们可以得到如下信息:

  1. ResponseEntityExceptionHandler 专用于 Spring MVC 自身的顶级异常,如需变更请重写;
  2. 处理方法不能被继承等方式修改

所以开发的应用未来在继承ResponseEntityExceptionHandler时需要像它一样专用于捕捉应用的内部异常, 换句话说,需要定义好这个应用的顶级异常有哪些,且不能被轻易修改。

二、思想与设计

(一)异常定义

1. 兼容 Spring MVC 内部异常

因为应用就是基于 Spring MVC 进行开发的,所以需要兼容,而兼容方式也非常简单,继承并用 @ControllerAdvice 对其进行注释再声明为Bean即可自定义处理,正如手册中提到的一样, ResponseEntityExceptionHandler 提供了勾子(继承重写)帮助我们来实现。

2. 应用的顶级异常

对于 Web 应用而言,本质上就是建立在"请求-响应协议"之上进行交互的应用,因此我们可以直接参考HTTP状态码, 直接定义出如下异常种类:

  • 应用异常(WebApplicationException)
    • 客户端异常(ClientException)
      • 非法操作异常(IllegalOperationException)
      • 请求参数异常(RequestParamsException)
      • 重复请求异常(RequestRepeatException)
      • 更多可扩展(More······)
    • 服务端异常(ServerException)
      • 业务异常(BusinessException)
        • 业务数据异常(BusinessDataException)
          • 没有这样的业务数据异常(NoSuchBusinessDataException)
      • 配置异常(ConfigurationException)
      • 第三方异常(ThirdPartyException)
      • 内部API异常(InternalApiException)
      • 更多可扩展(More······)

上述异常中,定义了应用异常(WebApplicationException)作为应用的顶级异常,在继承ResponseEntityExceptionHandler时 需要专门为其处理。

3. 非预期异常

应用内部中,往往存在开发人员未注意到代码细节而抛出异常,以除 0 为例:

java 复制代码
@Override  
public void unexpected() {  
    BigDecimal paidAmount = new BigDecimal(100);  
    for (int i = 0; i < 100; i++) {  
        paidAmount.divide(BigDecimal.valueOf(i));  
    }  
}  

像这种情况,我们需要和ResponseEntityExceptionHandler一样,在最后一个 else 处理这些异常, 以便通过 HandlerExceptionResolver 链进一步处理。

因此我们必须规定,开发人员抛出的异常只能是应用异常(WebApplicationException)的子类

(二)继承实现

综上(一)所述,继承ResponseEntityExceptionHandler,内容如下:

java 复制代码
@ControllerAdvice  
public class ApplicationResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {  

    @ExceptionHandler({  
        Throwable.class  
    })  
    public final ResponseEntity<Object> handleApplicationException(Throwable ex) {  
        logger.error("An exception is caught, the detailed content is as follows:", ex);  

        if (ex instanceof WebApplicationException) {  
            return handleWebApplicationException((WebApplicationException) wae);  
        } else {  
            return handleUnexpectedException(ex);  
        }  
    }  

    // Spring MVC internal exceptions  
    @Override  
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(  
        HttpRequestMethodNotSupportedException ex, HttpHeaders headers,  
        HttpStatus status, WebRequest request) {  
        // Compatibility processing  
    }  

    // more  

}  
  

特别地,与ResponseEntityExceptionHandler不同的是,ApplicationResponseEntityExceptionHandler@ExceptionHandler捕捉的是Throwable.class,是因为可能出现3.非预期异常部分提到的异常种类是多且不确定的。

三、配置化异常处理

在上述内容中,仍然存在以下问题:

  1. 发生异常时对外响应的内容仍然是硬编码,例如在测试环境中,希望直接将异常的详细原因响应出来方便查找, 而在生产环境中,却不希望将这些堆栈展示于客户端中;
  2. 有些常见的非预期异常需要进行控制,如NullPointerExceptionIOExceptionSQLException等。

针对上述情况,希望将异常处理进行配置化,因此可以结合@ConfigurationProperties定义配置属性,目的是 可以通过.yaml.properties文件配置完成,如下:

yaml 复制代码
spring:  
  application:  
    web:  
      expception:  
        response:  
          server:  
            exposure: true  
          unexpected:  
            http-status: 500  
            body-status: 5009999  
            exposure: true  
          configs:  
            "[java.sql.SQLException]":  
              http-status: 500  
              body-status: 5000000  
              body-message: 服务器开小差,请稍后再试...  

因此,ApplicationResponseEntityExceptionHandler的代码可以修改为:

java 复制代码
@ControllerAdvice  
public class ApplicationResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {  
  
    @ExceptionHandler({  
        Throwable.class  
    })  
    public final ResponseEntity<Object> handleApplicationException(Throwable ex) {  
        logger.error("An exception is caught, the detailed content is as follows:", ex);  

        if (ex instanceof WebApplicationException) {  
            return handleWebApplicationException((WebApplicationException) wae);  
        } else if (isConfigured(ex)) {  
            return handleConfiguredException(ex);  
        } else {  
            return handleUnexpectedException(ex);  
        }  
    }  

    // Spring MVC internal exceptions  
    @Override  
    protected ResponseEntity<Object> handleHttpRequestMethodNotSupported(  
        HttpRequestMethodNotSupportedException ex, HttpHeaders headers,  
        HttpStatus status, WebRequest request) {  
        // Compatibility processing  
    }  

    // more  
  
}  

四、实践与应用

(一)顶层异常

参考内容 二、思想与设计(一)异常定义 2. 应用的顶级异常 定义如下异常种类:

(二)属性配置

定义WebExceptionResponseProperties如下:

java 复制代码
package io.github.rovingsea.boot.web;  
  
import io.github.rovingsea.boot.web.exception.ServerException;  
import io.github.rovingsea.boot.web.exception.WebException;  
import lombok.Data;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.boot.context.properties.ConfigurationProperties;  
import org.springframework.http.HttpStatus;  
  
import java.util.LinkedHashMap;  
import java.util.Map;  
  
  
/**  
* <p>  
* Web 项目异常响应配置  
* </p>  
*  
* @author wuhaixin  
* @version 2023-11-17  
* @since 2023-11-17  
*/  
@Data  
@Slf4j  
@ConfigurationProperties(prefix = WebExceptionResponseProperties.PREFIX)  
public class WebExceptionResponseProperties {  
  
    public static final String PREFIX = "spring.application.web.exception.response";  

    public static final int SERVER_CODE = 5000000;  

    public static final int UNEXPECTED_CODE = 5009999;  
  
    public static final String UNEXPECTED_MESSAGE = "服务器开小差,请稍后再试...";  

    public WebExceptionResponseProperties() {  
        this.server = new Server();  
        this.unexpected = new Unexpected();  
        this.configs = new LinkedHashMap<>();  
    }  

    /**  
    * 关于 {@link ServerException} 的配置,当发生时,会根据配置的内容进行响应  
    */  
    private Server server;  
    /**  
    * 关于非预期异常(不是手动抛出来的异常)的配置,当发生时,会根据配置的内容进行响应  
    */  
    private Unexpected unexpected;  
    /**  
    * 手动配置哪些异常的响应内容  
    * <ul>  
    * <li>key: 类的全路径名</li>  
    * <li>value: {@link ResponseConfig}</li>  
    * </ul>  
    */  
    private Map<String, ResponseConfig> configs;  

    /**  
    * 根据类全路径名获取对应的配置,如果没有这个配置则会默认返回设置服务异常的配置  
    *  
    * @param className 类的全路径名  
    * @return 配置  
    * @see ResponseConfig  
    */  
    public ResponseConfig getConfigByClassName(String className) {  
        ResponseConfig responseConfig = configs.get(className);  
        if (responseConfig == null) {  
            log.warn("No exception class [" + className + "] configured.");  
            responseConfig = new ResponseConfig();  
            responseConfig.setHttpStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());  
            responseConfig.setBodyStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());  
            responseConfig.setBodyMessage(WebException.DEFAULT_SERVER_MESSAGE);  
        }  
        return responseConfig;  
    }  

    /**  
    * {@link ServerException} 响应配置  
    */  
    public static class Server {  

    /**  
    * 发生内部服务异常时对外展示的内容  
    */  
    private String displayMessage;  
    /**  
    * 是否将内部异常信息外曝,如果是,请结合 {@link #displayMessage} 一起使用  
    */  
    private boolean exposure;  

    public Server() {  
        this.displayMessage = UNEXPECTED_MESSAGE;  
        this.exposure = false;  
    }  

    public Server(String bodyMessage, boolean exposure) {  
        this.displayMessage = bodyMessage;  
        this.exposure = exposure;  
    }  

    public String getDisplayMessage() {  
        return displayMessage;  
    }  

    public void setDisplayMessage(String displayMessage) {  
        this.displayMessage = displayMessage;  
    }  

    public boolean isExposure() {  
        return exposure;  
    }  

    public void setExposure(boolean exposure) {  
    this.exposure = exposure;  
    }  
    }  

    /**  
    * 非预期异常(可认为非手动抛出的异常)配置,  
    */  
    public static class Unexpected {  
    /**  
    * 响应状态码  
    *  
    * @see HttpStatus  
    */  
    private int httpStatus;  
    /**  
    * 响应体 code  
    */  
    private int bodyStatus;  
    /**  
    * 响应体 message  
    */  
    private String bodyMessage;  
    /**  
    * 是否将内部异常信息外曝  
    */  
    private boolean exposure;  

    public Unexpected() {  
        this.httpStatus = HttpStatus.INTERNAL_SERVER_ERROR.value();  
        this.bodyStatus = UNEXPECTED_CODE;  
        this.bodyMessage = UNEXPECTED_MESSAGE;  
        this.exposure = false;  
    }  

    public Unexpected(int httpStatus, int bodyStatus, String bodyMessage, boolean exposure) {  
        this.httpStatus = httpStatus;  
        this.bodyStatus = bodyStatus;  
        this.bodyMessage = bodyMessage;  
        this.exposure = exposure;  
    }  

    public int getHttpStatus() {  
        return httpStatus;  
    }  

    public void setHttpStatus(int httpStatus) {  
        this.httpStatus = httpStatus;  
    }  

    public int getBodyStatus() {  
        return bodyStatus;  
    }  

    public void setBodyStatus(int bodyStatus) {  
        this.bodyStatus = bodyStatus;  
    }  

    public String getBodyMessage() {  
        return bodyMessage;  
    }  

    public void setBodyMessage(String bodyMessage) {  
        this.bodyMessage = bodyMessage;  
    }  

    public boolean isExposure() {  
        return exposure;  
    }  

    public void setExposure(boolean exposure) {  
        this.exposure = exposure;  
        }  
    }  

    /**  
    * 指定异常的响应配置  
    */  
    @Data  
    public static class ResponseConfig {  

    /**  
    * 响应的状态码  
    */  
    private int httpStatus;  
    /**  
    * 响应体中的 status  
    */  
    private int bodyStatus;  
    /**  
    * 响应体中的 message  
    */  
    private String bodyMessage;  
    /**  
    * 是否将异常 message 暴露(即作为响应体中的 message)出去?  
    * <ul>  
    * <li>true 为暴露 </li>  
    * <li>false 为不暴露 </li>  
    * </ul>  
    * 默认为 false  
    */  
    private boolean exposure;  

    }  

  
}  
  

(三)异常处理器

定义WebResponseEntityExceptionHandler如下:

java 复制代码
package io.github.rovingsea.boot.web.exception;  
  
import io.github.rovingsea.boot.web.WebExceptionResponseProperties;  
import io.github.rovingsea.common.util.StringUtils;  
import io.github.rovingsea.boot.web.share.protocol.ResponseInteBean;  
import org.apache.commons.logging.Log;  
import org.apache.commons.logging.LogFactory;  
import org.springframework.http.HttpHeaders;  
import org.springframework.http.HttpStatus;  
import org.springframework.http.ResponseEntity;  
import org.springframework.http.converter.HttpMessageNotReadableException;  
import org.springframework.validation.BindException;  
import org.springframework.validation.BindingResult;  
import org.springframework.validation.ObjectError;  
import org.springframework.web.bind.MethodArgumentNotValidException;  
import org.springframework.web.bind.annotation.ExceptionHandler;  
import org.springframework.web.bind.annotation.RestControllerAdvice;  
import org.springframework.web.context.request.WebRequest;  
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;  
  
import java.util.ArrayList;  
import java.util.List;  
import java.util.Map;  
  
/**  
* <p>  
*   异常处理器,捕获在系统中发生的所有异常,对异常有如下几种处理方式:  
*   <ol>  
*     <li>{@link WebException} 子类异常:如果是 {@link ClientException} 那么就直接将异常信息外曝;  
* 如果是 {@link ServerException} 那么还会依据 {@link WebExceptionResponseProperties#getServer()} 中的配置进行处理。</li>  
*     <li>经 {@link WebExceptionResponseProperties#getConfigs()} 配置过的异常:那么会根据配置进行处理。 </li>  
*     <li>未经配置也未不属于 <i>WebException</i> 异常,那么将会归属为非预期异常,处理的方式可根据配置  
* {@link WebExceptionResponseProperties#getUnexpected()} 进行处理。</li>  
*   </ol>  
* <p>  
*   本次升级主要有如下几点:  
*   <ol>  
*     <li>取消穷举异常和使用 <i>@ExceptionHandler</i> 的方式,转变为配置化。原因是随着项目扩展,定制化的异常的捕捉需要重新维护。</li>  
*     <li>结合状态码使用。原因是先前的响应状态码仅有 200 和 500,针对参数校验部分需要进行提示为 400,避免个别情况误判给后端开发。</li>  
*   </ol>  
* </p>  
* <p>  
* <b>本处理器兼容了 Spring MVC 的顶层异常</b>(具体可看该方法 {@link ResponseEntityExceptionHandler#handleException}) ,  
* 针对这些异常,默认在进行了配置,具体做法可见下述继承重写的方法,配置内容可参见案例模块中的<i> application.yaml </i>文件。<br>  
* <b>特别地,</b> 对于 {@link MethodArgumentNotValidException} 作为参数校验异常,该异常需要将校验的信息外曝,  
* 所以此处未进行配置而是通过重写方法,具体见 {@link #handleMethodArgumentNotValid(MethodArgumentNotValidException, HttpHeaders, HttpStatus, WebRequest)}  
* </p>  
* <p>  
* 如下是配置 <i>java.sql.SQLException</i> 的案例:<pre>{@code  
* spring:  
*   application:  
*     web:  
*       exception:  
*         response:  
*           configs:  
*             "[java.sql.SQLException]":  
*               http-status: 500  
*               body-status: 5000000  
*               body-message: 服务器开小差,请稍后再试...  
* }</pre>  
* 如再添加则按上述案例添加即可。  
* </p>  
*  
* @author wuhaixin  
* @version 2023-11-16  
* @see WebException  
* @see WebExceptionResponseProperties  
* @since 2023-11-16  
*/  
@RestControllerAdvice  
public class WebResponseEntityExceptionHandler extends ResponseEntityExceptionHandler {  

    protected final Log logger = LogFactory.getLog(getClass());  

    private final WebExceptionResponseProperties properties;  

    public WebResponseEntityExceptionHandler(WebExceptionResponseProperties webExceptionResponseProperties) {  
        this.properties = webExceptionResponseProperties;  
        logger.info("全局异常处理注册完毕!");  
    }  

    @ExceptionHandler({  
        Throwable.class  
    })  
    public final ResponseEntity<ResponseInteBean<Void>> wsjHandleException(Throwable ex) {  
        logger.error("An exception is caught, the detailed content is as follows:", ex);  

        if (ex instanceof WebException) {  
            return handleWebException((WebException) ex);  
        } else if (isConfigured(ex)) {  
            return handleConfiguredException(ex);  
        } else {  
            return handleUnexpectedException(ex);  
        }  

    }  

    private ResponseEntity<ResponseInteBean<Void>> handleWebException(WebException ex) {  
        ExceptionResponse response = buildWebException(ex);  
        ExceptionResponse.Body body = response.getBody();  
        return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
    }  

    private boolean isConfigured(Throwable ex) {  
        Map<String, WebExceptionResponseProperties.ResponseConfig> configs = properties.getConfigs();  
        String canonicalName = ex.getClass().getCanonicalName();  
        return configs.containsKey(canonicalName);  
    }  

    private ResponseEntity<ResponseInteBean<Void>> handleConfiguredException(Throwable ex) {  
        Map<String, WebExceptionResponseProperties.ResponseConfig> configs = properties.getConfigs();  
        String canonicalName = ex.getClass().getCanonicalName();  

        WebExceptionResponseProperties.ResponseConfig config = configs.get(canonicalName);  
        ExceptionResponse response = new ExceptionResponse(config, ex);  
        ExceptionResponse.Body body = response.getBody();  
        return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
    }  

    private ResponseEntity<ResponseInteBean<Void>> handleUnexpectedException(Throwable ex) {  
        ExceptionResponse response = buildUnexpectedException(ex);  
        ExceptionResponse.Body body = response.getBody();  
        return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
    }  


    private ExceptionResponse buildWebException(WebException webException) {  
        WebExceptionResponseProperties.Server server = properties.getServer();  

        ExceptionResponse response = new ExceptionResponse();  
        ExceptionResponse.Body body = new ExceptionResponse.Body();  

        body.setStatus(webException.getBodyStatus());  

        if (webException instanceof ClientException) {  
            body.setMessage(webException.getBodyMessage());  
        } else if (webException instanceof ServerException) {  
            if (server.isExposure()) {  
                body.setMessage(webException.getBodyMessage());  
            } else {  
                body.setMessage(server.getDisplayMessage());  
            }  
        }  

        response.setHttpStatus(webException.getHttpStatus());  
        response.setBody(body);  

        return response;  
    }  

    private ExceptionResponse buildUnexpectedException(Throwable ex) {  
        ExceptionResponse response = new ExceptionResponse();  
        ExceptionResponse.Body body = new ExceptionResponse.Body();  

        WebExceptionResponseProperties.Unexpected unexpected = properties.getUnexpected();  
        int httpStatus = unexpected.getHttpStatus();  
        int bodyStatus = unexpected.getBodyStatus();  
        String bodyMessage = unexpected.getBodyMessage();  
        boolean exposure = unexpected.isExposure();  

        body.setStatus(bodyStatus);  
        if (exposure) {  
        body.setMessage(ex.getMessage());  
        } else {  
        body.setMessage(bodyMessage);  
        }  

        response.setHttpStatus(httpStatus);  
        response.setBody(body);  

        return response;  
    }  

    @Override  
    protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {  
        return super.handleHttpMessageNotReadable(ex, headers, status, request);  
    }  

    @Override  
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {  
        ExceptionResponse.Body body;  
        List<String> messages;  
        BindingResult bindingResult = ex.getBindingResult();  

        List<ObjectError> allErrors = bindingResult.getAllErrors();  
        if (allErrors.size() != 0) {  
            messages = new ArrayList<>();  
            for (ObjectError error : allErrors) {  
                messages.add(error.getDefaultMessage());  
            }  
            body = new ExceptionResponse.Body(400, String.join(";", messages));  
            return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
        }  

        WebExceptionResponseProperties.ResponseConfig config = properties.getConfigByClassName(ex.getClass().getCanonicalName());  
        ExceptionResponse response = new ExceptionResponse(config, ex);  
        body = response.getBody();  
        return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
    }  

    @Override  
    protected ResponseEntity<Object> handleBindException(BindException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {  
        ExceptionResponse.Body body;  
        List<String> messages;  
        BindingResult bindingResult = ex.getBindingResult();  

        List<ObjectError> allErrors = bindingResult.getAllErrors();  
        if (allErrors.size() != 0) {  
            messages = new ArrayList<>();  
            for (ObjectError error : allErrors) {  
                messages.add(error.getDefaultMessage());  
            }  
            body = new ExceptionResponse.Body(400, String.join(";", messages));  
            return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
        }  

        WebExceptionResponseProperties.ResponseConfig config = properties.getConfigByClassName(ex.getClass().getCanonicalName());  
        ExceptionResponse response = new ExceptionResponse(config, ex);  
        body = response.getBody();  
        return ResponseEntity.ok(ResponseInteBean.build(body.getStatus(), StringUtils.defaultString(body.getMessage().toString()), Void.class));  
    }  
}  
  

(四)自动装配

定义自动装配器WebExceptionAutoConfiguration,如下:

java 复制代码
package io.github.rovingsea.boot.web.autoconfigure;  
  
  
import io.github.rovingsea.boot.web.WebExceptionResponseProperties;  
import io.github.rovingsea.boot.web.exception.WebResponseEntityExceptionHandler;  
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;  
import org.springframework.boot.context.properties.EnableConfigurationProperties;  
import org.springframework.context.annotation.Bean;  
import org.springframework.context.annotation.Configuration;  
  
/**  
* <p>  
* Web 异常处理自动装配  
* </p>  
*  
* @author wuhaixin  
* @version 2023-11-20  
* @since 2023-11-20  
* @see META-INF  
*/  
@Configuration  
@EnableConfigurationProperties(WebExceptionResponseProperties.class)  
@ConditionalOnProperty(name = "spring.application.web.exception.enabled", matchIfMissing = true)  
public class WebExceptionAutoConfiguration {  
  
    @Bean  
    public WebResponseEntityExceptionHandler webResponseEntityExceptionHandler(WebExceptionResponseProperties properties) {  
    return new WebResponseEntityExceptionHandler(properties);  
    }  
  
}  

(五)SPI

resource下新建文件夹META-INF,新建文件spring.factories 内容如下:

text 复制代码
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\  
io.github.rovingsea.boot.web.autoconfigure.WebExceptionAutoConfiguration  

(六)最佳实践

1. 模拟生产环境配置

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
        # 服务异常  
          server:  
            body-message: 服务器开小差,请稍后再试...  
            # 不允许服务器异常信息外曝  
            exposure: false  
          # 非预期异常  
          unexpected:  
            http-status: 500  
            body-status: 5009999  
            body-message: 服务器开小差,请稍后再试...  
            # 不允许非预期异常信息外曝  
            exposure: true  
          configs:  
            # 1. 兼容 spring mvc 顶层异常  
            "[org.springframework.web.HttpRequestMethodNotSupportedException]":  
              http-status: 405  
              body-status: 405  
              body-message: 请求方式不接受  
            "[org.springframework.web.HttpMediaTypeNotSupportedException]":  
              http-status: 415  
              body-status: 415  
              body-message: 请求媒体类型不支持  
            "[org.springframework.web.HttpMediaTypeNotAcceptableException]":  
              http-status: 406  
              body-status: 406  
              body-message: 请求媒体类型不接受  
            "[org.springframework.web.bind.MissingPathVariableException]":  
              http-status: 500  
              body-status: 500  
              body-message: 服务器开小差,请稍后再试...  
            "[org.springframework.web.bind.MissingServletRequestParameterException]":  
              http-status: 400  
              body-status: 400  
              body-message: 请求中缺少必填参数  
            "[org.springframework.web.bind.ServletRequestBindingException]":  
              http-status: 400  
              body-status: 400  
              body-message: 请求参数无法绑定  
            "[org.springframework.beans.ConversionNotSupportedException]":  
              http-status: 500  
              body-status: 500  
              body-message: 服务器开小差,请稍后再试...  
            "[org.springframework.beans.TypeMismatchException]":  
              http-status: 400  
              body-status: 400  
              body-message: 请求参数类型不正确  
            "[org.springframework.http.converter.HttpMessageNotReadableException]":  
              http-status: 400  
              body-status: 400  
              body-message: 请求体不可读,请检查请求体是否符合格式  
            "[org.springframework.http.converter.HttpMessageNotWritableException]":  
              http-status: 500  
              body-status: 500  
              body-message: 服务器开小差,请稍后再试...  
            "[org.springframework.web.multipart.support.MissingServletRequestPartException]":  
              http-status: 400  
              body-status: 400  
              body-message: 请求中缺少必填部分  
            "[org.springframework.validation.BindException]":  
              http-status: 400  
              body-status: 400  
              body-message: 请求参数无法绑定  
            "[org.springframework.web.servlet.NoHandlerFoundException]":  
              http-status: 404  
              body-status: 404  
              body-message: 请求路径无法处理  
            "[org.springframework.web.context.request.async.AsyncRequestTimeoutException]":  
              http-status: 503  
              body-status: 503  
              body-message: 请求超时,请稍后再试...  
            # 2. 系统异常配置  
            "[java.lang.IllegalArgumentException]":  
              http-status: 400  
              body-status: 4000000  
              exposure: true  
            "[java.lang.IllegalStateException]":  
              http-status: 500  
              body-status: 5000000  
              body-message: 服务器开小差,请稍后再试...  
            ## 2.2 SQL 异常  
            "[java.sql.SQLException]":  
              http-status: 500  
              body-status: 5000000  
              body-message: 服务器开小差,请稍后再试...  
  

2. 发生客户端异常

客户端异常代表着服务器对客户端的呈堂证供,因此客户端异常信息均会外曝异常信息

如下代码:

java 复制代码
throw new IllegalOperationException("存在未提交的临分询价,不允许再新增")  

响应内容:

json 复制代码
{  
    "status": 42804250,  
    "message": "存在未提交的临分询价,不允许再新增",  
    "data": null  
}  

3. 发生服务端异常

3.1 允许外曝

配置如下:

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
          server:  
            exposure: true  

如下代码:

java 复制代码
throw new NoSuchBizDataException("100001", Element.class);  

服务器日志:

text 复制代码
2024-02-03 14:55:28.311 ERROR 82586 --- [nio-8080-exec-5] .f.w.e.WebResponseEntityExceptionHandler : An exception is caught, the detailed content is as follows:  
  
io.github.rovingsea.boot.web.exception.NoSuchBizDataException: No business data named 'Element' with key '100001'.  
at io.github.rovingsea.boot.web.example.api.impl.TestApiImpl.noSuchBizException(TestApiImpl.java:66) ~[classes/:na]  
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_372]  
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_372]  
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_372]  
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_372]  
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  

响应内容:

json 复制代码
{  
    "status": 5000001,  
    "message": "No business data named 'Element' with key '100001'.",  
    "data": null  
}  

3.2 不允许外曝

配置如下:

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
          server:  
            body-message: 服务器开小差,请稍后再试...  
            exposure: false  

如下代码:

java 复制代码
throw new NoSuchBizDataException("100001", Element.class);  

服务器日志:

text 复制代码
2024-02-03 14:58:34.471 ERROR 82588 --- [nio-8080-exec-5] .f.w.e.WebResponseEntityExceptionHandler : An exception is caught, the detailed content is as follows:  
  
io.github.rovingsea.boot.web.exception.NoSuchBizDataException: No business data named 'Element' with key '100001'.  
at io.github.rovingsea.boot.web.example.api.impl.TestApiImpl.noSuchBizException(TestApiImpl.java:66) ~[classes/:na]  
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_372]  
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_372]  
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_372]  
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_372]  
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  

响应内容:

json 复制代码
{  
    "status": 5000001,  
    "message": "服务器开小差,请稍后再试...",  
    "data": null  
}  

4. 发生非预期异常

4.1 允许外曝

配置如下:

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
          unexpected:  
            exposure: true  

代码如下:

java 复制代码
public void unexpected() {  
    BigDecimal paidAmount = new BigDecimal(100);  
    for (int i = 0; i < 100; i++) {  
        paidAmount.divide(BigDecimal.valueOf(i));  
    }  
}  

服务器日志:

text 复制代码
2024-02-03 15:00:19.332 ERROR 82870 --- [nio-8080-exec-5] .f.w.e.WebResponseEntityExceptionHandler : An exception is caught, the detailed content is as follows:  
  
java.lang.ArithmeticException: Division by zero  
at java.math.BigDecimal.divide(BigDecimal.java:1682) ~[na:1.8.0_372]  
at io.github.rovingsea.boot.web.example.api.impl.TestApiImpl.unexpected(TestApiImpl.java:40) ~[classes/:na]  
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_372]  
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_372]  
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_372]  
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_372]  
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:878) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:792) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  

响应内容:

json 复制代码
{  
    "status": 5009999,  
    "message": "Division by zero",  
    "data": null  
}  

4.2 不允许外曝

配置如下:

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
          unexpected:  
            body-message: 服务器开小差,请稍后再试...  
            exposure: false  

代码如下:

java 复制代码
public void unexpected() {  
    BigDecimal paidAmount = new BigDecimal(100);  
    for (int i = 0; i < 100; i++) {  
        paidAmount.divide(BigDecimal.valueOf(i));  
    }  
}  

服务器日志:

text 复制代码
2024-02-03 15:01:38.113 ERROR 82871 --- [nio-8080-exec-2] .f.w.e.WebResponseEntityExceptionHandler : An exception is caught, the detailed content is as follows:  
  
java.lang.ArithmeticException: Division by zero  
at java.math.BigDecimal.divide(BigDecimal.java:1682) ~[na:1.8.0_372]  
at io.github.rovingsea.boot.web.example.api.impl.TestApiImpl.unexpected(TestApiImpl.java:40) ~[classes/:na]  
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_372]  
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_372]  
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_372]  
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_372]  
at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:190) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:138) ~[spring-web-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:105) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:878) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:792) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1040) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:943) ~[spring-webmvc-5.2.15.RELEASE.jar:5.2.15.RELEASE]  

响应内容:

json 复制代码
{  
    "status": 5009999,  
    "message": "服务器开小差,请稍后再试...",  
    "data": null  
}  

5. 多链路异常返回

模拟存在客户端调用 A 服务,A 服务再调用内部 B 服务,当 B 服务发生客户端异常时(这里的客户端指的是 A 服务,而非实际的客户端) A 服务应当接收到时要抛出服务器异常,不允许将 A、B 两个服务内部的错误交互信息外曝。

确认 A 服务异常配置,不允许服务器和非预期异常外曝:

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
          server:  
            body-message: 服务器开小差,请稍后再试...  
            exposure: false  
          unexpected:  
            body-message: 服务器开小差,请稍后再试...  
            exposure: false  

确认 B 服务异常配置,允许服务器和非预期异常外曝:

yaml 复制代码
spring:  
  application:  
    web:  
      exception:  
        response:  
          server:  
            exposure: true  
          unexpected:  
            exposure: true 

A 某接口代码内容如下:

java 复制代码
    Element element = elementService.getByKey(id);  
    if (element != null) {  
        ResponseInterEntity interEntity = internalApi.processElement(element);  
    if (!isSucess(interEntity)) {  
        throw new InternalApiException(interEntity, internalApi.class);  
    }  
// more  

B internalApi.processElement 接口部分代码内容如下:

java 复制代码
if (StringUtils.isEmpty(element.getNo())) {  
    throw new RequestParamsException("The no of Element can not be empty.");  
}  
// more  

A 服务器日志如下:

text 复制代码
2024-02-03 15:05:51.914 ERROR 82871 --- [nio-8080-exec-1] .f.w.e.WebResponseEntityExceptionHandler : An exception is caught, the detailed content is as follows:  
  
io.github.rovingsea.boot.web.exception.InternalApiException: Failed to request api named 'internalApi',  
the response body status is '4000001' and message is 'The no of Element can not be empty.'.  
at io.github.rovingsea.boot.web.example.api.impl.AApiImpl.textRpc(TestApiImpl.java:51) ~[classes/:na]  
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_372]  
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_372]  
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_372]  
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_372]  

B 服务器日志如下:

text 复制代码
2024-02-03 15:05:51.918 ERROR 82872 --- [nio-8080-exec-2] .f.w.e.WebResponseEntityExceptionHandler : An exception is caught, the detailed content is as follows:  
  
io.github.rovingsea.boot.web.exception.RequestParamsException: The no of Element can not be empty.  
at io.github.rovingsea.boot.web.example.api.impl.BApiImpl.textRpc(TestApiImpl.java:36) ~[classes/:na]  
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:1.8.0_372]  
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) ~[na:1.8.0_372]  
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:1.8.0_372]  
at java.lang.reflect.Method.invoke(Method.java:498) ~[na:1.8.0_372]  

客户端接收内容:

json 复制代码
{  
    "status": 5009999,  
    "message": "服务器开小差,请稍后再试...",  
    "data": null  
}  

五、总结

按照手册继承ResponseEntityExceptionHandler实现RESTFUL风格接口的全局异常处理的同时, 也挖掘出应用也需要定义出顶级异常的 思想,再将其结合并通过配置化处理进一步地使异常处理更加灵活, 也使抛出异常的种类更加丰富和多样,还使代码一语中的和语法化。 因此,选择配置化异常处理和遵循手册的做法是较于穷举 @ExceptionHandler 来异常处理的更优选择。

相关推荐
@昵称不存在37 分钟前
Flask input 和datalist结合
后端·python·flask
zhuyasen1 小时前
Go 分布式任务和定时任务太难?sasynq 让异步任务从未如此简单
后端·go
东林牧之1 小时前
Django+celery异步:拿来即用,可移植性高
后端·python·django
超浪的晨2 小时前
Java UDP 通信详解:从基础到实战,彻底掌握无连接网络编程
java·开发语言·后端·学习·个人开发
AntBlack2 小时前
从小不学好 ,影刀 + ddddocr 实现图片验证码认证自动化
后端·python·计算机视觉
Pomelo_刘金3 小时前
Clean Architecture 整洁架构:借一只闹钟讲明白「整洁架构」的来龙去脉
后端·架构·rust
双力臂4043 小时前
Spring Boot 单元测试进阶:JUnit5 + Mock测试与切片测试实战及覆盖率报告生成
java·spring boot·后端·单元测试
midsummer_woo4 小时前
基于spring boot的医院挂号就诊系统(源码+论文)
java·spring boot·后端
Olrookie5 小时前
若依前后端分离版学习笔记(三)——表结构介绍
笔记·后端·mysql
沸腾_罗强5 小时前
Bugs
后端