Springboot-优雅的全局异常处理

这是我参与8月更文挑战的第14天,活动详情查看:8月更文挑战

背景

在项目开发过程中,不管是对数据库的操作过程,还是业务层的处理过程,还是控制层的处理过程,都不可避免会遇到各种可预知的、不可预知的异常需要处理。如果对每个过程都单独作异常处理,那系统的代码耦合度会变得很高,此外,开发工作量也会加大而且不好统一,这也增加了代码的维护成本。

针对这种实际情况,我们需要将所有类型的异常处理从各处理过程解耦出来,这样既保证了相关处理过程的功能单一,也实现了异常信息的统一处理和维护。同时,我们也不希望直接把异常抛给用户,应该对异常进行处理,对错误信息进行封装,然后返回一个友好的信息给用户。

开发准备

  • 环境 JDKopenjdk 15.0.1(大家也可以根据自己响应的去做调整,建议JDK8及以上)
  • Spirng boot2.5.2

Maven依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
        <version>2.5.2</version>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
        <version>2.5.2</version>
    </dependency>
</dependencies>
复制代码

开发

定义统一响应json结构

前端或者其他服务请求本服务的接口时,该接口需要返回对应的 JSON 数据,一般该服务只需要返回请求中需要的参数即可。但在实际项目中,我们需要封装更多的信息,比如状态码 code、相关信息 message 等等,一方面是在项目中可以有个统一的返回结构,整个项目组都适用,另一方面方便结合全局异常处理信息,因为一般在异常处理信息中我们需要把状态码和异常内容反馈给调用方。

/**java
 * 响应体
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Body<T> {
    /**
     * 响应码,默认正常返回为 0
     */
    private int code = 0;
    /**
     * 响应消息,默认正常返回为"ok"
     */
    private String message = "ok";
    /**
     * 响应正文
     */
    private T content;

    public Body(T content) {
        if (content instanceof BaseException) {
            BaseException baseException = (BaseException) content;
            this.message = baseException.getMessage();
            this.code = baseException.getCode();
            this.content = null;
        } else {
            this.content = content;
        }
    }

}
复制代码

响应类需要包含响应体,同时还需要处理http响应的一些Header和状态码等等

/**
 * 响应类
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */

public class Response<T> extends ResponseEntity<Body<T>> {

    public Response(HttpStatus status) {
        super(new Body<>(), status);
    }

    public Response(T t, HttpStatus status) {
        super(new Body<>(t), status);
    }

    public Response(Body<T> body, HttpStatus status) {
        super(body, status);
    }

    public Response(MultiValueMap<String, String> headers, HttpStatus status) {
        super(new Body<>(), headers, status);
    }

    public Response(Body<T> body, MultiValueMap<String, String> headers, HttpStatus status) {
        super(body, headers, status);
    }

    public Response(Body<T> body, MultiValueMap<String, String> headers, int rawStatus) {
        super(body, headers, rawStatus);
    }
}
复制代码

定义全局自定义异常类

自定义一个异常类,用来处理全局捕获到的异常信息,同时方便在返回的时候更好的生成响应。

/**
 * 自定义异常类
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */

@Data
@EqualsAndHashCode(callSuper = true)
public class BaseException extends RuntimeException {
    private int code;
    private String message;

    public BaseException(GlobalExceptionEnum error) {
        this.code = error.getCode();
        this.message = error.getMessage();
    }

    /**
     * 根据code获取http 状态码,code的前三位和http 状态码一致
     * 不符合规则,默认返回 400
     *
     * @return http 状态码
     */
    public HttpStatus getHttpStatus() {
        //判断code是否为3位数
        if (!this.verifyCode()) {
            //不符合返回400
            return HttpStatus.BAD_REQUEST;
        }
        String codeStr = String.valueOf(this.code).substring(0, 3);
        int statusCode = Integer.parseInt(codeStr);

        HttpStatus httpStatus = HttpStatus.resolve(statusCode);

        if (Objects.isNull(httpStatus)) {
            //不符合返回400
            return HttpStatus.BAD_REQUEST;
        }
        return httpStatus;
    }

    /**
     * 校验code code 最少是3位数
     *
     * @return 符合校验true, 否则false
     */
    public boolean verifyCode() {
        return this.code >= 100;
    }
}
复制代码

预定义异常错误

这里使用枚举定义,把一些我们可以提前预知的错误定义出来,方便我们在开发过程中使用。

code前三位用http状态码来表示,将一些异常信息归类。后续可以根据业务需要自行扩展。

Http状态码 描述
400 INVALID REQUEST 用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
401 Unauthorized 表示用户没有认证(令牌、用户名、密码错误)。
403 Forbidden 表示用户得到认证(与401错误相对),但是访问是被禁止的(没有访问资源的权限)。
404 NOT FOUND 用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
410 Gone 用户请求的资源被永久删除,且不会再得到的。
422 Unprocesable entity 当创建一个对象时,发生一个验证错误。
500 INTERNAL SERVER ERROR 服务器发生错误,用户将无法判断发出的请求是否成功。
/**
 * 自定异常码
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */
public enum GlobalExceptionEnum {

    REQUEST_INVALID_PARAM(4001, "invalid param", "无效参数"),

    NOT_LOGGED_IN(4011, "not logged in", "未登录"),

    PASSWORD_INCORRECT(4012, "password incorrect", "密码错误"),

    AUTH_EXCEPTION(4031, "auth exception", "权限错误"),

    NOT_FOUNT(4041, "not found", "未找到"),

    DELETED(4101, "deleted", "已删除"),

    EXISTED(4221, "existed", "已存在"),

    INTERNAL_SERVER_ERROR(5001, "internal server error", "系统内部错误"),

    UNKNOWN_SERVER_ERROR(5002, "unknown server error", "系统未知错误"),

    NULL_POINTER_EXCEPTION(5003, "null pointer exception", "空指针异常");


    /**
     * 错误码
     */
    private int code;
    /**
     * 错误消息
     */
    private String message;
    /**
     * 错误描述信息
     */
    private String description;

    GlobalExceptionEnum(int code, String message, String description) {
        this.code = code;
        this.message = message;
        this.description = description;
    }

    public BaseException getException() {
        return new BaseException(this);
    }
}
复制代码

定义异常处理器

定义一个controller层的一个抽象类,需要捕获异常的都需要继承此类。

/**
 * 需要处理异常的controller都需要继承这个类
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */

public abstract class AbstractController {
}
复制代码

定义一个全局异常处理器,当继承这个AbstractController类中发现异常是,当前处理器会捕获,然后@ExceptionHandler定位到我们异常(自定义异常、空指针异常和Exception)的处理方法当中。

由于 Exception 异常是父类,所有异常都会继承该异常,所以我们可以直接拦截 Exception 异常,一劳永逸。但在项目中,我们一般都会比较详细地去拦截一些常见异常,但是不利于我们去排查或者定位问题。实际项目中,可以把拦截 Exception 异常写在 GlobalExceptionHandler 最下面,如果都没有找到,最后再拦截一下 Exception 异常,保证输出信息友好。

同时我们应该在异常抛出的地方,打印日志,方便定位和解决问题。

/**
 * 自定义全局异常处理器
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */

@ResponseBody
@ControllerAdvice(basePackageClasses = AbstractController.class)
public class GlobalExceptionHandler {

    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class.getName());


    /**
     * 业务异常处理
     *
     * @param request 请求
     * @param e       异常
     * @return 响应内容
     */
    @ExceptionHandler(value = BaseException.class)
    Response<?> handleBaseException(HttpServletRequest request, BaseException e) {
        logger.info("request url :{}, code:{}, msg: {}", request.getRequestURI(), e.getCode(), e.getMessage());
        return new Response<>(e, e.getHttpStatus());
    }


    /**
     * 空指针异常处理
     *
     * @param request 请求
     * @param e       异常
     * @return 响应内容
     */
    @ExceptionHandler(value = NullPointerException.class)
    Response<?> handleNpe(HttpServletRequest request, NullPointerException e) {
        logger.info("request url :{}, npe, msg: {}", request.getRequestURI(), e.getMessage());
        BaseException exception = new BaseException(GlobalExceptionEnum.NULL_POINTER_EXCEPTION);
        return new Response<>(exception, HttpStatus.INTERNAL_SERVER_ERROR);
    }

    /**
     * 所有异常处理
     */
    @ExceptionHandler(Exception.class)
    public Response<?> handleException(HttpServletRequest request, Exception e) {
        logger.info("request url :{},  msg: {}", request.getRequestURI(), e.getMessage());
        BaseException exception = GlobalExceptionEnum.INTERNAL_SERVER_ERROR.getException();
        return new Response<>(exception, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}
复制代码

注解:

  • @ControllerAdvice: @Controller层的一个增强器,在Spring里,我们可以使用来声明一些全局性的东西。

  • @ExceptionHandler: 注解标注的方法:用于捕获Controller中抛出的不同类型的异常,从而达到异常全局处理的目的。

注意:

  • 异常信息不要在try-catch块捕获,全部使用throw抛出

测试

写一个登录接口,然后针对不同情况作出不同的异常处理

错误描述 code http 状态码 message
空指针,什么参数都不传 5003 500 null pointer exception
用户名不符合参数校验 4001 400 invalid param
用户名不存在 4041 400 not found
密码错误 4012 401 password incorrect
/**
 * 测试controller
 *
 * @author 万恶的沫白
 * @date 2021/8/13
 */

@RestController
@RequestMapping("/users")
public class UserController extends AbstractController {

    public static final String DEFAULT_USERNAME = "wanedemobai";
    public static final String DEFAULT_PASSWORD = "000000";
    public static final int USERNAME_MIN_LENGTH = 6;

    @PostMapping("/login")
    public Response<?> login(
            @RequestBody LoginReq req
    ) {
        if (req.getUsername().length() < USERNAME_MIN_LENGTH) {
            throw GlobalExceptionEnum.REQUEST_INVALID_PARAM.getException();
        }

        if (!DEFAULT_USERNAME.equals(req.getUsername())) {
            throw GlobalExceptionEnum.NOT_FOUNT.getException();
        }

        if (!DEFAULT_PASSWORD.equals(req.getPassword())) {
            throw GlobalExceptionEnum.PASSWORD_INCORRECT.getException();
        }
        return new Response<>(HttpStatus.OK);
    }
}
复制代码

最后我们使用postman进行测试,查看结果是否正确

总结

代码仓库地址

执行流程

  1. 发送一个请求
  2. 执行controller层代码
  3. 当发现异常时,将由我们的GlobalExceptionHandler根据异常进行分类,处理
  4. 将响应返回