SpringBoot 全局异常统一处理(AOP):@RestControllerAdvice + @ExceptionHandler + @ResponseStatus

概述

在开发基于 Spring Boot 框架(本文适用的SpringBoot版本为:2.7.18)的Web应用程序时,控制器层(Controller)通常会处理HTTP请求并返回响应。由于输入参数校验、业务逻辑执行等各种原因,可能会抛出不同类型的异常。如果在每个Controller方法中单独编写try-catch代码来处理这些异常,会导致代码冗余且难以维护。因此,利用Spring框架提供的 面向切面编程(AOP)机制以及@RestControllerAdvice@ExceptionHandler等注解,我们可以集中定义全局异常处理器类,统一捕获和处理所有Controller中可能抛出的异常。

这样做的好处包括:

  1. 提高代码复用性和可维护性。
  2. 保证了API错误响应格式的一致性,方便前端或客户端进行统一处理。
  3. 可以根据不同的异常类型定制返回给用户的错误信息和HTTP状态码,增强系统的健壮性和用户体验。

原理剖析

要实现这一功能,关键在于以下三个注解:

  1. @RestControllerAdvice:该注解用于标记一个类,表明此类将作为全局异常处理器,其作用范围涵盖所有@RestController注解修饰的类及其方法。Spring容器会在执行这些控制器的方法前后织入切点,从而捕获并处理可能出现的异常。

  2. @ExceptionHandler:该注解应用于处理异常的具体方法上,通过分析方法的参数类型来识别并关联需要处理的特定类型异常。当相应类型的异常在Controller中抛出时,Spring容器会自动匹配对应的方法进行调用和处理。这意味着无需在 @ExceptionHandler 注解中显式指定异常类型,仅需确保方法参数与期望处理的异常类型一致即可实现精准捕获和处理。

  3. @ResponseStatus:此注解同样应用于处理异常的方法上,它用来指定在发生特定异常时HTTP响应的状态码。通过这种方式,我们可以为不同类型的异常设置不同的HTTP状态码,以便于前端或其他服务正确理解异常情况。

异常处理代码

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handle(BindException e) {
        // ...
    }

    // 其他异常处理方法...
}
  • 示例代码如下(图片):

在这里插入图片描述

异常类型概览

本文所涉及的异常种类包括:

1. BindException:数据绑定异常,如请求参数校验不通过

2. ConstraintViolationException:约束违反异常

3. MissingServletRequestParameterException:遗漏Servlet请求参数异常

4. HttpRequestMethodNotSupportedException:HTTP请求方法不支持异常

5. HttpMessageNotReadableException:HTTP消息不可读异常

6. BusinessException:自定义业务异常,用于封装业务层错误信息

7. Exception:兜底异常,用于兜底处理未预期的异常

通过上述方式,我们可以全面、灵活地对SpringBoot应用中的各类异常进行统一且有针对性的处理。

针对上述异常的原理说明和异常处理细节,将在本专栏后续文章中详细介绍。

详情请参考专栏:异常统一处理(SpringMVC)

相关问题的说明

@ExceptionHandler 无需显示指定异常类型

在Spring MVC框架中,@ExceptionHandler注解用于声明一个方法来处理特定类型的异常。实际上,根据方法参数类型,Spring容器能够智能地关联并处理相应的异常,我们无需在@ExceptionHandler注解中显式指定异常类型。

详情参考如下文章:
@ExceptionHandler隐式指定异常类型

Result:响应统一封装

异常统一处理后返回的 Result 类,是统一封装的响应实体类。

关于响应统一封装的具体细节,参考如下文章:
《接口返回响应,统一封装(ResponseBodyAdvice + Result)(SpringBoot)》

异常统一处理,设置兜底处理方法

对于是否应当全面覆盖所有异常并设立兜底机制,业界存在着两种主流思路:全面覆盖原则和精细化处理。

  • 全面覆盖原则:捕获所有可能出现的异常,定义一个处理异常的具体方法,捕获java.lang.Throwable 来作为“兜底”手段。
  • 精细化处理:仅处理已知且预期的特定异常,不设兜底方法。

我的建议是在实践中,设立一个兜底异常处理方法;但是针对预期的异常,异常处理尽可能细化到各个异常类别;当出现新的异常时,及时新增相应的处理方法。

具体细节,请参考如下文章:
《异常处理:全面覆盖与精细化管理的平衡》

404 Not Found :无需特别处理

在讨论异常统一处理时,关于是否处理404错误,我持有观点:无需特别处理

当客户端请求一个不存在的资源时,例如一个未定义的接口路径,服务器会返回404状态码。这个状态码明确地传达了一个信息:“Not Found”,即服务器上未找到所请求的资源。

在前后端分离的应用架构中,404错误特指 接口路径无效 。当前端接收到带有404状态码的响应时,它已经明确知道所请求的接口路径是无效的。因此,前端可以根据实际情况自行决定如何处理这一错误,例如通过弹窗提示用户接口不存在,或者引导用户至一个友好的错误页面。

综上所述,在SpringBoot项目中,在进行异常统一处理时,后端无需专门处理404错误

完整代码

GlobalExceptionHandler

package com.example.core.advice;

import com.example.core.advice.util.ErrorMessageGenerator;
import com.example.core.advice.util.UserTipGenerator;
import com.example.core.model.BusinessException;
import com.example.core.model.Result;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.HandlerMethod;

import javax.servlet.http.HttpServletRequest;
import javax.validation.ConstraintViolationException;
import java.util.List;

/**
 * 全局异常处理器
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final String USER_TIP = "服务器异常!请稍后重试,或联系业务人员处理。";

    private final HttpServletRequest request;

    public GlobalExceptionHandler(HttpServletRequest request) {
        this.request = request;
    }

    /**
     * 参数校验异常:对象参数校验。
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handle(BindException e, HandlerMethod handlerMethod) {
        logInfo(e, handlerMethod);

        List<FieldError> fieldErrors = e.getFieldErrors();
        String userMessage = UserTipGenerator.getUserMessage(fieldErrors);
        String errorMessageCore = ErrorMessageGenerator.getErrorMessage(fieldErrors);

        String errorMessage = String.format("【参数校验异常】(错误数量:%s):%s", e.getErrorCount(), errorMessageCore);
        return Result.fail(userMessage, String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMessage);
    }

    /**
     * 参数校验异常:直接参数校验。
     * <p>
     * 此校验适用的接口,需要满足如下条件:
     * 1. 需要校验的参数“直接”作为接口方法的参数;
     * 2. Controller上添加 @Validated 注解;
     * 3. 参数前添加了校验规则注解(比如 @Pattern)。
     * <p>
     * 示例:删除用户接口
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<String> handle(ConstraintViolationException e, HandlerMethod handlerMethod) {
        logInfo(e, handlerMethod);

        String userMessage = UserTipGenerator.getUserMessage(e);
        String errorMessage = String.format("【参数校验异常】(错误数量:%s):%s", e.getConstraintViolations()
                .size(), e.getMessage());
        return Result.fail(userMessage, String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMessage);
    }

    /**
     * 参数被 @RequestParam 修饰,且 required = true,则被修饰的参数必须要传递的;否则就会抛出异常 MissingServletRequestParameterException。
     * <p>
     * 如果接口有多个参数被 @RequestParam 修饰(且 required = true),会按照参数声明顺序,依次校验参数必须传递;不是一次性将所有的参数必传都校验出来,一次报错只会包含一个参数的错误。
     * <p>
     * 参数不传时,报错示例:
     * DefaultHandlerExceptionResolver : Resolved [org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter 'name' for method parameter type String is not present]
     */

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<String> handle(MissingServletRequestParameterException e, HandlerMethod handlerMethod) {
        logInfo(e, handlerMethod);

        String parameterDescription = getParameterDescription(e, handlerMethod);
        String userMessage = String.format("[%s] 参数缺失,此参数为必传参数", parameterDescription);

        String errorMessage = String.format("【参数校验异常】:%s", e.getMessage());
        return Result.fail(userMessage, String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMessage);
    }

    /**
     * 获取参数的描述。
     * <p>
     * 如果接口文档注解中有此参数的描述,则使用文档中的描述;如果接口文档注解中没有描述,则使用参数的字段名作为描述。
     */
    private String getParameterDescription(MissingServletRequestParameterException e, HandlerMethod handlerMethod) {
        String parameterName = e.getParameterName();
        String parameterDescription = parameterName;

        Parameter[] annotationsByType = handlerMethod.getMethod().getAnnotationsByType(Parameter.class);
        for (Parameter parameter : annotationsByType) {
            String name = parameter.name();
            if (name != null && name.equals(parameterName)) {
                String description = parameter.description();
                if (StringUtils.hasText(description)) {
                    parameterDescription = description;
                }
                break;
            }
        }
        return parameterDescription;
    }

    /**
     * Http请求方法不支持异常。
     * <p>
     * 一个接口,是由【请求方法】(比如:GET、POST、PUT、DELETE)和【接口路径】两个部分来唯一确定的。
     * 当一个请求,能找到对应的【接口路径】,但是没有找到对应的【请求方法】时,会报异常 HttpRequestMethodNotSupportedException ,进入此异常处理。
     * 当一个请求,找不到对应的【接口路径】时,会直接报错 404 ,不会进入此异常处理。
     * <p>
     * 测试:一个接口,应该用Post请求,却用了Get请求。
     * <p>
     * 报错示例:
     * DefaultHandlerExceptionResolver : Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'GET' not supported]
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    public Result<String> handle(HttpRequestMethodNotSupportedException e) {
        // 注意:此方法不能包含 HandlerMethod 入参,否则会报错;因为这个异常处理代表着没找到接口,自然也就没有接口对应的方法。
        logInfo(e);

        String api = getApi();
        String userMessage = "接口请求异常!请稍后重试,或联系业务人员处理。";
        String errorMessage = String.format("【Http请求方法不支持异常】:接口 %s 不存在;报错信息:[org.springframework.web.HttpRequestMethodNotSupportedException: %s]", api, e.getMessage());
        return Result.fail(userMessage, String.valueOf(HttpStatus.METHOD_NOT_ALLOWED.value()), errorMessage);
    }

    /**
     * Http消息不可读异常。
     * <p>
     * 报错原因包括(不完全的列举):
     * <p>
     * (1)缺少请求体(RequestBody)异常;
     * <p>
     * (2)无效格式异常。比如:参数为数字,但是前端传递的是字符串且无法解析成数字。
     * <p>
     * (3)Json解析异常(非法Json格式)。传递的数据不是合法的Json格式。比如:key-value对中的value(值)为String类型,却没有用双引号括起来。
     * <p>
     * 举例:
     * (1)缺少请求体(RequestBody)异常。报错:
     * .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException:
     * Required request body is missing:
     * public void com.example.web.response.controller.ResponseController.addUser(com.example.web.response.model.param.UserAddParam)]
     */
    @ExceptionHandler
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    public Result<String> handle(HttpMessageNotReadableException e, HandlerMethod handlerMethod) {
        logInfo(e, handlerMethod);

        Throwable rootCause = e.getRootCause();

        // 无效格式异常处理。比如:目标格式为数值,输入为非数字的字符串("80.5%"、"8.5.1"、"张三")。
        if (rootCause instanceof InvalidFormatException) {
            String userMessage = UserTipGenerator.getUserMessage((InvalidFormatException) rootCause);
            String errorMessage = String.format("InvalidFormatException(无效格式异常):%s", e.getMessage());
            return Result.fail(userMessage, String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMessage);
        }

        String userMessage = "Http消息不可读异常!请稍后重试,或联系业务人员处理。";
        String errorMessage = String.format("HttpMessageNotReadableException(Http消息不可读异常):%s", e.getMessage());
        return Result.fail(userMessage, String.valueOf(HttpStatus.BAD_REQUEST.value()), errorMessage);
    }

    /**
     * 业务异常处理
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handle(BusinessException e, HandlerMethod handlerMethod) {
        logInfo(e, handlerMethod);
        return Result.fail(e.getUserMessage(), e.getErrorCode(), e.getErrorMessage());
    }

    /**
     * 兜底异常处理
     */
    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<String> handle(Throwable e, HandlerMethod handlerMethod) {
        logError(e, handlerMethod);

        // 作为最后兜底的异常处理,不应该将异常信息返回给前端;
        // 因为异常信息中可能会包含一些敏感信息,比如SQL报错可能会包含数据库的表名、字段名和SQL语句,这些暴露给用户存在安全性风险。
        return Result.fail(USER_TIP, String.valueOf(HttpStatus.INTERNAL_SERVER_ERROR.value()), null);
    }

    private void logInfo(Throwable e) {
        String exceptionName = e.getClass().getName();
        String requestMethod = request.getMethod();
        String url = request.getRequestURI();
        String message = String.format("\n接口:[%s:%s]\n异常名称:[%s]\n异常信息:\n%s", requestMethod, url, exceptionName, e.getMessage());
        log.info(message, e);
    }

    private void logInfo(Throwable e, HandlerMethod handlerMethod) {
        String message = getLogMessage(e, handlerMethod);
        log.info(message, e);
    }

    private void logError(Throwable e, HandlerMethod handlerMethod) {
        String message = getLogMessage(e, handlerMethod);
        log.error(message, e);
    }

    private String getLogMessage(Throwable e, HandlerMethod handlerMethod) {
        String exceptionName = e.getClass()
                .getName();
        String requestMethod = request.getMethod();
        String url = request.getRequestURI();
        String className = handlerMethod.getBean()
                .getClass()
                .getName();
        String methodName = handlerMethod.getMethod()
                .getName();

        return String.format("\n接口:[%s:%s]\n异常名称:[%s]\n出现异常的方法:[%s.%s]\n异常信息:\n%s", requestMethod, url, exceptionName, className, methodName, e.getMessage());
    }

    private String getApi() {
        String requestMethod = request.getMethod();
        String url = request.getRequestURI();
        return String.format("[%s:%s]", requestMethod, url);
    }

}

UserTipGenerator + ErrorMessageGenerator

package com.example.core.advice.util;

import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import org.springframework.util.CollectionUtils;
import org.springframework.validation.FieldError;

import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.List;
import java.util.Set;

/**
 * 用户提示生成器。
 *
 * @author songguanxun
 * @since 2023-8-24
 */
public class UserTipGenerator {

    /**
     * 获取用户提示(参数校验异常时)
     */
    public static String getUserMessage(List<FieldError> errors) {
        StringBuilder stringBuilder = new StringBuilder();
        errors.forEach(error -> {
            String defaultMessage = error.getDefaultMessage();
            String numberFormatExceptionName = NumberFormatException.class.getName();
            if (defaultMessage != null && defaultMessage.contains(numberFormatExceptionName)) {
                String message = String.format("数字格式异常,当前输入为:[%s]", error.getRejectedValue());
                stringBuilder.append(message)
                        .append(";");
            } else {
                stringBuilder.append(defaultMessage)
                        .append(";");
            }
        });
        return stringBuilder.toString();
    }

    public static String getUserMessage(ConstraintViolationException e) {
        Set<ConstraintViolation<?>> sets = e.getConstraintViolations();
        if (CollectionUtils.isEmpty(sets)) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        sets.forEach(error -> sb.append(error.getMessage())
                .append(";"));
        return sb.toString();
    }

    public static String getUserMessage(InvalidFormatException rootCause) {
        // 目标类型
        Class<?> targetType = rootCause.getTargetType();
        // 目标类型提示信息
        String targetTypeNotification = "";
        if (targetType == Integer.class || targetType == Long.class || targetType == BigInteger.class || targetType == Byte.class || targetType == Short.class) {
            targetTypeNotification = "参数类型应为:整数;";
        } else if (targetType == BigDecimal.class || targetType == Double.class || targetType == Float.class) {
            targetTypeNotification = "参数类型应为:数值;";
        }
        Object value = rootCause.getValue();

        return String.format("参数格式错误!%s当前输入参数:[%s]", targetTypeNotification, value);
    }

}

package com.example.core.advice.util;

import org.springframework.validation.FieldError;

import java.util.List;

/**
 * 错误信息生成器。
 *
 * @author songguanxun
 * @date 2023-8-24
 */
public class ErrorMessageGenerator {

    /**
     * 获取错误信息(参数校验异常时)
     */
    public static String getErrorMessage(List<FieldError> errors) {
        StringBuilder stringBuilder = new StringBuilder();
        errors.forEach(error -> {
            String text = getItemErrorMessage(error);
            stringBuilder.append(text);
        });
        return stringBuilder.toString();
    }


    /**
     * 获取一个错误的信息
     */
    private static String getItemErrorMessage(FieldError error) {
        String field = error.getField();
        Object rejectedValue = error.getRejectedValue();
        String defaultMessage = error.getDefaultMessage();
        return String.format("【错误字段:[%s],错误值:[%s],错误信息:[%s]】;", field, rejectedValue, defaultMessage);
    }

}

BusinessException

package com.example.core.model;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;

/**
 * 业务异常
 */
@Getter
@Schema(name = "业务异常", description = "业务异常")
public class BusinessException extends RuntimeException {

    @Schema(description = "用户提示", example = "操作成功!")
    private final String userMessage;

    /**
     * 错误码<br>
     * 调用成功时,为 null。<br>
     * 示例:10001
     */
    @Schema(description = "错误码")
    private final String errorCode;

    /**
     * 错误信息<br>
     * 调用成功时,为 null。<br>
     * 示例:"验证码无效"
     */
    @Schema(description = "错误信息")
    private final String errorMessage;


    public BusinessException(ErrorEnum errorEnum) {
        super(String.format("错误码:[%s],错误信息:[%s],用户提示:[%s]", errorEnum.name(), errorEnum.getMessage(), errorEnum.getMessage()));
        this.userMessage = errorEnum.getMessage();
        this.errorCode = errorEnum.name();
        this.errorMessage = errorEnum.getMessage();
    }


    public BusinessException(String userMessage, String errorCode, String errorMessage) {
        super(String.format("错误码:[%s],错误信息:[%s],用户提示:[%s]", errorCode, errorMessage, userMessage));
        this.userMessage = userMessage;
        this.errorCode = errorCode;
        this.errorMessage = errorMessage;
    }

}

ErrorEnum(仅做示例)

错误枚举,仅做示例,实际开发中请根据业务自行定义。

package com.example.core.model;

import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * 错误枚举
 */
@Getter
@AllArgsConstructor
public enum ErrorEnum {

    A0001("用户端错误 "), // 一级宏观错误码


    // ------------------------------------------------------------------------------- //
    A0100("用户注册错误"), // 二级宏观错误码

    A0101("用户未同意隐私协议"),
    A0102("注册国家或地区受限"),

    A0110("用户名校验失败"),
    A0111("用户名已存在"),
    A0112("用户名包含敏感词"),
    A0113("用户名包含特殊字符"),


    // ------------------------------------------------------------------------------- //
    A0200("用户登录异常"), // 二级宏观错误码

    A0201("用户账户不存在"),
    A0202("用户账户被冻结"),
    A0203("用户账户已作废"),

    A0210("用户密码错误"),
    A0211("用户输入密码错误次数超限"),


    // ------------------------------------------------------------------------------- //
    A0400("用户请求参数错误"), // 二级宏观错误码

    A0420("请求参数值超出允许的范围"),
    A0421("参数格式不匹配"),
    A0422("地址不在服务范"),
    A0423("时间不在服务范围"),
    A0424("金额超出限制"),
    A0425("数量超出限制"),
    A0426("请求批量处理总个数超出限制"),
    A0427("请求 JSON 解析失败");


    private final String message;

}

  • 72
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

宋冠巡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值