基于SpringCloudGateway的统一异常处理

image.png

1. 背景

在分布式的大环境下,集群部署越来越多,各个服务之间调用链路越来越长,每一种服务都有自己的错误码,如果一个服务异常,很难快速明了的通过错误码定位问题。并且错误码也散落在各个服务中,无法集中化管理,若每个服务自己来处理,耦合度过高。基于此背景,我们做了增强网关BetterGateway

2. 概述

针对上述弊端,结合现在分布式流行的设计,我们决定在网关处做具体的改造,每一个请求都要经过网关才能到达业务集群,经业务集群处理之后,请求再次经过网关返回给调用者。为了实现配置文件的集中管理,放眼现在流行的配置中心,我们采用Nacos作为配置中心。将错误码存放在Nacos中,利用Nacos动态刷新的功能,实现错误码的动态不停机动态更改。同时,我们还可以支持将配置文件存放在文件系统中,利用nio来监听文件的变动,实现动态刷新,如图1.1。

image_6.png

图1.1

为了实现通过错误码能准确快速的定位异常,我们做了如下的分析:
想要快速通过错误码能定位异常,就要规定一种特殊的错误码的组装规则,从这个错误码中,可以一眼看出是哪个服务提供商的哪个功能下的哪个接口异常。为此,我们规定了错误码的格式如图1.2。

image_7.png

图1.2

此外还要保证错误码的唯一性,这里的错误码是组装之后的错误码(请读者分辨每个服务的错误码,和组装之后的错误码的区别,下文中都用错误码来代替每个服务自己的错误码,如有地方需要用到组装之后的错误码,笔者会写清楚)。每个服务提供商下的错误码可能有重合。为了保证唯一性,我们针对不同的网络调用,有不同的规则:

服务之间的网络调用错综复杂,大体分为两类。

  • 1.服务内部调用。
    服务名+错误码。
  • 2.三方接口(外部网络)调用。
    请求url+错误码

对于服务内部调用,内部服务定义一堆错误码。比如1000、1001,每一个内部服务就是一个服务提供商,比如a服务调用b服务的submit接口,假如此接口异常,返回错误码为1000,即可以看作
b服务提供商下返回了1000。那么此服务就是一个服务提供商,服务名+错误码是唯一标识。对于三方接口(外部服务)调用,我们采用了请求路径+该请求返回的状态码作为唯一标志。比如
b服务去调用了xxx公司的查询接口。该接口成功的返回200,失败返回1000(参数非法)、1001(鉴权失败)那么xxx公司就是一个服务提供商,查询接口+返回状态码就是唯一标识。

3. 功能

序号 功能 现阶段支持
1 错误码转换
2 热配置(Nacos)
3 热配置(文件系统)
4 动态鉴权
5 转发 ×
6 分发 ×
7 重试 ×
8 补偿 ×
9 动态返回值 ×

4. 设计

4.1 总体设计

BetterGateway是基于Spring Cloud Alibaba和Spring Cloud设计。同时也支持配置文件存放本地文件系统。本文中都是基于Spring Cloud Alibaba做示例。

BetterGateway总体设计

请求先到网关,网关转给下游的系统,如上图所示。网关调用A系统,A系统去调用B系统,B系统如果异常,B系统组装好错误码(主要是服务名和错误code)此时对于B系统来说有两种选择:

  • 抛出异常,通过全局异常处理类来处理此异常,并且返回给上游系统。
  • 自己处理,处理完后,将此错误码返回给上游系统。

A系统,接收接口返回结果,判断是否失败,如果失败,对于A系统来说此时也有两种选择:

  • 抛出异常,通过全局异常处理类来处理此异常,并且返回给上游系统。
  • 自己处理,处理完后,将此错误码返回给上游系统。

在A系统中不要改变B系统返回的错误码。主要是不要改变唯一标识(服务名+错误code或三方接口路径+code),经过A系统处理后,请求来到网关,网关判断该返回值是否成功。如果失败,就会按照配置文件来组装错误码,并且将组装之后的错误码返回给上游系统。

4.2 功能组件说明

4.2.1. 配置文件
  • 服务提供商总配置
    [
      {
        "code":"01",
        "name":"order-service",
        "dataId":"order-service.json",
        "domainName":"order-service",
        "authMethod":"token",
        "authConfig":{
          "ignorePath":[
            "/order/submit",
            "/order/cancel"
          ],
          "ignorePathPrefix": [
           "/common"
          ]
        },
        "type": "inner",
        "version": "5"
      }
    ]
    复制代码
    • code 服务提供商编码。
    • name 服务提供商名称。
    • dataId 服务提供商配置id。
    • domainName 服务提供商域。
    • authMethod 默认认证方式。
    • authConfig 额外认证方式。
      • ignorePath 不认证的路径(全路径)。
      • ignorePathPrefix 不认证的路径(前缀)。
    • type 服务提供商类别。
    • version 配置版本号,服务提供商错误码配置。
[
  {
    "url": "order-service",
    "featCode": "001",
    "errorCodeList": [
      {
        "errorCode": "5106",
        "code": "001",
        "type": "P",
        "tips": "当前用户无此节点操作权限",
        "handleStrategy": "",
        "handleParam": ""
      }
    ]
  },
  {
    "url": "order-service",
    "featCode": "002",
    "errorCodeList": [
      {
        "errorCode": "5012",
        "code": "001",
        "type": "P",
        "tips": "改订单已作废",
        "handleStrategy": "forward",
        "handleParam": "/viewCancelOrder"
      },
      {
        "errorCode": "5013",
        "code": "001",
        "type": "P",
        "tips": "订单正在处理中 ",
        "handleStrategy": "forward",
        "handleParam": "/viewDoingOrder"
      }
    ]
  },
  {
    "url": "/system/institution/dealer/search",
    "featCode": "",
    "errorCodeList": [
      {
        "errorCode": "",
        "code": "",
        "type": "",
        "tips": "",
        "handleStrategy": "distribute",
        "handleParam": "http://10.98.14.80/system/institution/dealer/search"
      }
    ]
  }
]
复制代码
  • url 接口地址(内部服务即服务名)。
  • featCode 功能域编码。
  • errorCodeList 错误码列表。
    • errorCode 错误码(内部)。
    • code 错误码(展示)。
    • type 错误类型。
    • tips 错误提示。
    • handleStrategy 处理策略。
    • handleParam 处理策略参数。

默认情况下,错误提示会覆盖原始响应中的 msg 属性,可以通过占位符来进行扩展,目前支持的占位符有:

  • #APPCODE#。用于在msg中展示转换后的错误码。
  • #MSG#。用于在msg中展示原始响应信息。

分发策略distribute针对的是入站请求,与错误码映射无关。因此在配置分发策略时,url为入站请求路径,其余错误码相关属性均设为空。

4.2.2 处理策略配置

支持的策略

  • distribute 分发
    • handleStrategy distribute。
    • handleParam 分发的目的地址,多个地址可用逗号隔开。
  • forward 转发 (暂不可用)
    • handleStrategy forward
    • handleParam 转发的目的地址
  • retry 重试 (暂不可用)
    • handleStrategy retry
    • handleParam 重试次数,重试间隔

4.2 相关改造

通过上面的介绍,错误码已经能快速准确的识别哪个服务异常。如果一个服务异常,此错误码就要透传到网关处。在调用链路中显示如图1.3所示。

image.png
图1.3

正所谓单丝不成线,独木不成林,BetterGateway的实际应用需要系统改造配合,所以我们建议使用下面的结构体,作为服务调用之间的统一返回值、自定义的异常(ApiException)和配套的全局异常处理(APIExceptionHandler)。

4.2.1 结构体

@Data
public class ApiResult<T> implements Serializable {
  private static final long serialVersionUID = 0xc93480e15321b2c5L;

  private static final Logger logger = LoggerFactory.getLogger(ApiResult.class);

  /**
   * 状态码
   */
  private Integer code;

  /**
   * 说明信息
   */
  private String msg;

  /**
   * 错误code
   */
  private String appCode;

  /**
   * 服务名/url
   */
  private String path;

  /**
   * 返回数据
   */
  private T data;

  public ApiResult() {
    this.path = PropertyUtils.getServiceName();
  }

  public ApiResult(ResultCode code) {
    this.code = code.code();
    this.msg = code.message();
    this.appCode = String.valueOf(code.code());
    this.path = PropertyUtils.getServiceName();
  }

  public ApiResult(Integer code, String msg) {
    this.code = code;
    this.msg = msg;
    this.appCode = String.valueOf(code);
    this.path = PropertyUtils.getServiceName();
  }

  public ApiResult(ResultCode resultCode, T data) {
    this.code = resultCode.getCode();
    this.msg = resultCode.getMsg();
    this.appCode = String.valueOf(resultCode.getCode());
    this.path = PropertyUtils.getServiceName();
    this.data = data;
  }

  public static <T> ApiResult<T> failure(Integer code, String path) {
    ApiResult<T> result = failure();
    result.setCode(code);
    result.setAppCode(String.valueOf(code));
    result.setPath(path);
    return result;
  }

  public static <T> ApiResult<T> failure(Integer code, String path, T data) {
    ApiResult<T> result = failure();
    result.setCode(code);
    result.setAppCode(String.valueOf(code));
    result.setPath(path);
    result.setData(data);
    return result;
  }

  public static ApiResult<String> failure(ResultCode code, String data, boolean isCustomErrorMessage) {
    ApiResult<String> result = failure(code, data);
    if (isCustomErrorMessage && Toolkit.isValid(data)) {
      result.setMsg(data);
    }
    return result;
  }

  public static <T> ApiResult<T> failure(APIException e) {
    ApiResult<T> result = failure();
    result.setAppCode(e.getAppCode());
    result.setPath(e.getPath());
    result.setMsg(e.getMsg());
    result.setData((T) e.getData());
    return result;
  }

  public static <T> ApiResult<T> success() {
    return new ApiResult<T>(ResultCode.SUCCESS);
  }

  public static <T> ApiResult<T> success(T data) {
    ApiResult<T> result = new ApiResult<T>(ResultCode.SUCCESS);
    result.setData(data);
    return result;
  }

  public static <T> ApiResult<T> success(T data, String msg) {
    ApiResult<T> result = new ApiResult<T>(ResultCode.SUCCESS.code(), msg);
    result.setData(data);
    return result;
  }

  public static <T> ApiResult<T> failure(ResultCode rc) {
    StackTraceElement se = Thread.currentThread().getStackTrace()[2];
    logger.error("failure result code: {}, msg: {}", rc.getCode(), rc.getMsg());
    logger.error("failure info: {} {} {}", se.getClassName(), se.getMethodName(), se.getLineNumber());
    return new ApiResult<T>(rc.getCode(), rc.getMsg());
  }

  public static <T> ApiResult<T> failure() {
    return failure(ResultCode.FAIL);
  }

  public static <T> ApiResult<T> failure(ResultCode code, T data) {
    ApiResult<T> result = failure(code);
    result.setData(data);
    return result;
  }
}
复制代码

也可以根据需要使用自定义的ApiResult,但需要有以下属性:

  • code:Integer类型的状态码,原始形态,不影响原功能。
  • appCode:String类型的状态码,可以存入更丰富的信息,以及第三方接口的非int状态码。转换后的状态码会覆盖此值。
  • msg:提示信息,转换后的提示信息会覆盖此值。
  • path:异常路径。一般约定第三方接口出错为接口url,内部服务调用为服务名。也可采用其他命名,在配置文件中配置即可。

    区分codeappCode是为了不影响原接口,如果是ApiResult的code原本就是String,直接使用即可,无需appCode。
    code不为成功时才会进行状态码映射,成功的状态配置在common包中的ResultCode枚举类。默认为200。

4.2.2 自定义的异常(ApiException)

@Data
public class APIException extends RuntimeException {

  private String appCode;
  private String path;
  private String msg;
  private Object data;

  public APIException() {
  }

  public APIException(String appCode, String path) {
    this.appCode = appCode;
    this.path = path;
  }

  public APIException(String appCode, String path, Object data) {
    this.appCode = appCode;
    this.path = path;
    this.data = data;
  }

  public APIException(String appCode, String path, String msg, Object data) {
    super(msg);
    this.appCode = appCode;
    this.path = path;
    this.msg = msg;
    this.data = data;
  }

  public APIException(ApiResult apiResult) {
    this.appCode = apiResult.getAppCode();
    this.path = apiResult.getPath();
    this.msg = apiResult.getMsg();
    this.data = apiResult.getData();
  }
}
复制代码

4.2.3全局异常处理(APIExceptionHandler)

@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class APIExceptionHandler {

  private static final Logger logger = LoggerFactory.getLogger(APIExceptionHandler.class);

  @ExceptionHandler(value = APIException.class)
  public ApiResult<Object> handleBizException(APIException e) {
    logger.error("发生接口调用异常 原因是:", e);
    ApiResult<Object> result = new ApiResult<>(ResultCode.FAIL);
    result.setAppCode(e.getAppCode());
    result.setPath(e.getPath());
    result.setMsg(e.getMsg());
    result.setData(e.getData());
    return result;
  }
}
复制代码

5. 重要的类

5.1 过滤器

  • 【CacheRequestBodyFilter】 缓存请求体。
  • 【RemoveCachedBodyFilter】 释放缓存的请求体。
  • 【GlobalExceptionHandler】 异常处理。
    • 非网关服务异常,此类异常,网关要做错误码转换。
    • 网关异常,网关也是一个java进程,自身可能也会发生异常。
  • 【ErrorCodeFilter】 错误码转换。
  • 【ErrorCodeSpringFilter】 错误码转换,和上面不同的是,此类利用了spring-cloud-gateway提供的方式。
  • 【AuthFilter】 鉴权,策略模式实现不同服务商的动态鉴权。

5.2 配置文件的变动

  • 【ConfigDataChangeContainer】 存放最新的配置文件。
  • 【CoreContainerService】 操作 ConfigDataChangeContainer 类,用于对比数据,拉取新数据。
  • 【NacosClient】 nacos客户端,从Nacos中拉取新的配置文件。
  • 【FileClient】 文件读取客户端。从文件系统中拉取配置文件。如果要启动本地文件读取,则dataId为全路径。
  • 【NacosConfChangeHandler】 Nacos监听处理类,用于接受配置中心中配置的文件。
  • 【FileConfigChangeHandler】 File监听处理类,监视文件的变动、读取文件。

5.3 几个特别的接口

  • 【DataFormatConversion】 此接口用于通知配置文件的变动,将最新的配置文件发送给实现了这个接口的类。
  • 【CompensationService】 此接口用于处理请求失败时候之后的补偿措施。
  • 【AuthService】 鉴权接口。

6. 测试

测试案例中,有四个角色,前端系统、网关、sys-service(系统服务)、api-service(外部服务接口服务)、三方数据接口。如图1.4。
img_7.png

图1.4

下面的几个例子中,针对不同的情况,展示不同服务之间和三方接口的异常,和网关转换之后的错误码。

6.1: sys服务正常

http://localhost:81/sys/sysNormal
前端直接请求sys-service,sys-service没有异常。

img_6.png

6.2: api服务正常

http://localhost:81/sys/apiNormal
前端直接请求sys-service,sys-service调用api-service,api-service没有异常。

img_1.png

6.3 sys服务异常

http://localhost:81/sys/sysException
前端直接请求sys-service,sys-service发生异常,网关转换错误码。

img_8.png

6.4 api服务异常

http://localhost:81/sys/apiException
前端直接请求sys-service,sys-service调用api-service,api-service发生异常,网关转换错误码。

img_3.png

6.5 api调用外部接口异常

http://localhost:81/sys/apiCallBaiduException
前端直接请求sys-service,sys-service调用api-service,api-service调用三方外部接口,外部接口异常,网关转换错误码。

img_4.png

6.6 api调用外部接口异常,没有配置错误码。

http://localhost:81/sys/apiCallBaiduException
前端直接请求sys-service,sys-service调用api-service,api-service调用三方外部接口,外部接口异常,没有配置该接口的错误码转换,展示最原始的错误码。

img_5.png


南京三百云信息科技有限公司(车300)成立于2014年3月27日,是一家扎根于南京的移动互联网企业,目前坐落于南京、北京。经过7年积累,累计估值次数已达52亿次,获得了国内外多家优质投资机构青睐如红杉资本、上汽产业基金等。
三百云是国内优秀的以人工智能为依托、以汽车交易定价和汽车金融风控的标准化为核心产品的独立第三方的汽车交易与金融SaaS服务提供商。

欢迎加入三百云,一起见证汽车行业蓬勃发展,期待与您携手同行!
公司官网:www.sanbaiyun.com/
投递简历:hr@che300.com,请注明来自掘金😁