learn-tech/专栏/Java业务开发常见错误100例/22接口设计:系统间对话的语言,一定要统一.md
2024-10-16 00:20:59 +08:00

30 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        22 接口设计:系统间对话的语言,一定要统一
                        今天,我要和你分享的主题是,在做接口设计时一定要确保系统之间对话的语言是统一的。

我们知道,开发一个服务的第一步就是设计接口。接口的设计需要考虑的点非常多,比如接口的命名、参数列表、包装结构体、接口粒度、版本策略、幂等性实现、同步异步处理方式等。

这其中,和接口设计相关比较重要的点有三个,分别是包装结构体、版本策略、同步异步处理方式。今天,我就通过我遇到的实际案例,和你一起看看因为接口设计思路和调用方理解不一致所导致的问题,以及相关的实践经验。

接口的响应要明确表示接口的处理结果

我曾遇到过一个处理收单的收单中心项目,下单接口返回的响应体中,包含了 success、code、info、message 等属性,以及二级嵌套对象 data 结构体。在对项目进行重构的时候,我们发现真的是无从入手,接口缺少文档,代码一有改动就出错。

有时候下单操作的响应结果是这样的success 是 true、message 是 OK貌似代表下单成功了但 info 里却提示订单存在风险code 是一个 5001 的错误码data 中能看到订单状态是 Cancelled订单 ID 是 -1好像又说明没有下单成功。

{

"success": true,

"code": 5001,

"info": "Risk order detected",

"message": "OK",

"data": {

"orderStatus": "Cancelled",

"orderId": -1

}

}

有些时候这个下单接口又会返回这样的结果success 是 falsemessage 提示非法用户 ID看上去下单失败但 data 里的 orderStatus 是 Created、info 是空、code 是 0。那么这次下单到底是成功还是失败呢

{

"success": false,

"code": 0,

"info": "",

"message": "Illegal userId",

"data": {

"orderStatus": "Created",

"orderId": 0

}

}

这样的结果,让我们非常疑惑:

结构体的 code 和 HTTP 响应状态码,是什么关系?

success 到底代表下单成功还是失败?

info 和 message 的区别是什么?

data 中永远都有数据吗?什么时候应该去查询 data

造成如此混乱的原因是:这个收单服务本身并不真正处理下单操作,只是做一些预校验和预处理;真正的下单操作,需要在收单服务内部调用另一个订单服务来处理;订单服务处理完成后,会返回订单状态和 ID。

在一切正常的情况下,下单后的订单状态就是已创建 Created订单 ID 是一个大于 0 的数字。而结构体中的 message 和 success其实是收单服务的处理异常信息和处理成功与否的结果code、info 是调用订单服务的结果。

对于第一次调用收单服务自己没问题success 是 truemessage 是 OK但调用订单服务时却因为订单风险问题被拒绝所以 code 是 5001info 是 Risk order detecteddata 中的信息是订单服务返回的,所以最终订单状态是 Cancelled。

对于第二次调用,因为用户 ID 非法,所以收单服务在校验了参数后直接就返回了 success 是 falsemessage 是 Illegal userId。因为请求没有到订单服务所以 info、code、data 都是默认值,订单状态的默认值是 Created。因此第二次下单肯定失败了但订单状态却是已创建。

可以看到,如此混乱的接口定义和实现方式,是无法让调用者分清到底应该怎么处理的。为了将接口设计得更合理,我们需要考虑如下两个原则:

对外隐藏内部实现。虽然说收单服务调用订单服务进行真正的下单操作,但是直接接口其实是收单服务提供的,收单服务不应该“直接”暴露其背后订单服务的状态码、错误描述。

设计接口结构时,明确每个字段的含义,以及客户端的处理方式。

基于这两个原则,我们调整一下返回结构体,去掉外层的 info即不再把订单服务的调用结果告知客户端

@Data

public class APIResponse {

private boolean success;

private T data;

private int code;

private String message;

}

并明确接口的设计逻辑:

如果出现非 200 的 HTTP 响应状态码,就代表请求没有到收单服务,可能是网络出问题、网络超时,或者网络配置的问题。这时,肯定无法拿到服务端的响应体,客户端可以给予友好提示,比如让用户重试,不需要继续解析响应结构体。

如果 HTTP 响应码是 200解析响应体查看 success为 false 代表下单请求处理失败,可能是因为收单服务参数验证错误,也可能是因为订单服务下单操作失败。这时,根据收单服务定义的错误码表和 code做不同处理。比如友好提示或是让用户重新填写相关信息其中友好提示的文字内容可以从 message 中获取。

success 为 true 的情况下,才需要继续解析响应体中的 data 结构体。data 结构体代表了业务数据,通常会有下面两种情况。

通常情况下success 为 true 时订单状态是 Created获取 orderId 属性可以拿到订单号。

特殊情况下,比如收单服务内部处理不当,或是订单服务出现了额外的状态,虽然 success 为 true但订单实际状态不是 Created这时可以给予友好的错误提示。

明确了接口的设计逻辑,我们就是可以实现收单服务的服务端和客户端来模拟这些情况了。

首先,实现服务端的逻辑:

@GetMapping("server")

public APIResponse server(@RequestParam("userId") Long userId) {

APIResponse<OrderInfo> response = new APIResponse<>();

if (userId == null) {

    //对于userId为空的情况收单服务直接处理失败给予相应的错误码和错误提示

    response.setSuccess(false);

    response.setCode(3001);

    response.setMessage("Illegal userId");

} else if (userId == 1) {

    //对于userId=1的用户模拟订单服务对于风险用户的情况

    response.setSuccess(false);

    //把订单服务返回的错误码转换为收单服务错误码

    response.setCode(3002);

    response.setMessage("Internal Error, order is cancelled");

    //同时日志记录内部错误

    log.warn("用户 {} 调用订单服务失败,原因是 Risk order detected", userId);

} else {

    //其他用户,下单成功

    response.setSuccess(true);

    response.setCode(2000);

    response.setMessage("OK");

    response.setData(new OrderInfo("Created", 2L));

}

return response;

}

客户端代码,则可以按照流程图上的逻辑来实现,同样模拟三种出错情况和正常下单的情况:

error==1 的用例模拟一个不存在的 URL请求无法到收单服务会得到 404 的 HTTP 状态码,直接进行友好提示,这是第一层处理。

error==2 的用例模拟 userId 参数为空的情况,收单服务会因为缺少 userId 参数提示非法用户。这时,可以把响应体中的 message 展示给用户,这是第二层处理。

error==3 的用例模拟 userId 为 1 的情况,因为用户有风险,收单服务调用订单服务出错。处理方式和之前没有任何区别,因为收单服务会屏蔽订单服务的内部错误。

但在服务端可以看到如下错误信息:

[14:13:13.951] [http-nio-45678-exec-8] [WARN ] [.c.a.d.APIThreeLevelStatusController:36 ] - 用户 1 调用订单服务失败,原因是 Risk order detected

error==0 的用例模拟正常用户,下单成功。这时可以解析 data 结构体提取业务结果,作为兜底,需要判断订单状态,如果不是 Created 则给予友好提示,否则查询 orderId 获得下单的订单号,这是第三层处理。

客户端的实现代码如下:

@GetMapping("client")

public String client(@RequestParam(value = "error", defaultValue = "0") int error) {

String url = Arrays.asList("http://localhost:45678/apiresposne/server?userId=2",

    "http://localhost:45678/apiresposne/server2",

    "http://localhost:45678/apiresposne/server?userId=",

    "http://localhost:45678/apiresposne/server?userId=1").get(error);

//第一层先看状态码如果状态码不是200不处理响应体

String response = "";

try {

    response = Request.Get(url).execute().returnContent().asString();

} catch (HttpResponseException e) {

    log.warn("请求服务端出现返回非200", e);

    return "服务器忙,请稍后再试!";

} catch (IOException e) {

    e.printStackTrace();

}

//状态码为200的情况下处理响应体

if (!response.equals("")) {

    try {

        APIResponse<OrderInfo> apiResponse = objectMapper.readValue(response, new TypeReference<APIResponse<OrderInfo>>() {

        });

        //第二层success是false直接提示用户

        if (!apiResponse.isSuccess()) {

            return String.format("创建订单失败,请稍后再试,错误代码: %s 错误原因:%s", apiResponse.getCode(), apiResponse.getMessage());

        } else {

            //第三层往下解析OrderInfo

            OrderInfo orderInfo = apiResponse.getData();

            if ("Created".equals(orderInfo.getStatus()))

                return String.format("创建订单成功,订单号是:%s状态是%s", orderInfo.getOrderId(), orderInfo.getStatus());

            else

                return String.format("创建订单失败,请联系客服处理");

        }

    } catch (JsonProcessingException e) {

        e.printStackTrace();

    }

}

return "";

}

相比原来混乱的接口定义和处理逻辑,改造后的代码,明确了接口每一个字段的含义,以及对于各种情况服务端的输出和客户端的处理步骤,对齐了客户端和服务端的处理逻辑。那么现在,你能回答前面那 4 个让人疑惑的问题了吗?

最后分享一个小技巧。为了简化服务端代码,我们可以把包装 API 响应体 APIResponse 的工作交由框架自动完成,这样直接返回 DTO OrderInfo 即可。对于业务逻辑错误,可以抛出一个自定义异常:

@GetMapping("server")

public OrderInfo server(@RequestParam("userId") Long userId) {

if (userId == null) {

    throw new APIException(3001, "Illegal userId");

}

if (userId == 1) {

    ...

    //直接抛出异常

    throw new APIException(3002, "Internal Error, order is cancelled");

}

//直接返回DTO

return new OrderInfo("Created", 2L);

}

在 APIException 中包含错误码和错误消息:

public class APIException extends RuntimeException {

@Getter

private int errorCode;

@Getter

private String errorMessage;

public APIException(int errorCode, String errorMessage) {

    super(errorMessage);

    this.errorCode = errorCode;

    this.errorMessage = errorMessage;

}

public APIException(Throwable cause, int errorCode, String errorMessage) {

    super(errorMessage, cause);

    this.errorCode = errorCode;

    this.errorMessage = errorMessage;

}

}

然后,定义一个 @RestControllerAdvice 来完成自动包装响应体的工作:

通过实现 ResponseBodyAdvice 接口的 beforeBodyWrite 方法,来处理成功请求的响应体转换。

实现一个 @ExceptionHandler 来处理业务异常时APIException 到 APIResponse 的转换。

//此段代码只是Demo生产级应用还需要扩展很多细节

@RestControllerAdvice

@Slf4j

public class APIResponseAdvice implements ResponseBodyAdvice