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