Skip to content

异常体系与统一响应 ​

DDK 用 ErrorCode + AbstractException + ApiResponse 三件套统一错误表达。这篇讲它们的契约、状态码规范,以及 ddk-web-starter 当前的全局异常处理行为。

一、三层结构 ​

ErrorCode          错误码接口,业务方用枚举实现
AbstractException  异常基类,持有 ErrorCode 和格式化参数
  ├── BusinessException   业务异常 → HTTP 400
  │     ├── ConcurrentUpdateException   保存时版本已被推进 → 409
  │     ├── AggregateBusyException      聚合正被其他操作独占 → 409
  │     ├── DuplicateRequestException   重复提交 → 409
  │     └── RateLimitedException        超过限流阈值 → 429
  └── SystemException     系统异常 → HTTP 500
ApiResponse<T>     统一响应包装

ErrorCode ​

java
public interface ErrorCode {

    String getMessage();

    /** 默认取 toString(),枚举实现时即枚举常量名 */
    default String getCode() {
        return this.toString();
    }

    /** 用 MessageFormat 做占位符替换;不传参数时直接返回原始消息;数字按原样写出 */
    default String getMessage(Object... args) {
        // 实现略:见下文关于数字的说明
    }
}

业务侧用枚举实现,getCode() 的默认实现直接返回枚举名,所以枚举常量名就是对外的错误码,命名要按第三节的规范来:

java
@Getter
@AllArgsConstructor
public enum OrderError implements ErrorCode {

    ORDV001("订单不能为空"),
    ORDV100("库存不足,当前库存 {0},需要 {1}"),
    ORDE201("支付渠道连接失败");

    private final String message;
}

注意占位符用的是 MessageFormat 语法 {0}、{1},不是 slf4j 的 {}。写错了不会报错,只是占位符不被替换。

数字按原样写出(0.6.0 起)。MessageFormat 默认会给数字加千分位,订单号 1234567 填进 订单不存在:{0} 会变成 1,234,567,用户拿着它既搜不到订单也对不上日志。DDK 在填充前把数字转成字符串;占位符里明确写了格式的(如 {0,number,#,##0.00})仍按写的格式来。0.5.0 及更早的版本没有这个处理,传标识时要自己先转成字符串。

异常基类 ​

java
@Getter
public abstract class AbstractException extends RuntimeException {

    private final ErrorCode errorCode;
    private Object[] args;

    public AbstractException(ErrorCode errorCode) { ... }
    public AbstractException(ErrorCode errorCode, Object... args) { ... }
    public AbstractException(ErrorCode errorCode, Throwable t) { ... }
    public AbstractException(ErrorCode errorCode, Throwable t, Object... args) { ... }
}

用法:

java
// 无参数
throw new BusinessException(OrderError.ORDV001);

// 带占位符参数
throw new BusinessException(OrderError.ORDV100, currentStock, required);

// 包装底层异常
throw new SystemException(OrderError.ORDE201, e);

选 BusinessException 还是 SystemException,判断标准是这个错误是否可以由调用方通过修改请求来避免:库存不足、参数非法、状态不允许 → 业务异常;数据库连不上、下游超时、序列化失败 → 系统异常。

这个区分不是分类癖好,它决定了两件实际的事:HTTP 状态码(400 vs 500),以及日志级别(warn 不带栈 vs error 带栈)。

ApiResponse ​

java
@Data
public class ApiResponse<T> {
    private String code;
    private String message;
    private T data;
    private Long timestamp;

    public static <T> ApiResponse<T> ofSuccess() { ... }
    public static <T> ApiResponse<T> ofSuccess(T data) { ... }
    public static <T> ApiResponse<T> ofFail(ErrorCode code, Object... args) { ... }
}

成功时 code 固定是字符串 "SUCCESS",失败时是 ErrorCode.getCode()。

二、全局异常处理 ​

ddk-web-starter 的 BaseExceptionHandler 把异常翻译成 ApiResponse,由自动配置注册,应用声明同类型的 Bean 可以替换它。

异常状态码code
BusinessException400业务错误码,消息按参数填充
ConcurrentUpdateException、AggregateBusyException、DuplicateRequestException409CONCURRENT_UPDATE、AGGREGATE_BUSY、DUPLICATE_REQUEST
RateLimitedException429RATE_LIMITED
SystemException500系统错误码
参数校验失败(@Valid、@Validated、类型转换失败)400VALIDATION_ERROR,消息列出每个字段
请求体无法解析400MALFORMED_REQUEST
Spring MVC 自身的异常(405、415、404 等)沿用原状态码状态名,如 METHOD_NOT_ALLOWED
其他未预料的异常500SYSTEM_ERROR

三点行为值得知道:

  1. 业务异常打 warn,不带堆栈;系统异常和未预料的异常打 error,带堆栈。 业务异常是正常的流程分支,不是故障。
  2. 未预料的异常只返回通用文案。 原始异常的消息可能包含连接串、SQL、内部地址,完整内容只写进日志,不返回给调用方。
  3. Spring MVC 自己的异常保留原状态码。 请求方法不对仍然是 405,不会被兜底处理变成 500。

这些行为都有针对 HTTP 响应的测试,断言的是状态码和响应体,而不是「注册了哪个 Bean」。

框架自带的错误码在 CommonError 里:VALIDATION_ERROR、MALFORMED_REQUEST、CONCURRENT_UPDATE、AGGREGATE_BUSY、DUPLICATE_REQUEST、RATE_LIMITED、SYSTEM_ERROR。业务错误码按领域各自定义,不要集中到一个巨大的枚举里。引入 springdoc 后,这些状态码和全部错误码会自动写进接口文档,见 Web starter。

三、状态码规范 ​

这一节是一套可选的编码规范,适合需要跨系统统一错误码格式的团队。DDK 自己的 CommonError 和用户示例没有采用它,直接用可读的枚举名作为错误码(VALIDATION_ERROR、USER_NOT_FOUND)。两种方式 ErrorCode 都支持,选一种在团队内保持一致即可。

所有状态码为字符串,格式:

[系统前缀 3 位][类型 1 位][错误码 3 位]

总长 7 位,只用大写字母和数字。成功是唯一例外,固定为 SUCCESS。

字段长度说明
系统前缀3标识所属系统或模块
类型1标识错误级别
错误码3区分同类型下的具体错误

系统前缀 ​

按模块划分,示例:

  • SYS 核心系统
  • AUT 认证模块
  • ORD 订单模块
  • PAY 支付模块
  • CMS 内容管理

类型 ​

  • E 错误(Error)——系统级失败:三方调用异常、数据库操作失败
  • V 校验失败(Validation)——参数缺失、格式错误、业务规则不满足
  • W 警告(Warning)——异步/多线程场景下的异常告警

错误码 ​

三位数字,建议按段分配以便快速定位:

  • 0xx 参数与校验
  • 1xx 业务规则
  • 2xx 外部依赖
  • 9xx 未分类

示例 ​

状态码含义
SUCCESS成功
AUTE001认证失败,用户名或密码错误
ORDV100订单创建失败,库存不足
PAYE201支付失败,支付渠道连接错误
CMSV001内容校验失败,标题不能为空
SYSW002日志文件创建异常

规范落地的一个问题 ​

ErrorCode.getCode() 默认返回 this.toString(),对枚举来说就是常量名。这意味着枚举常量必须直接命名成状态码:

java
// 符合规范,但可读性差
ORDV100("库存不足");

// 可读性好,但违反规范 —— getCode() 会返回 "INSUFFICIENT_STOCK"
INSUFFICIENT_STOCK("库存不足");

两者不可兼得,除非显式覆盖 getCode():

java
@Getter
@AllArgsConstructor
public enum OrderError implements ErrorCode {

    INSUFFICIENT_STOCK("ORDV100", "库存不足,当前库存 {0},需要 {1}"),
    EMPTY_ORDER("ORDV001", "订单不能为空");

    private final String code;      // 覆盖默认实现,常量名可以取得可读
    private final String message;
}

推荐后一种写法:常量名给开发者看,code 给前端和日志用。前一种写法在代码里满屏 ORDV100 这种字面量,几个月后没人知道它是什么。

相关文档 ​

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权