likes
comments
collection
share

如何优雅的设计接口状态码和异常

作者站长头像
站长
· 阅读数 2

原文:赵侠客

一、前言

目前大多互联网应用后端输出数据协议都是使用HTTP协议+JSON数据格式,HTTP协议里定义了一系列的状态码用来表明请求的状态,如常用的200表示请求正常,404表示请求的资源不存在。由于这些状态数量是有限的,无法完整的表达我们业务中的各种状态,所以一般会在返回的JSON中增加业务状态码,如请求参数不对、用户状态禁用、用户名密码错误等。首先要搞清楚HTTP状态码和我们业务状态的关系, 我们看一个简单的HTTP协议报文:

GET http://localhost/test?id=2

HTTP/1.1 200 
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 11 Mar 2024 06:42:39 GMT
Keep-Alive: timeout=60
Connection: keep-alive

{
  "code": "OK",
  "message": "OK",
  "data": {
    "id": 2,
    "userName": "test3@8531.cn"
  }
}

上面是一个简单的返回JSON数据的GET请求,其中响应头中的HTTP/1.1 200表明HTTP状态码是200,响应Body中的"code": "OK",是我们业务里定义的状态码。

HTTP状态码

HTTP 状态码是由 HTTP 协议定义的,用于表示 Web 服务器对请求的响应状态,每一个状态码都有特定的含义。虽然开发者可以自定义 HTTP 状态码,但并不推荐这样做,因为这可能会引起混淆或者与将来的 HTTP 规范相冲突。HTTP 状态码的值是三位数字,其中第一位数字表示响应类别,目前有以下五个类别:

  • 1xx:表示请求已被接收,需要继续处理;
  • 2xx:表示请求已成功被服务器接收、理解、并接受;
  • 3xx:重定向,需要客户端采取进一步的操作才能完成请求;
  • 4xx:客户端错误,表示请求包含语法错误或者无法完成请求;
  • 5xx:服务器错误,服务器在处理请求的过程中发生了错误。

HTTP状态码有非常多的作用:

  1. 服务器通知客户端:状态码用于指示网页请求的处理结果,帮助客户端了解发生了什么事件;
  2. 便于程序处理:三位数字的状态码便于自动化程序和脚本解析和处理响应结果;
  3. 便于用户理解:状态消息(状态码后面的文本)为用户提供关于响应的额外信息,帮助用户理解发生了什么问题;
  4. 指导后续操作:例如,301状态码表示资源已永久移动到新地址,客户端应使用新地址重新发送请求;
  5. 便于监控报警:通过监控分析nginx的接口请求日志,可以监控到服务异常从而发送报警消息。

业务状态码

业务状态码是在 HTTP 状态码之上,由应用程序自身定义的,以反映特定业务逻辑的状态。这些状态码可以针对不同的操作不同的条件提供更详细更具体的信息,以便客户端能够更好地理解和处理业务流程,根据不同的状态码采取相应的处理措施。业务状态码的主要作用有:

  1. 方便与前端开发对接:前端在请求接口时通过判断非正常业务状态码,可以给用户对应的提示;
  2. 方便对业务更进一步的监控:比如用户名密码错误用USER_PASSWORD_ERROR,可以分析日志,监控用户登录错误的请求,这个监控是HTTP状态码无法实现的;
  3. 提升用户操作体验:良好的错误提示可以大大提升用户体验,有些系统用户操作失败全部提示“系统异常”,如果是“用户名不存在”、“用户密码错误”等提示用户体验就非常好了。

HTTP状态码和业务状态码的关系

业务状态码应该是包含的HTTP状态码,在实际项目开发中很多开发者定义了很多业务状态码,但是所有接口请求都是返回http状态码为200,这是很不好的,应该是当HTTP状态码中能表达业务请求的状态时应该返回对应的HTTP状态码,HTTP状态码无法表达业务状态时才自定义业务状态码。我们参考《Google API Design Guide (谷歌API设计指南)中文版》看看大厂业务状态码是如何定义的:下面是一个表格,其中包含google.rpc.Code中定义的所有gRPC错误代码及其原因的简短说明:

HTTPRPC描述
200OK没有错误
400INVALID_ARGUMENT客户端指定了无效的参数。 检查错误消息和错误详细信息以获取更多信息。
400FAILED_PRECONDITION请求不能在当前系统状态下执行,例如删除非空目录。
400OUT_OF_RANGE客户端指定了无效的范围。
401UNAUTHENTICATED由于遗失,无效或过期的OAuth令牌而导致请求未通过身份验证。
403PERMISSION_DENIED客户端没有足够的权限。这可能是因为OAuth令牌没有正确的范围,客户端没有权限,或者客户端项目尚未启用API。
404NOT_FOUND找不到指定的资源,或者该请求被未公开的原因(例如白名单)拒绝。
409ABORTED并发冲突,例如读-修改-写冲突。
409ALREADY_EXISTS客户端尝试创建的资源已存在。
429RESOURCE_EXHAUSTED资源配额达到速率限制。 客户端应该查找google.rpc.QuotaFailure错误详细信息以获取更多信息。
499CANCELLED客户端取消请求
500DATA_LOSS不可恢复的数据丢失或数据损坏。 客户端应该向用户报告错误。
500UNKNOWN未知的服务器错误。 通常是服务器错误。
500INTERNAL内部服务错误。 通常是服务器错误。
501NOT_IMPLEMENTED服务器未实现该API方法。
503UNAVAILABLE暂停服务。通常是服务器已经关闭。
504DEADLINE_EXCEEDED已超过请求期限。如果重复发生,请考虑降低请求的复杂性。

从Google定义的RPC状态码可以看出业务状态码里很多都使用了HTTP状态码,这样通过监控HTTP状态码也可以反映出业务的某些状态。

如何设计一套优雅的状态码

"工欲善其事,必先利其器"、“磨刀不误砍柴工”,状态码的设计是非常基础的工作,在很多项目开发过程中刚开始时项目比较急也没有考虑统一状态码,等项目做好后发现船已经太大了,没法掉头了,很多错的东西就将错就错,这也增加了项目的后期维护成本,后面接手的人不了解代码历史也往往会吐槽前人代码写的垃圾。在项目开始时就应该将这些基础的东西规范好,这对后面的开发者来说用着也方便,项目也好维护。那么如何设计一套优雅的状态码呢?我觉得有以下几点:

  1. 统一:状态码的编码和命名风格及接口返回参数要统一,不能每个人搞一种风格,每个人定义一套状态码,状态码要全局唯一,特别是微服务模式开发,有可能每个人开发的代码都不是一个GIT,做到统一管理就非常重要了;
  2. 兼容HTTP状态码:HTTP状态码是全球通用的,你返回个404知道HTTP协议的人都知道是什么意思;
  3. 要可读:你返回一个USERNAME_NOT_EXIST和10002,很明显USERNAME_NOT_EXIST一眼就看出是用户不存在的意思了;
  4. 要方便维护:状态码越来越多,后面随着业务发展,添加扩展状态码应该要很方便。

二、设计步骤

总体设计思路

业务状态码统一使用code枚举返回,可读性强,返回格式如下:


HTTP/1.1 200 

{
  "code": "OK",
  "message": "OK",
  "data": {
    "id": 2,
    "userName": "test3@8531.cn"
  }
}

其中code 业务状态码主要分为三类:

  1. HTTP状态码:三位数,对应org.springframework.http.HttpStatus中定义的状态码;
  2. 公共状态码: 四位数,对应编码1XXX开头,枚举COMM_*,对应公共异常如参数错误;
  3. 业务状态码: 五位数,各业务模块自定义,如用户中心10XXX,枚举USER_XXX,订单中心20XXX,枚举ORDER_XXX等等。

如何优雅的设计接口状态码和异常

异常设计

异常设计UML图

如何优雅的设计接口状态码和异常

  1. ApiException定义异常接口,有获取状态码和提示消息两个方法,其它所有异常实现该接口;
  2. HttpException主要用于HTTP状态的异常,其中用了Spring自带的HttpStatus,如果是HTTP状态码能表达业务代码可直接 new HttpException(HttpStatus.XXX)
  3. CommException公共业务异常,如参数没有传、文件不存在、数据超界等;
  4. UserException用户模块的状态码,如用户不存在、用户密码错误、手机号已被使用等等;
  5. OrderException订单模块状态码,如订单超时、订单已取消等等。
  6. 如果还是其它模块自定义类XXXException和XXXCodeEnum,并分配好状态码段和枚举开头,就可以使状态码全局唯一了。

ApiException

public interface ApiException {
    String getCode();
    String getMessage();
}

HttpException

public class HttpException extends RuntimeException  implements ApiException{
    @Getter
    private final HttpStatus httpStatus;
    private final String message;
    public HttpException(HttpStatus apiCode) {
        this(apiCode, apiCode.getReasonPhrase());
    }
    public HttpException(HttpStatus httpStatus, String message) {
        this.httpStatus = httpStatus;
        this.message = message;
    }
    @Override
    public String getCode() {
        return httpStatus.name();
    }
    @Override
    public String getMessage() {
        return message;
    }
}

CommException

public class CommException extends HttpException implements ApiException{
    private CommCodeEnum commCodeEnum;

    public CommException(CommCodeEnum commCodeEnum) {
        super(commCodeEnum.getHttpStatus());
        this.commCodeEnum=commCodeEnum;
    }

    public CommException(CommCodeEnum commCodeEnum, String message) {
        super(commCodeEnum.getHttpStatus(), message);
        this.commCodeEnum=commCodeEnum;
    }

    @Override
    public String getCode() {
        return commCodeEnum.getEnumName();
    }

    @Override
    public String getMessage() {
        return commCodeEnum.getName();
    }
}

UserException

public class UserException extends HttpException implements ApiException {
    private UserCodeEnum userCodeEnum;
    public UserException(UserCodeEnum userCodeEnum) {
        super(userCodeEnum.getHttpStatus());
        this.userCodeEnum=userCodeEnum;
    }
    public UserException(UserCodeEnum userCodeEnum, String message) {
        super(userCodeEnum.getHttpStatus(), message);
        this.userCodeEnum=userCodeEnum;
    }
    @Override
    public String getCode() {
        return userCodeEnum.getEnumName();
    }
    @Override
    public String getMessage() {
        return userCodeEnum.getName();
    }
}


OrderException

public class OrderException extends HttpException implements ApiException {
    private OrderCodeEnum oderCodeEnum;
    public OrderException(OrderCodeEnum oderCodeEnum) {
        super(oderCodeEnum.getHttpStatus());
        this.oderCodeEnum=oderCodeEnum;
    }
    public OrderException(OrderCodeEnum oderCodeEnum, String message) {
        super(oderCodeEnum.getHttpStatus(), message);
        this.oderCodeEnum=oderCodeEnum;
    }
    @Override
    public String getCode() {
        return oderCodeEnum.getEnumName();
    }
    @Override
    public String getMessage() {
        return oderCodeEnum.getName();
    }
}

异常枚举设计

BaseEnum

如何优雅的处理枚举可以参考我的另一篇文章《项目中如何优雅的使用枚举

public interface BaseEnum {
    int getCode();
    String getName();
    String getEnumName();
}

CommCodeEnum

public enum CommCodeEnum implements BaseEnum {
    INVALID_ARGUMENT(HttpStatus.OK,600, "参数错误"),
    ;
    //公共错误码6xx
    private int code;
    @Getter
    private HttpStatus httpStatus;
    private String name;
    CommCodeEnum(HttpStatus httpStatus, Integer code, String name) {
        this.httpStatus = httpStatus;
        this.code = code;
        this.name = name;
    }
    @Override
    public int getCode() {
        return this.code;
    }
    @Override
    public String getName() {
        return name;
    }
    @Override
    public String getEnumName() {
        return this.name();
    }
}

UserCodeEnum

在定义业务状态码时,需要注意的是有一个HttpStatus参数,如果我们觉得该业务出错了接口不应该返回HTTP 200,就可以设置成对应的HTTP状态码,如用户名不存在,可以理解为HTTP 状态码里的404资源不存在,这样我们就设置成USERNAME_NOT_EXIST(HttpStatus.NOT_FOUND,10002, "用户名不存在")这样做的好处是在监控HTTP状态码异常时也可以监控到业务出问题了。

public enum UserCodeEnum implements BaseEnum {
    USERNAME_EXIST(HttpStatus.OK,10001, "用户名已存在"),
    USERNAME_NOT_EXIST(HttpStatus.NOT_FOUND,10002, "用户名不存在"),
    USERNAME_DISABLE(HttpStatus.OK,10003, "用户被禁用"),
    ;
    @Getter
    private HttpStatus httpStatus;
    //用户模块错误码10xxx
    private int code;
    private String name;
}

OrderCodeEnum

public enum OrderCodeEnum implements BaseEnum {
    ORDER_CANCELLED(HttpStatus.OK,20001, "订阅已取消"),
    ORDER_TIMEOUT(HttpStatus.OK,20002, "订阅已超时"),

    ;
    //订单模块错误码20xxx
    private int code;
    @Getter
    private HttpStatus httpStatus;
    private String name;
}

三、统一接口返回

为了达到统一的接口数据返回格式,我们需要定义统一的接口返回类ApiResult,其它定义的code业务状态码,message提示消息和业务数据data参数。

统一接口返回包装

@NoArgsConstructor
@Data
public class ApiResult {
    private String code;
    private String message;
    private Object data;
    public ApiResult(BaseEnum apiCode, Map<String, Object> data) {
        this.code = apiCode.getEnumName();
        this.message = apiCode.getName();
        this.data = data;
    }
    public ApiResult(HttpStatus httpStatus, Object data) {
        this.code = httpStatus.name();
        this.message = httpStatus.getReasonPhrase();
        this.data = data;
    }
    public ApiResult(HttpStatus httpStatus,String message, Object data) {
        this.code = httpStatus.name();
        this.message = message;
        this.data = data;
    }
    public ApiResult(BaseEnum apiCode, String explanation, Map<String, Object> data) {
        this.code = apiCode.getEnumName();
        this.message = apiCode.getName() + (explanation != null ? "【" + explanation + "】" : "");
        this.data = data;
    }
}

公共Controller

为了方便Controller返回统一的数据格式,我可以定义BaseController,重载多种返回数据格式,业务Controller只需继承BaseController就可以直接调用 return renderOk(data)返回统一的接口数据格式。

public abstract class BaseController {
    @Resource
    protected HttpServletRequest httpRequest;

    protected ResponseEntity<ApiResult> renderOk() {
        return renderOk(null);
    }

    protected ResponseEntity<ApiResult> renderOk(Map<String, Object> data) {
        ApiResult apiResult = new ApiResult(HttpStatus.OK, data);
        return new ResponseEntity<>(apiResult, HttpStatus.OK);
    }

    protected ResponseEntity<ApiResult> renderOk(Object data) {
        ApiResult apiResult = new ApiResult(HttpStatus.OK, data);
        return new ResponseEntity<>(apiResult, HttpStatus.OK);
    }

    protected ResponseEntity<ApiResult> renderError(HttpStatus apiCode) {
        ApiResult apiResult = new ApiResult(apiCode, null);
        return new ResponseEntity<>(apiResult, HttpStatus.OK);
    }

    protected ResponseEntity<ApiResult> renderError(BaseEnum apiCode) {
        ApiResult apiResult = new ApiResult(apiCode, null);
        return new ResponseEntity<>(apiResult, HttpStatus.OK);
    }

    protected ResponseEntity<Map<String, Object>> renderError(HttpException httpException) {
        Map<String, Object> map = ImmutableMap.of("code", httpException.getCode(), "message", httpException.getMessage());
        return new ResponseEntity<>(map, httpException.getHttpStatus());
    }

    protected ResponseEntity<ApiResult> renderError(HttpException httpException, Object data) {
        ApiResult apiResult = new ApiResult(httpException.getHttpStatus(), data);
        return new ResponseEntity<>(apiResult, httpException.getHttpStatus());
    }

    protected ResponseEntity<Map<String, Object>> render(Map<String, Object> map) {
        return new ResponseEntity<>(map, HttpStatus.OK);
    }
}

四、统一异常拦截

针对代码中的非正常行业,可以统一使用抛自定义异常的方式,这样只需配置一个统一的异常拦截器,统一返回状态码。

@Slf4j
@ControllerAdvice
public class ErrorHandler extends BaseController {
    @ExceptionHandler(value = {HttpException.class})
    public ResponseEntity<Map<String, Object>>  httpException(HttpException ex) {
        log.error("{}", ex);
        return renderError(ex);
    }
}

五、测试

这样我们使用状态码就比较简单了,主要分为三种:

  1. 公共异常:throw new CommException(CommCodeEnum.INVALID_ARGUMENT);
  2. http状态码类异常: throw new HttpException(HttpStatus.GATEWAY_TIMEOUT);
  3. 业务类异常:如用户throw new UserException(UserCodeEnum.USERNAME_NOT_EXIST),订单throw new OrderException(OrderCodeEnum.ORDER_TIMEOUT, "请求订单超时")等。
@RestController
public class UserController extends BaseController {
    @Resource
    private UserService userService;
    @GetMapping("/test")
    public ResponseEntity<ApiResult> getById(@RequestParam Long id) {
        //1.公共异常
        if (id == null) {
            throw new CommException(CommCodeEnum.INVALID_ARGUMENT);
        }
        //2.http协议异常
        User user = null;
        try {
            user = userService.selectById(id);
            int b = 1 / 0;
        } catch (Exception exception) {
            throw new HttpException(HttpStatus.GATEWAY_TIMEOUT);
        }
        //3.用户模块异常
        if (user == null) {
          // throw new UserException(UserCodeEnum.USERNAME_NOT_EXIST);
        }
        //4.订单模块异常
        try {
            //调用订单

        } catch (Exception e) {
            //throw new OrderException(OrderCodeEnum.ORDER_TIMEOUT, "请求订单超时");
        }
        return renderOk(user);
    }
}

正常返回状态码

接口正常返回统一使用code:OK,http状态码为200, 如何优雅的设计接口状态码和异常

公共异常可以设置非200HTTP状态码

公共异常状态码

如何优雅的设计接口状态码和异常

HTTP异常 HTTP状态码都是非200

http异常状态码

如何优雅的设计接口状态码和异常

用户异常HTTP状态码可以是200也可以非200

用户异常状态码

如何优雅的设计接口状态码和异常

订单异常HTTP状态码可以是200也可以非200

订单异常状态码

如何优雅的设计接口状态码和异常

六、总结

本文介绍了HTTP状态码及业务状态码的区别和作用,提出并实现一种统一维护业务状态码和HTTP状态码的思路,该思路融合了HTTP状态码,规范了接口返回格式,统一的业务状态码,大大方便了在系统中使用异常和定义状态码。