异常体系与统一响应
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
public interface ErrorCode {
String getMessage();
/** 默认取 toString(),枚举实现时即枚举常量名 */
default String getCode() {
return this.toString();
}
/** 用 MessageFormat 做占位符替换;不传参数时直接返回原始消息;数字按原样写出 */
default String getMessage(Object... args) {
// 实现略:见下文关于数字的说明
}
}业务侧用枚举实现,getCode() 的默认实现直接返回枚举名,所以枚举常量名就是对外的错误码,命名要按第三节的规范来:
@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 及更早的版本没有这个处理,传标识时要自己先转成字符串。
异常基类
@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) { ... }
}用法:
// 无参数
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
@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 |
|---|---|---|
BusinessException | 400 | 业务错误码,消息按参数填充 |
ConcurrentUpdateException、AggregateBusyException、DuplicateRequestException | 409 | CONCURRENT_UPDATE、AGGREGATE_BUSY、DUPLICATE_REQUEST |
RateLimitedException | 429 | RATE_LIMITED |
SystemException | 500 | 系统错误码 |
参数校验失败(@Valid、@Validated、类型转换失败) | 400 | VALIDATION_ERROR,消息列出每个字段 |
| 请求体无法解析 | 400 | MALFORMED_REQUEST |
| Spring MVC 自身的异常(405、415、404 等) | 沿用原状态码 | 状态名,如 METHOD_NOT_ALLOWED |
| 其他未预料的异常 | 500 | SYSTEM_ERROR |
三点行为值得知道:
- 业务异常打
warn,不带堆栈;系统异常和未预料的异常打error,带堆栈。 业务异常是正常的流程分支,不是故障。 - 未预料的异常只返回通用文案。 原始异常的消息可能包含连接串、SQL、内部地址,完整内容只写进日志,不返回给调用方。
- 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(),对枚举来说就是常量名。这意味着枚举常量必须直接命名成状态码:
// 符合规范,但可读性差
ORDV100("库存不足");
// 可读性好,但违反规范 —— getCode() 会返回 "INSUFFICIENT_STOCK"
INSUFFICIENT_STOCK("库存不足");两者不可兼得,除非显式覆盖 getCode():
@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 这种字面量,几个月后没人知道它是什么。
相关文档
- DDK Web Starter — 全局异常处理、Jackson、CORS 配置
- 领域模型基类 — 领域层如何抛业务异常