跳到正文
Kale的数字花园
目录 文章
/8 分钟/8 阅读/0 评论

通用异常体系与全局响应封装

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 年梳理微服务异常处理时,发现两个核心问题:

  1. HTTP 200 + 业务错误(RestResponse.error())被 Feign 当成正常响应
  2. 各服务错误码定义混乱,没有统一号段

最终设计了: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. 面试题清单

  1. 【基础】@ControllerAdvice 和 @ExceptionHandler 的执行顺序是什么?多个 @ExceptionHandler 能同时生效吗?
    • 考察点:Spring MVC 异常处理链、ExceptionHandlerExceptionResolver 优先级
  2. 【基础】BusinessException 和 RemoteException 的核心区别是什么?分别在什么场景下使用?
    • 考察点:BusinessException=本服务,i18n key;RemoteException=远程服务返回,已处理过 i18n
  3. 【进阶】为什么用 HTTP Header X-CODO-IS-ERROR 来标识业务异常,而不是直接用 HTTP 状态码(如 400/403)?
    • 考察点:HTTP 语义 vs 业务语义、网关可能不传递自定义 Header、Feign 对 2xx 和非 2xx 的处理差异
  4. 【进阶】Feign 的 ErrorDecoder 在什么时候被调用?HTTP 200 但 body 中包含错误码时会被拦截吗?
    • 考察点:ErrorDecoder 只在非 2xx 触发;HTTP 200 的业务错误需要自定义 Decoder 或 Header 标识
  5. 【原理】Response Body 是 InputStream,只能读一次。FeignServerErrorDecoder 读取 Body 后,后续还会再读吗?
    • 考察点:ErrorDecoder 处于处理链末端,读完就结束;asReader() vs asInputStream()
  6. 【进阶】为什么错误码定义用 Interface + 静态方法,而不用枚举?
    • 考察点:枚举穷举限制、不可继承;Interface 可扩展、跨模块隔离
  7. 【设计】错误码号段分配策略(1300013999 / 2500025999)是如何设计的?为什么按号段分配?
    • 考察点:避免冲突、可追溯(看码知服务)、便于管理
  8. 【场景】如果 BaseRestExceptionHandler 捕获了 BusinessException,但 MessageSource 找不到对应的 i18n key,会发生什么?
    • 考察点:MessageSource 找不到 key 时抛出 NoSuchMessageException;框架中应兜底处理
  9. 【场景】跨服务调用时,RemoteException 的堆栈信息是否会丢失?为什么?
    • 考察点:HTTP 传输只传 message,不传堆栈;RemoteException 构造函数接收的 Throwable cause 可追溯原始异常
  10. 【延伸】如果需要将所有 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
相关阅读

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。