如何处理异常

2023-12-19

作者简介:大家好,我是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

进群,大家一起学习,一起进步,一起对抗互联网寒冬
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

如何处理异常 的相关文章

  • 如何开发一个免费的App

    开发一个免费App意味着能够在项目启动初期 以更低成本的方式进行业务的迭代和市场化验证 互联网发展到2023年 尤其在生成式AI及大模型技术 跃进式 增长的背景下 一个创新式商业模式的起步变得异常艰难 但如果用好工具 那么不仅能事半功倍 还

随机推荐

  • 网络安全设备概念的熟悉和学习

    什么是网络安全 网络安全技术有哪些 Web应用防火墙 WAF 为什么需要WAF 什么是WAF 与传统防火墙的区别 WAF不是全能的 入侵检测系统 IDS 什么是IDS 跟防火墙的比较 部署位置选择
  • 【网络安全】—Shell编程入门(1)

    文章目录 基础 变量概念介绍 特殊变量进阶 数值计算实践 条件测试比较 条件判断语句 流程控制语句 循环语句应用
  • threejs 解析外部模型关键帧动画

    参考资料 threejs中文网 threejs qq交流群 814702116 解析外部模型关键帧动画 前面几节课 用到的关键帧动画 是借助threejs提供的两个类 KeyframeTrack AnimationClip 自己写代码实现
  • SQL Server 大数据量分页

    1 ROW NUMBER OVER 方式 SQL2012以下推荐使用 SELECT FROM SELECT ROW NUMBER OVER ORDER BY menuId AS RowId FROM sys menu AS r WHERE
  • “jar“:CreateProcess error = 2,系统找不到指定的文件

    起因 系统配置的java环境为jar 或java环境不完整导致 修复 将jar exe拷贝至java环境的bin目录中即可
  • c语言:指针作为参数传递

    探究实参与形参它们相互独立 由于主调函数的变量 a b 与被调函数的形参 x y 它们相互独立 函数 swap 可以修改变量 x y 但是却无法影响到主调函数中的 a b 现在利用取地址运算符 分别打印它们的首地址 让我们从内存的角度 来分
  • 使用小程序实现App灰度测试的好处

    灰度测试 Gray Testing 是一种软件测试策略 也被称为渐进性测试或部分上线测试 在灰度测试中 新的软件版本或功能并非一次性推送给所有用户 而是仅在一小部分用户中进行测试 这可以帮助开发团队逐步暴露新功能或版本 以便及时发现和修复问
  • 网络安全基础知识

    1 什么是防火墙 什么是堡垒主机 什么是DMZ 防火墙是在两个网络之间强制实施访问控制策略的一个系统或一组系统 堡垒主机是一种配置了安全防范措施的网络上的计算机 堡垒主机为网络之间的通信提供了一个阻塞点 也可以说 如果没有堡垒主机 网络间将
  • Matlab-绘图及其位置摆放

    一 绘图函数 1 绘制二维图形 1 1 plot 函数的应用格式 1 plot x 当x 为一向量时 以x 元素的值为纵坐标 x 的序号为横坐标值绘制曲线 当x 为一实矩阵时 则以其序号为横坐标 按列绘制每列元素值相对于其序号的曲线 例如
  • threejs 机械虚拟装配案例(播放)

    参考资料 threejs中文网 threejs qq交流群 814702116 机械虚拟装配案例 播放 如果你想做一个产品 机械 建筑的虚拟装配动画 可以美术先在建模软件中生成关键帧动画的数据 然后通过threejs加载模型 播放动画数据即
  • 使用AI大模型无损放大照片

    在线体验 点击 图像处理 即可使用 private static final String IMAGE QUALITY ENHANCE https aip baidubce com rest 2 0 image process v1 ima
  • 多线程案例:购买车票

    购票案例 多线程同步 多线程的并发执行虽然可以提高程序的效率 但是 当多个线程去访问同一个资源时 也会引发一些安全问题 并发 同一个对象被多个线程同时操作 处理多线程问题时 多个线程访问同一个对象 并且某些线程还想修改这个对象 这时候我们就
  • 网络安全自学入门:(超详细)从入门到精通学习路线&规划,学完即可就业_网络安全自学从哪里入手

    很多人上来就说想学习黑客 但是连方向都没搞清楚就开始学习 最终也只是会无疾而终 黑客是一个大的概念 里面包含了许多方向 不同的方向需要学习的内容也不一样 算上从学校开始学习 已经在网安这条路上走了10年了 无论是以前在学校做安全研究 还是毕
  • 开源一个超好用的接口Mock工具——Msw-Tools

    作为一名前端开发 是不是总有这样的体验 基础功能逻辑和页面UI开发很快速 本来可以提前完成 但是接口数据联调很费劲 耗时又耗力 有时为了保证进度还不得不加加班 为了摆脱这种痛苦 经过一周的努力 从零开发了一个灵活无依赖 且集成简单的数据接口
  • threejs动画播放(暂停、倍速、循环)AnimationAction

    参考资料 threejs中文网 threejs qq交流群 814702116 动画播放 暂停 倍速 循环 上节课对关键帧动画如何 创建 如何 播放 做了整体介绍 下面进一步介绍关键帧动画播放的知识 比如关键帧动画停止播放 暂停播放 倍速播
  • 3DM/OFF格式在线转换

    3D模型在线转换 https 3dconvert nsdt cloud 是一个可以进行3D模型格式转换的在线工具 支持多种3D模型格式进行在线预览和互相转换 3DM与OFF格式简介 3DM是一种常用的三维模型文件格式 具有多种几何体和材质
  • 【网络安全】—Shell编程入门(2)

    文章目录 循环控制语句 函数知识精讲 数组知识精讲 开发环境规范 调试优化实践 自动化实战项目 在前面的章节中 我们已经介绍了Shell编程的基础知识 包括变量 特殊变量 数值计算 条件测试 条件判断和基本的循环语句 接下来 我
  • ssm+mysql应急指挥平台-计算机毕业设计源码13263

    摘 要 科技进步的飞速发展引起人们日常生活的巨大变化 电子信息技术的飞速发展使得电子信息技术的各个领域的应用水平得到普及和应用 信息时代的到来已成为不可阻挡的时尚潮流 人类发展的历史正进入一个新时代 在现实运用中 应用软件的工作规则和开发步
  • 我从阿里云学到的返回值处理技巧

    作者简介 大家好 我是smart哥 前中兴通讯 美团架构师 现某互联网公司CTO 联系qq 184480602 加我进群 大家一起学习 一起进步 一起对抗互联网寒冬 阿里云 CosmoController 来思考一下前面两篇都做了什么 一开
  • 如何处理异常

    作者简介 大家好 我是smart哥 前中兴通讯 美团架构师 现某互联网公司CTO 联系qq 184480602 加我进群 大家一起学习 一起进步 一起对抗互联网寒冬 为什么要做异常处理 据说阿里的多隆不仅技术高超 而且记忆力拔群 据说有一次