作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO
联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
据说阿里的多隆不仅技术高超,而且记忆力拔群。据说有一次出了bug,同事打电话给多隆求助。电话那头,多隆略微思考后说了句:你打开Xxx文件,大概在xxx行左右有一个xxx,你xxx应该就可以了。后来大家才知道,当时多隆正在一家餐馆吃饭...
连多隆都会犯错,我们作为普通程序员,写的代码出现错误是再正常不过了。Java中程序运行出错时,通常都是以异常的形式展现的:
可以说,只要我们还在编码,就必定要处理异常。
和很多人的观点不同,我个人认为系统设计上必须做全局异常兜底处理,无论你们公司使用什么框架都一样。以SpringBoot为例,它默认的异常处理机制会把错误信息全部返回,甚至把SQL错误信息逐行打印出来。这会暴露系统内部的设计,显然是不合适的。
在SpringBoot中,无论是请求不存在的路径、@Valid校验错还是业务代码(Controller、Service、Dao)抛出异常,SpringBoot对错误的默认处理机制是:
BasicErrorController会判断当前请求来自哪里,如果来自浏览器则响应错误页面,如果来自APP则响应JSON。
那么,SpringBoot是如何判断一个请求到底来自浏览器还是APP的呢?其实,主要是看HTTP的一个请求头:Accept。
具体逻辑可以查看SpringBoot的BasicErrorController类
SpringBoot默认的异常处理机制有什么不好呢?主要还是两点:
以JSON格式为例,通常我们希望不论接口请求是否正常,都返回以下格式:
{
"data": {}
"success": true,
"massage": ""
}
如果请求失败,希望把错误信息转化为指定内容(比如“系统正在繁忙”)放在message中返回,给前端/客户端一个友好提示。
这样一来,不论请求成功还是失败,响应格式都是统一的,对外暴露的信息也可控。
自定义异常处理可以大致分为两类:
在resources/error下存放404.html和500.html,当本次请求状态码为404或500时,SpringBoot就会读取我们自定义的html返回,否则返回默认的错误页面。
现在一般都是前后端分离,所以关于自定义错误页面就略过了。
之前提到过,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响应格式,我们需要对异常进行处理。
一般有两种方式,并且通常会组合使用:
为了方便模拟异常情况,下面案例中我们会直接抛出自定义异常,然后考虑如何处理它。
在此之前,我们先准备通用枚举类和自定义的业务异常:
/**
* 通用错误枚举(不同类型的错误也可以拆成不同的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,用来统一返回格式:
/**
* 一般返回实体
*
* @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
}
异常还有一种处理方式,就是利用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(),你还有其他方式吗?
按本文的分类,处理业务异常无非两种:
所以,当项目中没有使用切面统一处理异常时,除了使用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
进群,大家一起学习,一起进步,一起对抗互联网寒冬
?