如何处理异常

2023-12-21 04:42:44
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

为什么要做异常处理

据说阿里的多隆不仅技术高超,而且记忆力拔群。据说有一次出了bug,同事打电话给多隆求助。电话那头,多隆略微思考后说了句:你打开Xxx文件,大概在xxx行左右有一个xxx,你xxx应该就可以了。后来大家才知道,当时多隆正在一家餐馆吃饭...

连多隆都会犯错,我们作为普通程序员,写的代码出现错误是再正常不过了。Java中程序运行出错时,通常都是以异常的形式展现的:

  • 代码逻辑不严谨导致的一些异常,最常见的是空指针异常
  • 系统崩溃、网络抖动或内存溢出等原因导致的异常

可以说,只要我们还在编码,就必定要处理异常。

和很多人的观点不同,我个人认为系统设计上必须做全局异常兜底处理,无论你们公司使用什么框架都一样。以SpringBoot为例,它默认的异常处理机制会把错误信息全部返回,甚至把SQL错误信息逐行打印出来。这会暴露系统内部的设计,显然是不合适的。

SpringBoot默认异常处理机制

在SpringBoot中,无论是请求不存在的路径、@Valid校验错还是业务代码(Controller、Service、Dao)抛出异常,SpringBoot对错误的默认处理机制是:

BasicErrorController会判断当前请求来自哪里,如果来自浏览器则响应错误页面,如果来自APP则响应JSON。

那么,SpringBoot是如何判断一个请求到底来自浏览器还是APP的呢?其实,主要是看HTTP的一个请求头:Accept。

具体逻辑可以查看SpringBoot的BasicErrorController类

SpringBoot默认的异常处理机制有什么不好呢?主要还是两点:

  • 样式或数据格式不统一
  • 对外暴露的信息不可控

以JSON格式为例,通常我们希望不论接口请求是否正常,都返回以下格式:

{
  "data": {}
  "success": true,
  "massage": ""
}

如果请求失败,希望把错误信息转化为指定内容(比如“系统正在繁忙”)放在message中返回,给前端/客户端一个友好提示。

这样一来,不论请求成功还是失败,响应格式都是统一的,对外暴露的信息也可控。

自定义异常处理可以大致分为两类:

  • 自定义错误页面
  • 自定义异常JSON

自定义错误页面

在resources/error下存放404.html和500.html,当本次请求状态码为404或500时,SpringBoot就会读取我们自定义的html返回,否则返回默认的错误页面。

现在一般都是前后端分离,所以关于自定义错误页面就略过了。

自定义异常JSON

之前提到过,SpringBoot默认的异常JSON格式是这样的:

{
    "timestamp": "2021-01-31T01:36:12.187+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "",
    "path": "/insertUser"
}

而我们上一篇封装的响应格式是这样的:

{
  "data": {}
  "success": true,
  "massage": ""
}

如果前端习惯了根据code判断请求是否正常返回(业务码,不是HttpStatus):

if(res.code == 200) {
  // 请求成功后的处理逻辑
}

当接口异常时,返回的JSON却没有code,会比较错愕。为了统一JSON响应格式,我们需要对异常进行处理。

一般有两种方式,并且通常会组合使用:

  • 在代码中使用工具类封装(ApiResultTO/Result)
  • 用全局异常处理兜底

为了方便模拟异常情况,下面案例中我们会直接抛出自定义异常,然后考虑如何处理它。

在此之前,我们先准备通用枚举类和自定义的业务异常:

/**
 * 通用错误枚举(不同类型的错误也可以拆成不同的Enum细分)
 *
 * @author mx
 */
@Getter
public enum ExceptionCodeEnum {

    /**
     * 通用结果
     */
    ERROR(-1, "网络错误"),
    SUCCESS(200, "成功"),

    /**
     * 用户登录
     */
    NEED_LOGIN(900, "用户未登录"),

    /**
     * 参数校验
     */
    ERROR_PARAM(10000, "参数错误"),
    EMPTY_PARAM(10001, "参数为空"),
    ERROR_PARAM_LENGTH(10002, "参数长度错误");

    private final Integer code;
    private final String desc;

    ExceptionCodeEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    private static final Map<Integer, ExceptionCodeEnum> ENUM_CACHE = new HashMap<>();

    static {
        for (ExceptionCodeEnum exceptionCodeEnum : ExceptionCodeEnum.values()) {
            ENUM_CACHE.put(exceptionCodeEnum.code, exceptionCodeEnum);
        }
    }

    public static String getDesc(Integer code) {
        return Optional.ofNullable(ENUM_CACHE.get(code))
                .map(ExceptionCodeEnum::getDesc)
                .orElseThrow(() -> new IllegalArgumentException("invalid exception code!"));
    }

}
/**
 * 业务异常
 * biz是business的缩写
 *
 * @author mx
 * @see ExceptionCodeEnum
 */
@Getter
public class BizException extends RuntimeException {

    private ExceptionCodeEnum error;

    /**
     * 构造器,有时我们需要将第三方异常转为自定义异常抛出,但又不想丢失原来的异常信息,此时可以传入cause
     *
     * @param error
     * @param cause
     */
    public BizException(ExceptionCodeEnum error, Throwable cause) {
        super(cause);
        this.error = error;
    }

    /**
     * 构造器,只传入错误枚举
     *
     * @param error
     */
    public BizException(ExceptionCodeEnum error) {
        this.error = error;
    }
}

下面演示两种处理异常的方式。

Result手动封装

先封装一个Result,用来统一返回格式:

/**
 * 一般返回实体
 *
 * @author mx
 */
@Data
@NoArgsConstructor
public class Result<T> implements Serializable {

    private Integer code;
    private String message;
    private T data;

    private Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    private Result(Integer code, String message) {
        this.code = code;
        this.message = message;
        this.data = null;
    }

    /**
     * 带数据成功返回
     *
     * @param data
     * @param <T>
     * @return
     */
    public static <T> Result<T> success(T data) {
        return new Result<>(ExceptionCodeEnum.SUCCESS.getCode(), ExceptionCodeEnum.SUCCESS.getDesc(), data);
    }

    /**
     * 不带数据成功返回
     *
     * @return
     */
    public static <T> Result<T> success() {
        return success(null);
    }

    /**
     * 通用错误返回,传入指定的错误枚举
     *
     * @param exceptionCodeEnum
     * @return
     */
    public static <T> Result<T> error(ExceptionCodeEnum exceptionCodeEnum) {
        return new Result<>(exceptionCodeEnum.getCode(), exceptionCodeEnum.getDesc());
    }

    /**
     * 通用错误返回,传入指定的错误枚举,但支持覆盖message
     *
     * @param exceptionCodeEnum
     * @param msg
     * @return
     */
    public static <T> Result<T> error(ExceptionCodeEnum exceptionCodeEnum, String msg) {
        return new Result<>(exceptionCodeEnum.getCode(), msg);
    }

    /**
     * 通用错误返回,只传入message
     *
     * @param msg
     * @param <T>
     * @return
     */
    public static <T> Result<T> error(String msg) {
        return new Result<>(ExceptionCodeEnum.ERROR.getCode(), msg);
    }

}

比如原本是这样的:

@PostMapping("insertUser")
public Result<Boolean> insertUser(@RequestBody MpUserPojo userPojo) {
    return Result.success(mpUserService.save(userPojo));
}

加上参数校验:

@PostMapping("insertUser")
public Result<Boolean> insertUser(@RequestBody MpUserPojo userPojo) {
    if (userPojo == null) {
        // 只传入定义好的错误
        return Result.error(ExceptionCodeEnum.EMPTY_PARAM)
    }
    if (userPojo.getUserType() == null) {
        // 抛出自定义的错误信息
        return Result.error(ExceptionCodeEnum.ERROR_PARAM, "userType不能为空");
    }
    if (userPojo.getAge() < 18) {
        // 抛出自定义的错误信息
        return Result.error("年龄不能小于18");
    }

    return Result.success(mpUserService.save(userPojo));
}

这样一来,前端联调时就比较舒服:

除了参数校验抛异常外,还可以在Service层调用时进行异常转换:

public Result<Boolean> insertUser(MpUserPojo userPojo) {
    try {
        return Result.success(mpUserService.save(userPojo));
    } catch (Exception e) {
        log.warn("userService rpc failed, request:{}", JSON.toJSONString(userPojo), e);
        return Result.error(ExceptionCodeEnum.RPC_ERROR);
    }
}
{
  "code": -2
  "message": "远程调用失败",
  "data": null
}

或者执行到一半,发现数据为空直接返回(当然,这个本身和异常关系不大):

public Result<User> updateUser(User user) {
	// 预先校验,如果不符合预期,提前结束
    User user = userService.getUserById(user.getId());
    if(user == null) {
        return Result.error("用户不存在");
    }
   
    // ...
}
{
  "code": -1
  "message": "用户不存在",
  "data": null
}

@RestControllerAdvice全局异常处理兜底

异常还有一种处理方式,就是利用Spring/SpringBoot提供的@RestControllerAdvice进行兜底处理,

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

    /**
     * 业务异常
     *
     * @param
     * @return
     */
    @ExceptionHandler(BizException.class)
    public Result<ExceptionCodeEnum> handleBizException(BizException bizException) {
        log.error("业务异常:{}", bizException.getMessage(), bizException);
        return Result.error(bizException.getError());
    }

    /**
     * 运行时异常
     *
     * @param e
     * @return
     */
    @ExceptionHandler(RuntimeException.class)
    public Result<ExceptionCodeEnum> handleRunTimeException(RuntimeException e) {
        log.error("运行时异常: {}", e.getMessage(), e);
        return Result.error(ExceptionCodeEnum.ERROR);
    }

}

一般来说,全局异常处理只是一种兜底的异常处理策略,也就是说提倡自己处理异常。但现在其实很多人都喜欢直接在代码中抛异常,全部交给@RestControllerAdvice处理:

@PostMapping("insertUser")
public Result<Boolean> insertUser(@RequestBody MpUserPojo userPojo) {
    if (userPojo == null) {
        throw new BizException(ExceptionCodeEnum.EMPTY_PARAM);
    }

    return Result.success(mpUserService.save(userPojo));
}

这个异常抛到@RestControllerAdvice后,其实还是被封装成Result返回了。

所以Result和@ResultControllerAdvice两种方式归根结底是一样的:

对于异常处理,每个人的理解都不同,各家公司也都有自己的规范,我们知道怎么回事以及有哪些套路即可。

思考题

假设你接手了一个项目,内部并没有统一异常处理,并且出于某些原因不允许(或者你不敢)使用@ResultControllerAdvice,除了直接在Service中使用Result.error(),你还有其他方式吗?

按本文的分类,处理业务异常无非两种:

  • 代码里先出异常,在某处统一处理(惰性集中处理)
  • 直接在代码里使用ApiResult等统一结果就地封装异常(即时分散处理)

所以,当项目中没有使用切面统一处理异常时,除了使用Result.error()即时包裹信息外,我们仍然可以把异常抛出去。

你或许会有疑问:不是说不能@ResultControllerAdvice吗?

是啊,但谁说捕获异常一定要用切面呢?别忘了最原滋原味的try catch呀!具体做法是,在Service直接throw BizException,然后在上层(比如Controller、Manager)捕获异常。

@Controller
@Slf4j
public class Controller {
    @Autowired
    private Service service;
    
    @GET
    public ApiResult<User> test() {
        try {
            service.test();
        } catch (Exception e) {
            log.error(...);
            return ApiResult.error(e.getMessage);
        }
    }
}

@Service
public class Service {
    public User test() {
        if(check something failed) {
            throw new BizException();
        }
        
        if(check something else failed) {
            throw new BizException();
        }
        
        // biz code ...
    }
}
作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

进群,大家一起学习,一起进步,一起对抗互联网寒冬

?

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