异常与错误码
soda 用一个统一的方式表达失败:错误码 + 中文消息。错误码定义在 BaseErrorCode,异常是 BaseException,校验用 ParamChecker,响应包装用 Result。
BaseErrorCode
com.hibuka.soda.foundation.error.BaseErrorCode,实现 CodeEnum<Integer>。共 48 个码,分三段:
| 区段 | 范围 | 含义 |
|---|---|---|
| HTTP 语义码 | 200 ~ 503 | 与 HTTP 状态码对齐 |
| 框架通用码 | 10000 ~ 10039 | 技术层面的错误 |
| 业务码 | 19999 | 留给业务自定义 |
HTTP 语义码
| 枚举 | code | desc |
|---|---|---|
SUCCESS | 200 | 成功 |
BAD_REQUEST | 400 | 请求错误 |
UNAUTHORIZED | 401 | 未授权 |
FORBIDDEN | 403 | 禁止访问 |
NOT_FOUND | 404 | 资源不存在 |
INTERNAL_SERVER_ERROR | 500 | 服务器内部错误 |
SERVICE_UNAVAILABLE | 503 | 服务不可用 |
框架通用码
| 枚举 | code | desc |
|---|---|---|
SYSTEM_ERROR | 10000 | 系统错误 |
PARAM_ERROR | 10001 | 参数错误 |
BUSINESS_ERROR | 10002 | 业务错误 |
NETWORK_ERROR | 10003 | 网络错误 |
DATABASE_ERROR | 10004 | 数据库错误 |
VALIDATION_ERROR | 10005 | 验证错误 |
AUTHENTICATION_ERROR | 10006 | 认证错误 |
PERMISSION_ERROR | 10007 | 权限错误 |
RESOURCE_ERROR | 10008 | 资源错误 |
TIMEOUT_ERROR | 10009 | 超时错误 |
CONFLICT_ERROR | 10010 | 冲突错误 |
CONFIG_ERROR | 10011 | 配置错误 |
RPC_ERROR | 10012 | RPC调用错误 |
CACHE_ERROR | 10013 | 缓存错误 |
FILE_ERROR | 10014 | 文件错误 |
ENCRYPT_ERROR | 10015 | 加密错误 |
DECRYPT_ERROR | 10016 | 解密错误 |
SERIALIZATION_ERROR | 10017 | 序列化错误 |
DESERIALIZATION_ERROR | 10018 | 反序列化错误 |
INVALID_FORMAT | 10019 | 格式错误 |
INVALID_STATE | 10020 | 状态错误 |
UNSUPPORTED_OPERATION | 10021 | 不支持的操作 |
NULL_POINTER_ERROR | 10022 | 空指针错误 |
INDEX_OUT_OF_BOUNDS_ERROR | 10023 | 索引越界错误 |
DIVISION_BY_ZERO_ERROR | 10024 | 除零错误 |
ILLEGAL_ARGUMENT_ERROR | 10025 | 非法参数错误 |
ILLEGAL_STATE_ERROR | 10026 | 非法状态错误 |
CONCURRENT_ERROR | 10027 | 并发错误 |
DEADLOCK_ERROR | 10028 | 死锁错误 |
MEMORY_ERROR | 10029 | 内存错误 |
STACK_OVERFLOW_ERROR | 10030 | 栈溢出错误 |
IO_ERROR | 10031 | IO错误 |
INTERRUPTED_ERROR | 10032 | 中断错误 |
CLASS_NOT_FOUND_ERROR | 10033 | 类未找到错误 |
NO_SUCH_METHOD_ERROR | 10034 | 方法未找到错误 |
ILLEGAL_ACCESS_ERROR | 10035 | 非法访问错误 |
INSTANTIATION_ERROR | 10036 | 实例化错误 |
TYPE_ERROR | 10037 | 类型错误 |
CAST_ERROR | 10038 | 类型转换错误 |
VERIFICATION_ERROR | 10039 | 验证错误 |
业务码
| 枚举 | code | desc |
|---|---|---|
CUSTOM_ERROR | 19999 | 自定义错误 |
业务自己的错误码应该继承 BaseErrorCode 的思路另建一套枚举,不要往 BaseErrorCode 里加。soda 的 BaseErrorCode 是框架共用的,改它会和其它工程冲突。
按码查消息
public static String getMessage(Integer code) {
for (BaseErrorCode errorCode : values()) {
if (errorCode.code.equals(code)) {
return errorCode.desc;
}
}
return "未知错误";
}码不在字典里时返回 "未知错误"。
VALIDATION_ERROR(10005)和 VERIFICATION_ERROR(10039)的 desc 都是「验证错误」。这是有意保留的重名 —— 两个码对应不同语义,消息文本相同。
BaseException
com.hibuka.soda.foundation.error.BaseException,继承 RuntimeException 并实现 IErrorCode。
构造函数
| 构造函数 | 说明 |
|---|---|
BaseException(int code, String message) | 最常用 |
BaseException(int code, String message, Throwable cause) | 带原始异常 |
BaseException(int code, String message, Map<String, Object> errorData) | 带附加数据 |
BaseException(int code, String message, Map<String, Object> errorData, Throwable cause) | 全参 |
BaseException(int code) | message 从 BaseErrorCode.getMessage(code) 取 |
静态工厂
public static BaseException of(int code, String message)
public static BaseException of(int code, String message, Throwable cause)
public static BaseException of(int code, String message, Map<String, Object> errorData)
public static BaseException of(int code, String message, Map<String, Object> errorData, Throwable cause)
public static BaseException of(int code)携带的信息
除了 code 和 message,BaseException 还会自动记录抛出位置:
| 字段 | 类型 | 说明 |
|---|---|---|
code | int | 错误码 |
errorData | Map<String, Object> | 附加信息,含 timestamp、className、methodName、lineNumber 和你传入的额外数据 |
timestamp | long | 创建时的毫秒时间戳 |
className | String | 抛出点的类名 |
methodName | String | 抛出点的方法名 |
lineNumber | int | 抛出点的行号 |
抛出点的定位逻辑是跳过 BaseException 自身和 Thread,取栈里的第一个「业务」帧:
private StackTraceElement findCaller(StackTraceElement[] stackTrace) {
for (StackTraceElement element : stackTrace) {
if (!element.getClassName().equals(BaseException.class.getName()) &&
!element.getClassName().equals("java.lang.Thread")) {
return element;
}
}
return stackTrace[0];
}errorData 是 Collections.unmodifiableMap 包装的 —— 外部改不了。传进去的额外数据会被合并进同一张 Map,你的 key 会覆盖自动记录的 key(如果重名)。
用法
// 最常见:仓储里包一层
try {
return scodaProducerMapper.insert(scodaProducer) > 0;
} catch (Exception e) {
throw new BaseException(BaseErrorCode.DATABASE_ERROR.getCode(), e.getMessage());
}
// 带原始异常
throw new BaseException(BaseErrorCode.NETWORK_ERROR.getCode(), "调用下游失败", e);
// 带附加数据
Map<String, Object> errorData = new HashMap<>();
errorData.put("uid", uid);
throw new BaseException(BaseErrorCode.NOT_FOUND.getCode(), "生产车间不存在", errorData);BaseException 是 RuntimeException 子类,不需要 catch 就会向上传播。在仓储里 catch (Exception e) 之后要记得把 e 作为 cause 传进去,否则原始堆栈就丢了,排查问题会很痛苦。
IErrorCode
public interface IErrorCode {
int getCode();
default String getMessage() {
return BaseErrorCode.getMessage(this.getCode());
}
default Map<String, Object> getErrorData() {
return null;
}
}这是一个可扩展的错误码接口。BaseErrorCode 实现它,业务也可以实现自己的:
@Getter
public enum OrderErrorCode implements IErrorCode {
ORDER_NOT_EXIST(20001, "订单不存在"),
ORDER_ALREADY_PAID(20002, "订单已支付"),
ORDER_AMOUNT_MISMATCH(20003, "订单金额不匹配");
private final int code;
private final String message;
OrderErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
@Override
public int getCode() {
return code;
}
@Override
public String getMessage() {
return message;
}
}注意 IErrorCode.getMessage() 的 default 实现返回的是 BaseErrorCode.getMessage(code),也就是框架字典里的中文消息,而不是你自己的消息。所以自定义枚举必须覆写 getMessage(),否则 Result.fail(errorCode) 返回的 message 会是「未知错误」。
另外 getErrorData() 的 default 实现返回 null,而 Result 的构造函数会把 null 转成空 Map,所以不会 NPE。
自定义错误码可以直接喂给 Result:
return Result.fail(OrderErrorCode.ORDER_NOT_EXIST);ParamChecker
com.hibuka.soda.foundation.io.ParamChecker 是一组静态校验方法。所有校验失败抛的都是 BaseException,错误码固定为 BAD_REQUEST(400),message 是你传入的那句提示。
基础校验
| 方法 | 失败条件 | ||
|---|---|---|---|
notNull(T obj, String message) | obj == null | ||
notNull(T obj, int errorCode, String message) | 同上,自定义错误码 | ||
notNull(T obj, Supplier<E> exceptionSupplier) | 同上,抛自定义异常 | ||
notEmpty(String str, String message) | `str == null \ | \ | str.isEmpty()` |
notBlank(String str, String message) | `str == null \ | \ | str.isBlank()` |
notEmpty(T collection, String message) | 集合为 null 或空 | ||
notEmpty(Map<K, V> map, String message) | Map 为 null 或空 | ||
notEmpty(T[] array, String message) | 数组为 null 或长度为 0 |
notEmpty 有四个重载(String / Collection / Map / 数组)。传 null 字面量时会有歧义,编译不过 —— 写成 (String) null 明确指定类型。
布尔与范围校验
| 方法 | 失败条件 |
|---|---|
isTrue(boolean condition, String message) | condition == false |
isTrue(boolean condition, int errorCode, String message) | 同上,自定义错误码 |
isTrue(boolean condition, Supplier<E> exceptionSupplier) | 同上,抛自定义异常 |
isFalse(boolean condition, String message) | condition == true |
inRange(T value, T min, T max, String message) | 值不在 [min, max] 闭区间内 |
lengthInRange(String str, int min, int max, String message) | 字符串长度不在 [min, max] 内 |
matches(String str, String regex, String message) | 字符串不匹配正则 |
inRange 和 lengthInRange 是闭区间 —— min 和 max 本身都算合法。
inRange 要求类型实现 Comparable:
public static <T extends Comparable<T>> T inRange(T value, T min, T max, String message)集合成员校验
| 方法 | 失败条件 |
|---|---|
inCollection(T value, Collection<T> collection, String message) | 值不在集合里 |
inArray(T value, T[] array, String message) | 值不在数组里 |
两个方法都会先校验 value 非 null、容器非空 —— 所以容器为空时报的是同一个 message。
默认值方法
这三个不抛异常,只在值为空时返回默认值:
| 方法 | 何时返回默认值 | ||
|---|---|---|---|
defaultIfEmpty(String str, String defaultValue) | `str == null \ | \ | str.isEmpty()` |
defaultIfBlank(String str, String defaultValue) | `str == null \ | \ | str.isBlank()` |
defaultIfNull(T obj, T defaultValue) | obj == null |
还有几个单参数版本:
| 方法 | 说明 |
|---|---|
defaultIfNull(T collection) | 集合为 null 时返回空集合 |
defaultIfNull(Map<K, V> map) | Map 为 null 时返回空 Map |
defaultIfNull(T[] array) | 数组为 null 时返回空数组 |
defaultIfEmpty 和 defaultIfBlank 的区别:前者只认空串(""),后者还认纯空白串(" ")。用户输入的表单字段用 defaultIfBlank 更稳妥。
用法
private void commandValidate(CreateProducerCommand command) throws BaseException {
ParamChecker.notNull(command, "command");
ParamChecker.notBlank(command.getName(), "名称不能为空");
ParamChecker.lengthInRange(command.getName(), 1, 64, "名称长度必须在 1~64 之间");
}带自定义错误码:
ParamChecker.notNull(command, BaseErrorCode.PARAM_ERROR.getCode(), "command 不能为空");抛自定义异常:
ParamChecker.notNull(command, () -> new OrderException("订单不存在"));
ParamChecker.isTrue(amount > 0, () -> new OrderException("金额必须为正"));Result
com.hibuka.soda.foundation.io.Result<T> 的工厂方法。
成功
| 方法 | code | message |
|---|---|---|
success(T data) | 200 | "" |
success(T data, String message) | 200 | 你传的 |
success(T data, int code, String message) | 你传的 | 你传的 |
successWithRequestId(T data, String requestId) | 200 | "" |
successWithRequestId(T data, String message, String requestId) | 200 | 你传的 |
successWithRequestId(T data, int code, String message, String requestId) | 你传的 | 你传的 |
失败
| 方法 | code | message |
|---|---|---|
fail(int code, String message) | 你传的 | 你传的 |
fail(T data, int code, String message) | 你传的 | 你传的 |
failWithRequestId(T data, int code, String message, String requestId) | 你传的 | 你传的 |
fail(IErrorCode errorCode) | errorCode.getCode() | errorCode.getMessage() |
fail(T data, IErrorCode errorCode) | 同上 | 同上 |
failWithRequestId(T data, IErrorCode errorCode, String requestId) | 同上 | 同上 |
fail 有两个 int code 版本和一个 IErrorCode 版本。传 IErrorCode 时用的是接口的 getMessage(),不是 BaseErrorCode.getDesc()。自定义枚举务必覆写 getMessage()。
setRequestId 返回 this,可以链式调用:
Result.success(dto).setRequestId(requestId);Result 的字段
| 字段 | 类型 | 默认值 |
|---|---|---|
requestId | String | null |
code | Integer | 200 |
message | String | "" |
data | T | null |
success | Boolean | true |
errorData | Map<String, Object> | 空 Map |
PageResult
PageResult<T> 继承 Result<T>,多了八个分页字段:
| 字段 | 类型 | 说明 |
|---|---|---|
total | Long | 总记录数 |
pageNo | Integer | 当前页码,从 1 开始 |
pageSize | Integer | 每页条数 |
pages | Long | 总页数 |
hasNext | Boolean | 是否有下一页 |
hasPrevious | Boolean | 是否有上一页 |
startIndex | Long | 当前页起始索引 |
endIndex | Long | 当前页结束索引 |
工厂方法
| 方法 | 说明 |
|---|---|
success(T data, Long total, Integer pageNo, Integer pageSize) | 完整分页 |
success(T data, Long total, Integer pageNo, Integer pageSize, int code, String message) | 带 code 和 message |
success(T data, Integer pageNo, Integer pageSize) | total 固定为 0 |
success(T data) | total=0,pageNo=1,pageSize=10 |
successWithRequestId(T data, Long total, Integer pageNo, Integer pageSize, String requestId) | 带 requestId |
successWithRequestId(T data, Long total, Integer pageNo, Integer pageSize, int code, String message, String requestId) | 全参 |
successWithRequestId(T data, Integer pageNo, Integer pageSize, String requestId) | total=0 |
successWithRequestId(T data, String requestId) | total=0,pageNo=1,pageSize=10 |
fail(int code, String message) | 失败 |
fail(T data, int code, String message) | 失败带数据 |
failWithRequestId(T data, int code, String message, String requestId) | 失败带 requestId |
success(T data, Long total, Integer pageNo, Integer pageSize) 的参数顺序是 data → total → pageNo → pageSize。4.0.0 之前是 data → pageNo → pageSize → total。从旧版本升级时这里要改。详见 从 v16 升级。
分页元信息的计算:
private Long calculatePages(Long total, Integer pageSize) {
if (total == null || total <= 0) {
return 0L;
}
return total % pageSize == 0 ? total / pageSize : total / pageSize + 1;
}
private Long calculateStartIndex(Integer pageNo, Integer pageSize) {
return (long) (pageNo - 1) * pageSize + 1;
}
private Long calculateEndIndex(Long startIndex, Integer pageSize, Long total) {
Long endIndex = startIndex + pageSize - 1;
return Math.min(endIndex, total);
}startIndex 从 1 开始(不是 0)。
calculatePages 里 total % pageSize 没有做 pageSize 的 null 检查。pageSize 传 null 会 NPE。calculateStartIndex / calculateEndIndex 同样直接用 pageSize。构造 PageResult 时务必保证 pageSize 非 null —— 用 query.getPageSizeWithDefault() 就没问题。
异常到响应的映射
Web 层需要一个统一的地方把异常翻译成 Result。典型规则:
| 异常 | 响应 code | 响应 message |
|---|---|---|
BaseException | 异常自带的 code | 异常自带的 message |
| 其它异常 | SYSTEM_ERROR(10000) | 「系统内部错误,请稍后重试」 |
@ExceptionHandler(Exception.class)
public Result<Object> handleException(Exception e) {
logger.error("Unexpected Exception: {}", e.getMessage(), e);
return Result.fail(BaseErrorCode.SYSTEM_ERROR.getCode(), "系统内部错误,请稍后重试");
}系统异常不要把 e.getMessage() 直接返回给客户端 —— 里面常有 SQL 语句、文件路径、类名。详细信息只写进服务端日志。
详见 接入 Web 层。