soda docs v17.0.1

异常与错误码

soda 用一个统一的方式表达失败:错误码 + 中文消息。错误码定义在 BaseErrorCode,异常是 BaseException,校验用 ParamChecker,响应包装用 Result。

BaseErrorCode

com.hibuka.soda.foundation.error.BaseErrorCode,实现 CodeEnum<Integer>。共 48 个码,分三段:

区段范围含义
HTTP 语义码200 ~ 503与 HTTP 状态码对齐
框架通用码10000 ~ 10039技术层面的错误
业务码19999留给业务自定义

HTTP 语义码

枚举codedesc
SUCCESS200成功
BAD_REQUEST400请求错误
UNAUTHORIZED401未授权
FORBIDDEN403禁止访问
NOT_FOUND404资源不存在
INTERNAL_SERVER_ERROR500服务器内部错误
SERVICE_UNAVAILABLE503服务不可用

框架通用码

枚举codedesc
SYSTEM_ERROR10000系统错误
PARAM_ERROR10001参数错误
BUSINESS_ERROR10002业务错误
NETWORK_ERROR10003网络错误
DATABASE_ERROR10004数据库错误
VALIDATION_ERROR10005验证错误
AUTHENTICATION_ERROR10006认证错误
PERMISSION_ERROR10007权限错误
RESOURCE_ERROR10008资源错误
TIMEOUT_ERROR10009超时错误
CONFLICT_ERROR10010冲突错误
CONFIG_ERROR10011配置错误
RPC_ERROR10012RPC调用错误
CACHE_ERROR10013缓存错误
FILE_ERROR10014文件错误
ENCRYPT_ERROR10015加密错误
DECRYPT_ERROR10016解密错误
SERIALIZATION_ERROR10017序列化错误
DESERIALIZATION_ERROR10018反序列化错误
INVALID_FORMAT10019格式错误
INVALID_STATE10020状态错误
UNSUPPORTED_OPERATION10021不支持的操作
NULL_POINTER_ERROR10022空指针错误
INDEX_OUT_OF_BOUNDS_ERROR10023索引越界错误
DIVISION_BY_ZERO_ERROR10024除零错误
ILLEGAL_ARGUMENT_ERROR10025非法参数错误
ILLEGAL_STATE_ERROR10026非法状态错误
CONCURRENT_ERROR10027并发错误
DEADLOCK_ERROR10028死锁错误
MEMORY_ERROR10029内存错误
STACK_OVERFLOW_ERROR10030栈溢出错误
IO_ERROR10031IO错误
INTERRUPTED_ERROR10032中断错误
CLASS_NOT_FOUND_ERROR10033类未找到错误
NO_SUCH_METHOD_ERROR10034方法未找到错误
ILLEGAL_ACCESS_ERROR10035非法访问错误
INSTANTIATION_ERROR10036实例化错误
TYPE_ERROR10037类型错误
CAST_ERROR10038类型转换错误
VERIFICATION_ERROR10039验证错误

业务码

枚举codedesc
CUSTOM_ERROR19999自定义错误
技巧

业务自己的错误码应该继承 BaseErrorCode 的思路另建一套枚举,不要往 BaseErrorCode 里加。soda 的 BaseErrorCode 是框架共用的,改它会和其它工程冲突。

按码查消息

java
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) 取

静态工厂

java
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 还会自动记录抛出位置:

字段类型说明
codeint错误码
errorDataMap<String, Object>附加信息,含 timestamp、className、methodName、lineNumber 和你传入的额外数据
timestamplong创建时的毫秒时间戳
classNameString抛出点的类名
methodNameString抛出点的方法名
lineNumberint抛出点的行号

抛出点的定位逻辑是跳过 BaseException 自身和 Thread,取栈里的第一个「业务」帧:

java
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(如果重名)。

用法

java
// 最常见:仓储里包一层
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

java
public interface IErrorCode {
    int getCode();

    default String getMessage() {
        return BaseErrorCode.getMessage(this.getCode());
    }

    default Map<String, Object> getErrorData() {
        return null;
    }
}

这是一个可扩展的错误码接口。BaseErrorCode 实现它,业务也可以实现自己的:

java
@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:

java
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:

java
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 更稳妥。

用法

java
private void commandValidate(CreateProducerCommand command) throws BaseException {
    ParamChecker.notNull(command, "command");
    ParamChecker.notBlank(command.getName(), "名称不能为空");
    ParamChecker.lengthInRange(command.getName(), 1, 64, "名称长度必须在 1~64 之间");
}

带自定义错误码:

java
ParamChecker.notNull(command, BaseErrorCode.PARAM_ERROR.getCode(), "command 不能为空");

抛自定义异常:

java
ParamChecker.notNull(command, () -> new OrderException("订单不存在"));
ParamChecker.isTrue(amount > 0, () -> new OrderException("金额必须为正"));

Result

com.hibuka.soda.foundation.io.Result<T> 的工厂方法。

成功

方法codemessage
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)你传的你传的

失败

方法codemessage
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,可以链式调用:

java
Result.success(dto).setRequestId(requestId);

Result 的字段

字段类型默认值
requestIdStringnull
codeInteger200
messageString""
dataTnull
successBooleantrue
errorDataMap<String, Object>空 Map

PageResult

PageResult<T> 继承 Result<T>,多了八个分页字段:

字段类型说明
totalLong总记录数
pageNoInteger当前页码,从 1 开始
pageSizeInteger每页条数
pagesLong总页数
hasNextBoolean是否有下一页
hasPreviousBoolean是否有上一页
startIndexLong当前页起始索引
endIndexLong当前页结束索引

工厂方法

方法说明
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 升级。

分页元信息的计算:

java
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)「系统内部错误,请稍后重试」
java
@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 层。

下一步