一线大厂的最佳实践 - API 错误处理


来自:猿天地

今天的主题是API错误处理

正所谓一入江湖身不由己

至今还在探索中

如今到底如何

↓↓

-

1

-

前言

API 中的错误如何定义,请求过程中出错或请求处理中出错。API 无法解析传递的数据,API 本身有很多问题,甚至格式正确的请求也会进行失败。在这两种情况下,都需要进行分析查找原因。

无论是代码形式的错误还是简单的错误响应,错误代码可能是 API 领域中最有用的诊断元素,错误代码非常有用。API 响应阶段中的错误代码是开发人员可以将故障传达给用户的基本方式。

-

2

-

编写良好的错误代码

好的错误代码必须通过三个基本标准,才能真正发挥作用。好的错误代码应包括:

  • 业务域标识,因此可以轻松确定问题的根源和领域;

  • 内部参考 ID,用于特定于文档的错误符号。在某些情况下,只要内部参考表中包含 HTTP 状态码方案或类似的参考资料,就可以替换 HTTP 状态码。

  • 人工可读的消息,概述了当前错误的上下文,原因和一般解决方案。

-

3

-

业界主流的处理方式

facebook

curl https://graph.facebook.com/v2.9/me?fields=id%2Cname%2Cpicture%2C%20picture&access_token=xxxxxxxxxxx

{
  error: {
    message: "An active access token must be used to query information about the current user.",
    type: "OAuthException",
    code: 2500,
    fbtrace_id: "ABdaipBGDyGFOyVCgrBfL56"
  }
}

Twitter

curl https://api.twitter.com/1.1/statuses/mentions_timeline.json
{
  errors: [{
    code: 215,
    message: "Bad Authentication data."
  }]
}

-

4

-

错误代码的定义

请求过程中出错,未进入处理逻辑。

{
   "domain": "pay",
   "code": 10501002,
   "message": "参数错误",
   "errors": [{
     "name": "bankNo",
     "message": "银行卡号不符合规范"
   }]
}

请求处理中出错

{
   "domain": "order",
   "code": 111501002,
   "message": "支付通道网络异常"
}
{
   "domain": "user",
   "code": 100501001,
   "message": "对应的用户不存在!"
}

错误代码详细说明:

  • domain 定义了领域,方便定位错误的根源。

  • code 定义了内部错误的编码

  • message 描述了错误的原因

  • error 对部分具体性错误进行了详细的说明

code 补充说明:异常码说明是由 8位 数字组成,前三位系统标识(从100开始),中间两位是模块标识(业务划分),后三位是异常标识(特定异常)

error 补充说明:当 message 不能准确描述错误产生的原因,需要细化每项错误说明时,可考虑使用 error 字段,来补充说明错误项。

domain 补充说明:底层框架里面封装了部分异常处理,比如参数校验错误这种 code 应该是全系统共用的,而不会有系统标识。导致就不能根据 code 识别出来是哪个系统发生错误了,链路一长就很难排查到底是哪的问题了,所以错误处理中动态去拿当前应用的业务域标识。

-

5

-

错误处理-Spring Boot

定义 Response 模型

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Data; 
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;


/**
 * Result
 *
 * @author Weichao Li (liweichao0102@gmail.com)
 * @since 2019-08-11
 */
@Data
@AllArgsConstructor
@ApiModel("统一 Response 返回值")
public class Result<T> implements Serializable {


  private static final long serialVersionUID = 1L;
  public static final long SUCCESS_CODE = 200L;
  public static final String DEFAULT_SUCCESS_MESSAGE = "success";
  @ApiModelProperty(name = "业务域或应用标识", notes = "仅当产生错误时会赋值该字段")
  private String domain;
  @ApiModelProperty(name = "结果码", notes = "正确响应时该值为 Result#SUCCESS_CODE,错误响应时为错误代码")
  private long code;
  @ApiModelProperty(name = "人工可读的消息", notes = "正确响应时该值为 Result#DEFAULT_SUCCESS_MESSAGE,错误响应时为错误信息")
  private String msg;
  @ApiModelProperty(name = "响应体", notes = "正确响应时该值会被使用")
  private T data;
  /**
   * 当验证错误时,各项具体的错误信息
   */
  @ApiModelProperty("错误信息")
  private List<Error> errors;


  public Result(T data) {
    this.setData(data);
    this.setCode(SUCCESS_CODE);
    this.setMsg(DEFAULT_SUCCESS_MESSAGE);
  }


  public Result() {
    this.setCode(SUCCESS_CODE);
    this.setMsg(DEFAULT_SUCCESS_MESSAGE);
  }


  public void addError(String name, String message) {
    if (this.errors == null) {
      this.errors = new ArrayList<>();
    }
    this.errors.add(new Error(name, message));
  }


  @Data
  @AllArgsConstructor
  @NoArgsConstructor
  @ApiModel("统一 Response 返回值中错误信息的模型")
  class Error {
    @ApiModelProperty(name = "错误项", notes = "错误的具体项")
    private String name;


    @ApiModelProperty(name = "错误项说明", notes = "错误的具体项说明")
    private String message;
    }
}

异常拦截器处理

Spring Boot 的项目已经对有一定的异常处理了,但是比较泛化不够精细化,因此需要基础框架对这些异常进行统一的捕获并处理。Spring Boot 中有一个 @RestControllerAdvice  的注解,使用该注解表示开启了全局异常的捕获,我们只需在自定义一个方法使用 ExceptionHandler 注解然后定义捕获异常的类型即可对这些捕获的异常进行统一的处理。

定义异常基础类

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor; 


/**
 * 扩展异常
 *
 * @author Weichao Li (liweichao0102@gmail.com)
 * @since 2019-08-11
 */
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ExtensionException extends RuntimeException {


  /**
     * 业务异常码 ( 详情参加文档说明 )
     */
  private Long code;


  /**
     * 业务异常信息
     */
  private String message;


  /**
     * 额外数据,可支持扩展
     */
  private Object data;




  /**
     * cause
     */
  private Throwable cause;




  public ExtensionException(Long code, String message) {
    this.code = code;
    this.message = message;
  }
}

全局异常处理器 - 信息枚举

import lombok.Getter; 




/**
 * WebMvc 模块异常码定义
 * <p>
 * 系统标识:100
 * 模块标识:02
 *
 * @author Weichao Li (liweichao0102@gmail.com)
 * @since 2019-08-11
 */
@Getter
public enum EnumExceptionMessageWebMvc {




    // 非预期异常
    UNEXPECTED_ERROR(10002000L, "服务发生非预期异常,请联系管理员!"),
    PARAM_VALIDATED_UN_PASS(10002001L, "参数校验(JSR303)不通过,请检查参数或联系管理员!"),
    NO_HANDLER_FOUND_ERROR(10002002L, "未找到对应的处理器,请检查 API 或联系管理员!"),
    HTTP_REQUEST_METHOD_NOT_SUPPORTED_ERROR(10002003L, "不支持的请求方法,请检查 API 或联系管理员!"),
    HTTP_MEDIA_TYPE_NOT_SUPPORTED_ERROR(10002004L, "不支持的互联网媒体类型,请检查 API 或联系管理员"),
    ;
    private final Long code;


    private final String message;


    EnumExceptionMessageWebMvc(Long code, String message) {
        this.code = code;
        this.message = message;
     }
}

全局异常处理器

import com.github.hicolors.best.practices.exception.ExtensionException;
import com.github.hicolors.best.practices.pojo.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.HttpMediaTypeNotSupportedException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException; 
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.text.MessageFormat;
import java.util.Objects; 


/**
 * ExceptionHandlerAdvice
 *
 * @author Weichao Li (liweichao0102@gmail.com)
 * @since 2019/11/25
 */
@RestControllerAdvice
@Slf4j
public class ExceptionHandlerAdvice {


  //业务领域标识,首先去配置中的业务领域标识,没有之后取应用名称,最后取默认值
  @Value("${spring.application.domain:${spring.application.name:unknown-spring-boot}}")
  private String domain; 


  /**
   * 针对业务异常的处理
   *
   * @param exception 业务异常
   * @param request   http request
   * @param response  http response
   * @return 异常处理结果
   */
  @ExceptionHandler(value = ExtensionException.class)
  public Result extensionException(ExtensionException exception, HttpServletRequest request,
      HttpServletResponse response) {
    log.warn("请求发生了预期异常,出错的 url [{}],出错的描述为 [{}]", request.getRequestURL().toString(), exception.getMessage());
    Result result = new Result();
    result.setDomain(domain);
    result.setCode(exception.getCode());
    result.setMsg(exception.getMessage());
    Object data = exception.getData();
    if (Objects.nonNull(data)) {
        // todo 策略调整
    }
    return result;
  } 


  /**
   * 针对参数校验失败异常的处理
   *
   * @param exception 参数校验异常
   * @param request   http request
   * @param response  http response
   * @return 异常处理结果
   */
  @ExceptionHandler(value = {BindException.class, MethodArgumentNotValidException.class, ConstraintViolationException.class})
  public Result databindException(Exception exception, HttpServletRequest request, HttpServletResponse response) {
        log.error(MessageFormat.format("请求发生了非预期异常,出错的 url [{0}],出错的描述为 [{1}]",
                request.getRequestURL().toString(), exception.getMessage()), exception);
        Result result = new Result();
        result.setDomain(domain);
        result.setCode(EnumExceptionMessageWebMvc.PARAM_VALIDATED_UN_PASS.getCode());
        result.setMsg(EnumExceptionMessageWebMvc.PARAM_VALIDATED_UN_PASS.getMessage());




    if (exception instanceof BindException) {
      for (FieldError fieldError : ((BindException) exception).getBindingResult().getFieldErrors()) {
              result.addError(fieldError.getField(), fieldError.getDefaultMessage());
        }
        } else if (exception instanceof MethodArgumentNotValidException) {
          for (FieldError fieldError : ((MethodArgumentNotValidException) exception).getBindingResult().getFieldErrors()) {
                result.addError(fieldError.getField(), fieldError.getDefaultMessage());
            }
        } else if (exception instanceof ConstraintViolationException) {
          for (ConstraintViolation cv : ((ConstraintViolationException) exception).getConstraintViolations()) {
                result.addError(cv.getPropertyPath().toString(), cv.getMessage());
            }
        }
    return result;
    } 


  /**
   * 针对spring web 中的异常的处理
   *
   * @param exception Spring Web 异常
   * @param request   http request
   * @param response  http response
   * @return 异常处理结果
   */
  @ExceptionHandler(value = {
            NoHandlerFoundException.class,
            HttpRequestMethodNotSupportedException.class,
            HttpMediaTypeNotSupportedException.class
    })
    public Result springWebExceptionHandler(Exception exception, HttpServletRequest request, HttpServletResponse response) {
        log.error(MessageFormat.format("请求发生了非预期异常,出错的 url [{0}],出错的描述为 [{1}]",
                request.getRequestURL().toString(), exception.getMessage()), exception);
        Result result = new Result();
        result.setDomain(domain);
     if (exception instanceof NoHandlerFoundException) {
              result.setCode(EnumExceptionMessageWebMvc.NO_HANDLER_FOUND_ERROR.getCode());
              result.setMsg(EnumExceptionMessageWebMvc.NO_HANDLER_FOUND_ERROR.getMessage());
      } else if (exception instanceof HttpRequestMethodNotSupportedException) {
              result.setCode(EnumExceptionMessageWebMvc.HTTP_REQUEST_METHOD_NOT_SUPPORTED_ERROR.getCode());
              result.setMsg(EnumExceptionMessageWebMvc.HTTP_REQUEST_METHOD_NOT_SUPPORTED_ERROR.getMessage());
      } else if (exception instanceof HttpMediaTypeNotSupportedException) {
              result.setCode(EnumExceptionMessageWebMvc.HTTP_MEDIA_TYPE_NOT_SUPPORTED_ERROR.getCode());
              result.setMsg(EnumExceptionMessageWebMvc.HTTP_MEDIA_TYPE_NOT_SUPPORTED_ERROR.getMessage());
      } else {
              result.setCode(EnumExceptionMessageWebMvc.UNEXPECTED_ERROR.getCode());
              result.setMsg(EnumExceptionMessageWebMvc.UNEXPECTED_ERROR.getMessage());
      }
       return result;
    }  


  /**
   * 针对全局异常的处理
   *
   * @param exception 全局异常
   * @param request   http request
   * @param response  http response
   * @return 异常处理结果
   */
  @ExceptionHandler(value = Throwable.class)
     public Result throwableHandler(Exception exception, HttpServletRequest request, HttpServletResponse response) {
        log.error(MessageFormat.format("请求发生了非预期异常,出错的 url [{0}],出错的描述为 [{1}]",
                request.getRequestURL().toString(), exception.getMessage()), exception);
        Result result = new Result();
        result.setDomain(domain);
        result.setCode(EnumExceptionMessageWebMvc.UNEXPECTED_ERROR.getCode());
        result.setMsg(EnumExceptionMessageWebMvc.UNEXPECTED_ERROR.getMessage());
         return result;
     }
}

使用

业务开发异常使用

  

// 此处只是简单演示,逻辑处理应该抽象在 mvc 分层中,业务开发过程中只需要抛异常即可。
@GetMapping
public String get() {
    throw new ExtensionException(105001001L, "simple 资源不存在");
}

基础框架异常使用

model

@Data
public class ValidatedModel {


  @NotNull(message = "id 不能为空")
  @Min(value = 10, message = "id 不能小于 10")
  private Long id;


  @NotBlank(message = "name 不能为空")
  @Length(max = 5, message = "name 长度不能超过 5")
  private String name;
 
}

controller

// 此处只是简单演示
@PostMapping("/test/validated")
public String getx(@Validated @RequestBody ValidatedModel model) {
    return model.getName();
}

代码链接

https://github.com/hiColors/best-practices-api-error-handling

特别推荐一个分享架构+算法的公众号,还没关注的小伙伴,可以长按关注一下:

长按订阅更多精彩▼

如有收获,点个在看,诚挚感谢

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值