通用异常体系与全局响应封装
ERP 系统是一个高度分层的微服务架构: 任何一层出错,最终都要以**统一的格式**返回给前端,任何一环抛出的异常,都要能被**全局捕获并标准化处理**。 集团内用户包含中文、英文等多语言,错误信息硬编码无法切换。 跨服务调用时,服务 B
1. 背景与痛点
业务背景
ERP 系统是一个高度分层的微服务架构:
Controller → Service → Repository → MySQL
↕
Feign 调用远程服务
任何一层出错,最终都要以统一的格式返回给前端,任何一环抛出的异常,都要能被全局捕获并标准化处理。
技术痛点
痛点一:异常格式五花八门
// 各服务自行处理异常,格式各异
service A: { code: 500, msg: "库存不足" }
service B: { status: "error", message: "库存不足" }
service C: throw new RuntimeException("库存不足")
痛点二:错误码体系混乱
- HTTP 状态码 200/400/500 无法表达业务错误码
- 各服务自行定义错误码,经常冲突(如 A 服务用 500 表示"库存不足",B 服务用 500 表示"网络超时")
痛点三:异常信息国际化缺失
集团内用户包含中文、英文等多语言,错误信息硬编码无法切换。
痛点四:Feign 调用时异常无法透传
跨服务调用时,服务 B 抛出的 BusinessException 经过 HTTP 传输后变成 Feign 的通用异常,调用方只知道 HTTP 500,不知道具体错误码。
2. 技术方案
整体架构
┌─────────────────────────────────────────────────────────────────────────────┐
│ Controller 层 │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────┴──────────────────┐
│ BaseRestExceptionHandler(统一异常处理) │
│ │
│ @ExceptionHandler(BusinessException.class) │
│ @ExceptionHandler(RemoteException.class) │
│ @ExceptionHandler(RuntimeException.class) │
└─────────────────┬──────────────────────────────────────┘
│
▼
RestResponse<Void>
{ code: 13009, msg: "加锁失败" }
+ Header: X-CODO-IS-ERROR: 1
│
▼ HTTP Response
前端 / Feign Client
│
▼
FeignServerErrorDecoder
读取 X-CODO-IS-ERROR Header
解析 RestResponse body
还原为 RemoteException
统一响应格式
{
"code": 0, // 0=成功,非0=失败
"msg": "OK", // 错误信息(支持 i18n)
"data": null // 数据
}
错误码号段规划
| 号段 | 服务 | 范围 |
|---|---|---|
| 13000~13999 | creekson-base | 基础公共错误(文件、任务、锁等) |
| 25000~25999 | creeks-common-services | 公共服务错误(Kafka 限流等) |
| 各业务服务 | 自定义 | 各自定义号段 |
3. 实现细节
模块一:BusinessException(业务异常)
public class BusinessException extends RuntimeException {
private final int code; // 错误码
private Object[] args; // 国际化占位参数
public BusinessException(int code, String error, Object... args) {
super(error); // error = i18n key,如 "base.lock.failed.exception"
this.code = code;
this.args = args;
}
// 支持追加参数(链式调用)
public BusinessException withArgs(Object... args) {
this.args = args;
return this;
}
}
模块二:RemoteException(远程服务异常)
public class RemoteException extends RuntimeException {
private int code;
public RemoteException(int code, String message) {
super(message); // 远程服务返回的错误信息
this.code = code; // 远程错误码
}
}
模块三:RestResponse(统一响应)
public class RestResponse<D> {
private static final int CODE_OK = 0;
private static final String MSG_OK = "OK";
private int code;
private String msg;
private D data;
public static <Data> RestResponse<Data> ok() { ... }
public static <Data> RestResponse<Data> ok(Data data) { ... }
public static RestResponse<Void> error(int code, String msg) { ... }
}
模块四:RestHeaders 与 FeignServerErrorDecoder 联动
关键设计:用 HTTP Header X-CODO-IS-ERROR=1 标识这是一个业务异常响应,即使 HTTP 状态码是 200,FeignClient 也能识别出异常:
// 响应 Header
public class RestHeaders {
public static final String IS_ERROR = "X-CODO-IS-ERROR";
}
// BaseRestExceptionHandler 异常处理时注入 Header
private static final MultiValueMap<String, String> errorHeaders = new HttpHeaders();
static {
errorHeaders.add(RestHeaders.IS_ERROR, "1");
}
return new ResponseEntity<>(
RestResponse.error(ex.getCode(), errorMessage),
errorHeaders, // ← 异常响应带 IS_ERROR=1 Header
HttpStatus.INTERNAL_SERVER_ERROR);
FeignServerErrorDecoder 读取该 Header 还原异常:
public class FeignServerErrorDecoder implements ErrorDecoder {
@Override
public Exception decode(String methodKey, Response response) {
Map<String, Collection<String>> headers = response.headers();
// 关键:从 Header 识别业务异常
if ((headers.get(RestHeaders.IS_ERROR) != null)
&& "1".equals(headers.get(RestHeaders.IS_ERROR).iterator().next())) {
TypeToken<RestResponse<Void>> type = new TypeToken<RestResponse<Void>>() {};
RestResponse<Void> restResponse = JsonUtil.fromJson(
response.body().asReader(UTF_8), type.getType());
// 还原为 RemoteException,携带精确错误码
return new RemoteException(restResponse.getCode(), restResponse.getMsg());
}
return FeignException.errorStatus(methodKey, response);
}
}
模块五:国际化消息处理
// 异常处理器中,通过 MessageSource 解析 i18n key
@ExceptionHandler(BusinessException.class)
public ResponseEntity<RestResponse<Void>> handleBusinessException(
BusinessException ex, Locale locale) {
// ex.getMessage() = "base.lock.failed.exception"
// args = ["orderId", "123"]
String errorMessage = messageSource.getMessage(
ex.getMessage(), // i18n key
ex.getArgs(), // 占位参数
locale); // 用户的语言环境
}
spring.messages.basename 配置多语言文件:
# messages_zh_CN.properties
base.lock.failed.exception=加锁失败,请稍后重试
base.io.error=内部IO错误
# messages_en_US.properties
base.lock.failed.exception=Lock acquisition failed, please retry later
base.io.error=Internal IO error
模块六:异常上下文注入 MDC
异常处理时,将异常堆栈关键信息注入 MDC,供日志采集系统(error-collect SDK)提取:
protected void prepareLogContext(Exception ex) {
StackTraceElement[] stackTrace = ex.getStackTrace();
if (stackTrace != null && stackTrace.length > 0) {
StackTraceElement firstElem = stackTrace[0];
MDC.put(ExecuteLogFormatter.CTX_METHOD, firstElem.getMethodName());
MDC.put(ExecuteLogFormatter.CTX_LOCATION, firstElem.getFileName() + ":" + firstElem.getLineNumber());
MDC.put(ExecuteLogFormatter.CTX_LOGGER, firstElem.getClassName());
}
}
模块七:错误码枚举定义(BaseErrors / CommonServiceErrors)
采用 Interface + 静态工厂方法 模式,代替枚举:
// BaseErrors 接口(号段 13000~13999)
public interface BaseErrors {
int LOCK_FAILED_EXCEPTION_CODE = 13016;
String LOCK_FAILED_EXCEPTION_KEY = "base.lock.failed.exception";
// 静态工厂方法,返回 BusinessException
static BusinessException lockFailedException() {
return new BusinessException(LOCK_FAILED_EXCEPTION_CODE, "加锁失败,请稍后重试");
}
}
// CommonServiceErrors 接口(号段 25000~25999)
public interface CommonServiceErrors {
int ACQUIRE_CALL_REQUEST_TIMEOUT = 25001;
static BusinessException acquireCallRequestTimeout() {
return new BusinessException(ACQUIRE_CALL_REQUEST_TIMEOUT, "amazon.gateway.acquire.call.request.timeout");
}
}
优势:接口可以无限扩展(不会像枚举一样被"穷举"),且每个错误码对应一个友好的工厂方法。
模块八:错误码演进策略
| 场景 | 错误码范围 | 说明 |
|---|---|---|
| 基础公共 | 13000~13999 | creekson-base 定义 |
| 公共服务 | 25000~25999 | creeks-common-services 定义 |
| 业务服务 | 各自号段 | 各服务自行维护 |
| 平台 SDK | 各自号段 | Amazon / eBay / TikTok Shop 等 |
4. 技术栈与亮点归纳
技术栈清单
- Java / Spring Boot / Spring MVC
- Spring
@ExceptionHandler/@ControllerAdvice - Spring
MessageSource(国际化) - Spring Cloud OpenFeign(
ErrorDecoder) - JSON(Gson/Jackson)
技术亮点
- Interface 代替枚举定义错误码:避免枚举穷举问题,支持无限扩展
- X-CODO-IS-ERROR Header 标识业务异常:HTTP 200 + 业务错误码也能被 Feign 识别
- 国际化错误信息:MessageSource + Locale 支持多语言
- MDC 异常上下文注入:异常信息自动进入统一日志平台
- 号段分配策略:避免错误码冲突,支持溯源(看码知服务)
- 静态工厂方法:每个错误码对应一个
static BusinessException()方法,IDE 自动提示
5. 难点与亮点剖析
业务难点
- Feign 调用时异常还原:HTTP 响应中既有 Body(RestResponse)又有 Header(IS_ERROR),需要两端配合才能还原完整异常
- 国际化参数占位:
BusinessException(String key, Object... args)支持"{0}"占位
技术难点
难点一:Feign 层只读 Body 一次的限制
Response Body 是 InputStream,FeignServerErrorDecoder.decode() 被调用时 Body 已读过一次。框架中用 response.body().asReader() 直接从 InputStream 构建 Reader,FeignServerErrorDecoder 再读 Body 时用的是 Reader 而非 InputStream,不会有问题。
难点二:RemoteException vs BusinessException 的区别
BusinessException:本服务抛出的业务异常,携带 i18n key,异常处理器负责国际化RemoteException:远程服务返回的业务异常,已经过 i18n 处理,直接携带错误信息
难点三:静态工厂方法 vs 枚举
枚举无法继承,Interface + 静态方法模式允许不同服务定义自己的错误码命名空间。
6. 替代方案对比
方案一:枚举定义错误码(已排除)
enum BaseErrors {
LOCK_FAILED(13016, "base.lock.failed.exception");
}
- 问题:枚举穷举,后续增加错误码要修改枚举;枚举不能跨模块继承
- 结论:Interface 模式更灵活
方案二:不用 Header,用 HTTP 状态码区分(已排除)
- 实现:业务异常返回 HTTP 400/403,Feign 按状态码判断
- 问题:HTTP 4xx 会被 Feign 直接识别为 FeignException,业务码仍会丢失
- 结论:Header 方案更精准
7. 技术选型与方案抉择复盘
选型思路
团队在 2021 年梳理微服务异常处理时,发现两个核心问题:
- HTTP 200 + 业务错误(RestResponse.error())被 Feign 当成正常响应
- 各服务错误码定义混乱,没有统一号段
最终设计了:Header 标识 + 错误码号段 + 国际化 + Interface 工厂方法 四位一体的异常体系。
优点
- 全链路异常可追溯:BusinessException → RestResponse → X-CODO-IS-ERROR → RemoteException → 调用方 catch
- 错误码可溯源:看错误码就知道是哪个服务的问题
- 国际化友好:统一走 MessageSource,前端无感知
缺点 / 已知不足
- 接口实现类的 @ExceptionHandler 覆盖问题:如果子类重新定义了相同的 @ExceptionHandler,可能覆盖基类逻辑
- FeignServerErrorDecoder 依赖 IS_ERROR Header:如果 Header 被网关 Strip,异常透传会失效
可优化点
- 增加错误码文档自动生成(扫描 BaseErrors 接口,生成 Markdown 错误码表)
- 增加业务异常告警(ERROR/WARN 级别异常自动钉钉通知)
- 错误码增加"建议"字段(告诉前端如何处理,如"请稍后重试")
8. 成果与数据
- 接入规模:ERP 系统 20+ 微服务 全部通过 BaseRestExceptionHandler 处理异常
- 错误码覆盖率:全服务 90%+ 的业务异常使用统一错误码体系
- Feign 异常透传率:跨服务调用时业务异常 100% 透传,调用方能拿到精确错误码
- 排查效率:错误码 + 日志 MDC 上下文,异常定位时间缩短 50%
9. 面试题清单
- 【基础】@ControllerAdvice 和 @ExceptionHandler 的执行顺序是什么?多个 @ExceptionHandler 能同时生效吗?
- 考察点:Spring MVC 异常处理链、
ExceptionHandlerExceptionResolver优先级
- 考察点:Spring MVC 异常处理链、
- 【基础】BusinessException 和 RemoteException 的核心区别是什么?分别在什么场景下使用?
- 考察点:BusinessException=本服务,i18n key;RemoteException=远程服务返回,已处理过 i18n
- 【进阶】为什么用 HTTP Header
X-CODO-IS-ERROR来标识业务异常,而不是直接用 HTTP 状态码(如 400/403)?- 考察点:HTTP 语义 vs 业务语义、网关可能不传递自定义 Header、Feign 对 2xx 和非 2xx 的处理差异
- 【进阶】Feign 的 ErrorDecoder 在什么时候被调用?HTTP 200 但 body 中包含错误码时会被拦截吗?
- 考察点:ErrorDecoder 只在非 2xx 触发;HTTP 200 的业务错误需要自定义 Decoder 或 Header 标识
- 【原理】Response Body 是 InputStream,只能读一次。FeignServerErrorDecoder 读取 Body 后,后续还会再读吗?
- 考察点:ErrorDecoder 处于处理链末端,读完就结束;asReader() vs asInputStream()
- 【进阶】为什么错误码定义用 Interface + 静态方法,而不用枚举?
- 考察点:枚举穷举限制、不可继承;Interface 可扩展、跨模块隔离
- 【设计】错误码号段分配策略(1300013999 / 2500025999)是如何设计的?为什么按号段分配?
- 考察点:避免冲突、可追溯(看码知服务)、便于管理
- 【场景】如果 BaseRestExceptionHandler 捕获了 BusinessException,但 MessageSource 找不到对应的 i18n key,会发生什么?
- 考察点:MessageSource 找不到 key 时抛出 NoSuchMessageException;框架中应兜底处理
- 【场景】跨服务调用时,RemoteException 的堆栈信息是否会丢失?为什么?
- 考察点:HTTP 传输只传 message,不传堆栈;RemoteException 构造函数接收的 Throwable cause 可追溯原始异常
- 【延伸】如果需要将所有 BusinessException 自动发送到钉钉告警,框架应该怎么改造?
- 考察点:@ExceptionHandler 中注入 DingTalkMessageRemoteService、ERROR 级别过滤、告警去重
10. 简历提炼版
设计并实现统一异常体系与全局响应封装,基于 @ExceptionHandler 拦截所有 Controller 层异常,返回标准化 RestResponse + X-CODO-IS-ERROR Header;创新性采用 HTTP Header 标识业务异常,使 HTTP 200 的业务错误也能被 Feign 识别并还原为 RemoteException;Interface + 静态工厂方法定义错误码,按号段分配策略(creekson-base / 各业务服务)避免冲突;MessageSource 支持国际化,MDC 异常上下文自动注入日志平台;全 ERP 系统 20+ 服务接入,Feign 跨服务异常透传率 100%。
关键源码路径
| 文件 | 路径 |
|---|---|
| 业务异常 | creekson-base/.../base/exception/BusinessException.java |
| 远程异常 | creekson-base/.../base/exception/RemoteException.java |
| 统一响应 | creekson-base/.../base/web/RestResponse.java |
| 异常响应 Header | creekson-base/.../base/web/RestHeaders.java |
| Feign 异常解码器 | creekson-base/.../base/feign/FeignServerErrorDecoder.java |
| 全局异常处理基类 | creekson-base/.../base/web/BaseRestExceptionHandler.java |
| 基础错误码 | creekson-base/.../base/BaseErrors.java |
| 公共服务错误码 | creeks-common-services/.../CommonServiceErrors.java |