后端的自我修养基础篇(一)——异常处理以及服务器应如何返回异常状态

一、引子

大多数人写后端的api时,都喜欢这样定义响应:

HTTP 200 OK
...headers

{
  code: 500,
  msg: "error"
  data: T
}

你是不是这样写的呢?反正我刚开始做项目的时候,是这样写的,现在很多的项目也大多是这样写的。
我查了查网上的资料,一种说法是一些地区的网络运营商会劫持4xx和5xx请求,所以一律把http的状态码改成200,所有异常的状态码放在响应体中,但是现在有了https,除非中间人有那个能力搞到你的证书(有那个能力就直接在你的服务器动手脚了,还劫持你请求干啥),不然从概率学上是不可能篡改你的请求的。而且就算是不用https,也可以用自定义请求头等更优雅的办法来解决这个问题。

二、回顾HTTP状态码

先来看看HTTP状态码:
标准的http状态码有五类,分别是:

  • 1xx: 通知
  • 2xx: 成功
  • 3xx: 重定向
  • 4xx: 客户端错误
  • 5xx: 服务端错误

可以看到这些分类是很明确的,对于客户端来讲,只需要在业务代码里处理2xx,4xx就可以了,因为1xx和3xx对开发者是透明的,http调用框架(ajax、retrofit、axios等等)会自动处理;5xx是后端的锅,服务器炸掉了,和客户端开发者就更没关系了。

三、如何设计

首先,在我们的后端代码中,要自定义一个业务异常,在抛出该异常之后,异常处理切面就将响应的状态码改成4xx,其余的异常类型,http状态码都设置成5xx。因为抛出ServiceException的肯定是我们验证客户端发送来的数据出了问题,比如参数错误、权限不足等,用户改改参数就可以解决这个异常。而其他异常,比如NPE,客户端怎么改参数都是没用的,因为是服务器的代码有问题。当然你也可以将诸如HibernateValidation2.0(JSR380)的ConstraintViolation异常的http状态码也设置成4xx,看你系统具体的设计了。

3.1 自定义业务异常

@Getter
@Setter
public class ServiceException extends RuntimeException{
    // 该异常要返回什么样的http状态码,常用的有:
    // 400 BAD_REQUEST, 客户端的请求参数错误
    // 403 FORBIDDEN, 服务器理解请求客户端的请求,但是拒绝执行此请求,例如考试系统,用户在考试开始前请求获取试卷
    private HttpResponseCode httpCode;
    //异常的编号,一般为模块编号+异常编号
    private int errorId;
    
    public ServiceException(HttpResponseCode httpCode,int errorId,String msg){
        super(msg);
        this.httpCode=httpCode;
        this.errorId=errorId;
    }
    public ServiceException(int errorId,String msg){this(HttpResponseCode.BAD_REQUEST,errorId,msg);}
}

3.2 定义异常处理切面

可以定义一个异常处理切面

// TODO

值得注意的是,本文的思路是针对发现客户端参数错误就立马抛出异常的,如果后端要检查出所有的违例后再抛出异常,可以在当前请求上下文中添加产生的异常列表,然后再在异常处理切面构造异常响应。

关于客户端参数的校验,可以参考我的另一篇文章JSR 380 参数验证,或者自定义类似JUnit的Assert的工具类,在违例时抛出异常,减少if else的滥用,让代码更整洁。

3.3 客户端/前端使用

这个时候就要召唤我们万能的ajax了,其他的例子,像rx* + *HttpClient, 实现的思路大体都是差不多的,这里就不再详细写了,可以找我私下讨论。

//首先增加一个全局的过滤器,用来处理5xx异常和网络问题,这里就不做演示了

//然后请求api时就比较舒服了:
$.ajax().get("https://api.xxx.com/some-resource"
    ,function(res){//success callback
        //这里我们只要对正常的响应数据做处理就好了,因为按照我们的设计,
        //只有服务器正确的处理了我们的数据才会走到这个回调
        console.log(res);
    },function(res){//error callback
        //仅在4xx时会落入这个分支,因为5xx已经在全局的拦截器中处理掉了,
        //服务器的锅,为什么要让客户端在每行代码里都写一遍呢是不是?
        //所以在这里我们只要客户端的请求违例就可以了
        //另外异常的响应体也可以和正常的不同了
        switch(res.errorId){
            case 1:
                console.log("异常1");
                break;
            case 2:
                console.log("异常2");
                break;
            default:
                console.log("未知的问题");
                break;
        }
    }
)

3.4 http的额外处理

上面的内容,都是基于我们的通信是基于https的情况下做的,如果你非要用http,那肯定会遇到运营商劫持和响应内容被篡改的问题,这个时候就要做一些变通。

其实也简单,只要把本该放在响应报文第一行的异常状态码,放到我们自定义的响应头里面去就可以了:

HTTP 200 OK                     //http状态码全设置为200
My-Response-Status=400          //将本该放在第一行的状态码放到自定义头里
Content-Type=application/json

{...}

然后再在客户端做相应的处理

对于响应内容可能会被中间人篡改的问题,以后会再开一篇文章介绍,如果没有,那么请在评论区提醒我。

四、这样做的好处

那我们先看看不这么做,客户端是如何处理数据的:

$.ajax().get("https://api.xxx.com/tx"
    ,function(res){//success callback
        switch(res.code){
            case 0:
                //正常的处理逻辑...
                console.log(res.data);
                console.log(res.data.xxx);//每次获取数据都是res.data.xxx,多了一层,代码很臃肿
                console.log(res.data.yyy);
                break;
            //下面是对异常状态的处理逻辑
            //异常处理和正常的逻辑混在一个brach里,缩进地狱,代码更混乱
            case 1:
                console.log("异常1");
                break;
            case 2:
                console.log("异常2");
                break;
            default:
                console.log("未知的问题");
                break;
        }
        
    },function(res){//error callback
        //在这里,客户端还是要处理来自服务器的5xx响应,比如502等,
        //因为这个时候http状态码是200,添加全局的拦截较为麻烦(js好做,其他强类型的语言就不好做了)
        //你大可以说我把这里的逻辑封装好,所有代码都加上一行就解决了,但是为啥要脱裤子放屁呢~
    }
)

结果显而易见了。

五、这样做的坏处

对前端的人员要求比较高,因为有的前端可能工作了很多年都不知道拦截器之类的概念,也无从谈起怎么做全局的异常处理了。

六、杠精说

leader: 不行,我非要封装一个统一的响应体,你bb这么多,我觉得也就那样嘛,还是按照我们传统的方式balabala…
我: 笑mmp

好吧,介于肯定有人会继续用统一响应体的方式来做异常处理,那我后面会再写一篇文章来说怎么做更合适,尽可能减少对代码的污染,让代码更整洁。

你可能感兴趣的:(后端的自我修养)