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

2024-01-09 10:20:59

概述

在开发基于 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:缺少必需请求参数异常

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

5. HttpMessageNotReadableException:HTTP消息不可读异常,如JSON解析失败

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

7. Throwable:作为所有错误和异常的超类,用于兜底处理未明确捕获的异常情况

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

针对上述异常的处理细节,将在后续文章中详细介绍。

相关问题的说明

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

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

详情参考如下文章:
Spring MVC中@ExceptionHandler注解的智能处理机制——无需显示指定异常类型

Result:响应统一封装

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

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

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

针对是否应全面处理所有异常并设立兜底机制,有如下两种主流思路:

  1. 全面覆盖原则:提倡捕获并适当地处理所有可能出现的异常,包括但不限于数据库操作异常、运行时异常等。这种情况下,通常会定义一个处理异常的具体方法,捕获java.lang.Throwable 来作为“兜底”手段。这种方法有助于确保任何未预见的异常情况发生时,系统不会崩溃,并能提供一定程度的恢复或优雅降级策略。但需要注意的是,尽管兜底方法可以避免程序直接终止,但也应尽可能保持针对性,对不同类型的异常进行有意义的错误记录、通知及可能的恢复动作。

  2. 精细化处理:另一种观点倾向于仅处理已知且预期的特定异常,不为所有异常设置通用的兜底方法。这类观点认为每个业务逻辑所抛出的异常都有其特殊的含义和处理方式,笼统地处理可能会隐藏问题的具体原因或者返回过于模糊的错误信息给用户。当出现未显式处理的异常时,让它们穿透至框架层或顶层,如SpringMVC框架,以默认的错误响应形式反馈调用失败,这样可以清晰地标识出系统中的潜在bug或未完成的异常处理逻辑。

结合上述两种观点,我的建议是在实践中采取一种折衷而严谨的做法:

  • 对于各种预期的业务异常和系统异常,应当编写具体的处理逻辑,尽量细化到各个异常类别,确保每种异常都能够得到合理的应对与反馈。

  • 同时,确实应该设立一个兜底异常处理方法,但这不是为了替代具体的异常处理,而是在所有具体处理都无法匹配或执行时的一个最终保障。该兜底方法应包含基本的错误记录和必要的安全措施,比如通知运维人员或用户系统遇到了未知问题。

  • 遇到新的异常场景时,开发团队应当及时识别并更新异常处理模块,新增相应的处理方法,以维持系统的稳定性和用户体验。

总之,在实现异常处理时,既要做到全面考虑,防止程序因意外情况而崩溃,又要注重精细化管理,保证异常信息的准确传递和恰当处置。

兜底方法代码示例:

在这里插入图片描述

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);
    }

}

辅助代码

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;

}

文章来源:https://blog.csdn.net/sgx1825192/article/details/135398494
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。