错误码设计思考

木小丰 2022年03月24日 7,943次浏览

在微服务化的今天,服务间的交互越来越复杂,统一异常处理规范作为框架的基础,一旦上线后很难再更改,如果设计不好,会导致后期的维护成本越来越来大。 对于错误码的设计,不同的开发团队有不同的风格习惯。本文分享作者从实践中总结的经验及对应的思考,期望对读者有所启发。

本文中涉及的源码:https://github.com/sofn/app-engine/tree/master/common-error

什么是错误码

引自阿里巴巴《Java 开发手册》- 异常日志-错误码

错误码的制定原则:快速溯源、简单易记、沟通标准化。

说明:错误码想得过于完美和复杂,就像康熙字典中的生僻字一样,用词似乎精准,但是字典不容易随身携带并且简单易懂。
正例:错误码回答的问题是谁的错?错在哪?
1)错误码必须能够快速知晓错误来源,可快速判断是谁的问题。
2)错误码易于记忆和比对(代码中容易 equals)。
3)错误码能够脱离文档和系统平台达到线下轻量化地自由沟通的目的。

那么用Java异常能表示出来吗?答案显然是否定的

  • 必须能够快速知晓错误来源:异常类因为复用性不能很快的定位,异常类和代码行数也不是一个稳定的值
  • 必须易于记忆和对比:异常类不具有可比性,且不利于前后端交互
  • 能够脱离代码沟通:异常类只能存在于Java代码中

错误码设计

错误码的设计是比较简单的,一般只需要定义一个数字和描述信息即可。不过想设计一套完善错误码系统还有很多需要考虑的场景。

1、错误码的分层

大部分项目错误码设计分为3级能满足业务场景,即项目、模块、错误编码。比如错误码是6位,前两位是项目码、中间两位是模块码,最后两位是异常编号。以下是错误码10203的对应说明:

2、错误的表示方法:枚举or 类

推荐使用枚举,因为枚举具有不可变性,且所有值都在一个文件里描述。

3、多模块错误码定义及接口定义

最原始的错误定义方法是项目中所有的错误码都定义在一个类里,但是这样会随着业务的发展错误码越来越多,最终导致难以维护,推荐的做法是按照项目+模块粒度定义成多个错误码枚举类。有两个问题需要考虑:

(1)项目编码、模块编码的维护:推荐另建一个枚举类统一维护

(2)异常类的统一引用:定义接口,枚举类实现接口

示例:

//异常接口定义
public interface ErrorCode {
}
//模块定义
public enum UserProjectCodes {
    LOGIN(1, 1, "登录模块"),
    USER(1, 2, "用户模块")
}
//登录模块异常码定义
public enum LoginErrorCodes implements ErrorCode {
    USER_NOT_EXIST(0, "用户名不存在"), //错误码: 10100
    PASSWORD_ERROR(1, "密码错误");    //错误码: 10101
    
    private final int nodeNum;
    private final String msg;

    UserLoginErrorCodes(int nodeNum, String msg) {
        this.nodeNum = nodeNum;
        this.msg = msg;
        ErrorManager.register(UserProjectCodes.LOGIN, this);
    }
}

4、防重设计

错误码本质上就是一个数字,且每一个都需要由RD编码定义,在错误码多的项目很容易重复。最佳实践是在枚举的构造方法里调用Helper类,Helper类统一维护所有的异常码,如有重复则枚举初始化失败。

5、错误扩展信息

只有错误码是不够的,还需要反馈给调用方详细的错误信息已方便修正。固定的错误信息字符串在某些场景写也是不够的,这里推荐使用slf4j打日志时使用的动态参数,这种方式相比于String.format格式的好处是不需要关心参数的类型以及记忆%s、%d等的区别,且打印日志时经常使用,降低了团队成员的学习成本。

示例:

//错误码定义
PARAM_ERROR(17, "参数非法,期望得到:{},实际得到:{}")
//错误码使用
ErrorCodes.PARAM_ERROR.format(arg1, arg2);

实现方式:

org.slf4j.helpers.MessageFormatter.arrayFormat(this.message, args).getMessage()  

错误码和异常

在日常业务开发中,对于异常使用最多的还是抛出Java异常(Exception),异常又分为受检查异常(Exception)和不受检查异常(RuntimeException):

  • 受检查的异常:这种在编译时被强制检查的异常称为"受检查的异常"。即在方法的声明中声明的异常。
  • 不受检查的异常:在方法的声明中没有声明,但在方法的运行过程中发生的各种异常被称为"不被检查的异常"。这种异常是错误,会被自动捕获。

1、异常绑定错误码

定义两个父类,分别用于首检查异常和非受检查异常。可支持传入错误码,同时需要支持原始的异常传参,这种场景会赋予一个默认的错误码,比如:500服务器内部异常

//父类定义
public abstract class BaseException extends Exception {

    protected BaseException(String message) {...}

    protected BaseException(String message, Throwable cause) {...}

    protected BaseException(Throwable cause) {...}

    protected BaseException(ErrorInfo errorInfo) {...}

    protected BaseException(ErrorCode errorCode) {...}

    protected BaseException(ErrorCode errorCode, Object... args) {...}
}

2、部分异常

使用异常能适用于大部分场景,不过对于多条目的场景不是很适合,比如需要批量保存10条记录,某些成功、某些失败,这种场景就不适合直接抛出异常。

在Node.js和Go语言中异常处理采用多返回值方式处理,第一个值是异常,如果为null则表示无异常。在Java里建议采用vavr库中的Either来实现,通常使用左值表示异常,而右值表示正常调用后的返回结果,即: Either<ErrorCode, T>

注意不推荐Pair、Tuple来实现,因为Either只能设置一个左值或右值,而Pair、Tuple无此限制。

错误码和统一返回值

在前后端的交互中,后端一般使用JSON方式返回结果,整合前面说的错误码,可定义以下格式:

{
   "code": number,
   "msg": string,
   "data": object
}

在SpringMVC中实现方式是自定义ResponseBodyAdvice和异常拦截,具体实现方式直接查看:源码

实现了以上步骤之后就可以在SpringMVC框架中愉快的使用了,会自动处理异常及封装成统一返回格式

    @GetMapping("/order")
    public Order getOrder(Long orderId) {
        return service.findById(orderId);
    }

总结

本文总结了设计错误码需要考虑的各种因素,并给出了参考示例,基本能满足一般中大型项目。规范有了最重要的还是落地,让团队成员遵守规范才能让项目健康的迭代。

源码地址:https://github.com/sofn/app-engine/tree/master/common-error

本文链接:错误码设计思考

作者简介:木小丰,美团Java技术专家,专注分享软件研发实践、架构思考。欢迎关注公共号:Java研发

更多精彩文章:
Java线程池进阶
从MVC到DDD的架构演进
平台化建设思路浅谈
构建可回滚的应用及上线checklist实践
Maven依赖冲突问题排查经验