接口设计注意的问题


设计接口

1. 接口的设计需要考虑,比如接口的命名、参数列表、包装结构体、接口粒度、版本策略、冥等性实现、同步异步处理方式等。
2. 其中和接口设计相关重要的有三点:包装结构体、版本策略、同步异步处理方式。

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

两个原则

1. 对外隐藏内部实现。
2. 设计接口结构时,明确每个字段的含义,以及客户端的处理方式。

@Data
public class APIResponse {
private boolean success;
private T data;
private int code;
private String message;
}

明确接口的设计逻辑

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

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

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

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

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

模拟收单服务客户端和服务端

 1. 服务端逻辑

@GetMapping("server")
public APIResponse server(@RequestParam("userId") Long userId) {
    APIResponse 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;
}

2. 客户端根据流程图逻辑实现

//error==1 请求无法到收单服务 第一层处理 :服务器忙,请稍后再试!
//error==2 模拟 userId 参数为空,收单服务会因为缺少 userId 参数提示非法用户。第二层处理:创建订单失败,请稍后再试,错误代码:3001 错误原因:Illegal userId
//error==3 模拟 userId 为 1 ,因用户有风险,收单服务调用订单服务出错。处理方式和之前没有任何区别,因为收单服务会屏蔽订单服务的内部错误:创建订单失败,请稍后再试,错误代码:3002 错误原因:Internal Error,order is cancelled.同时服务端可以看到错误日志
//error==0 模拟正常用户,下单成功。这时可以解析 data 结构体提取业务结果,作为兜底,需要判断订单状态,如果不是 Created 则给予友好提示,否则查询 orderId 获得下单的订单号,这是第三层处理:创建订单成功,订单号:2,状态是:Created

@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 apiResponse = objectMapper.readValue(response, new TypeReference>() {
            });
            //第二层,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 "";
}

3. 简化服务端代码

//包装API响应体APIResponse的工作交给框架自动完成。直接返回DTO OrderId即可。对于业务逻辑错误,可以抛出自定义异常:

@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来完成自动包装响应体的工作:

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

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

//生产级应用还需要扩展很多细节
@RestControllerAdvice
@Slf4j
public class APIResponseAdvice implements ResponseBodyAdvice {

    //自动处理APIException,包装为APIResponse
    @ExceptionHandler(APIException.class)
    public APIResponse handleApiException(HttpServletRequest request, APIException ex) {
        log.error("process url {} failed", request.getRequestURL().toString(), ex);
        APIResponse apiResponse = new APIResponse();
        apiResponse.setSuccess(false);
        apiResponse.setCode(ex.getErrorCode());
        apiResponse.setMessage(ex.getErrorMessage());
        return apiResponse;
    }

    //仅当方法或类没有标记@NoAPIResponse才自动包装
    @Override
    public boolean supports(MethodParameter returnType, Class converterType) {
        return returnType.getParameterType() != APIResponse.class
                && AnnotationUtils.findAnnotation(returnType.getMethod(), NoAPIResponse.class) == null
                && AnnotationUtils.findAnnotation(returnType.getDeclaringClass(), NoAPIResponse.class) == null;
    }

    //自动包装外层APIResposne响应
    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        APIResponse apiResponse = new APIResponse();
        apiResponse.setSuccess(true);
        apiResponse.setMessage("OK");
        apiResponse.setCode(2000);
        apiResponse.setData(body);
        return apiResponse;
    }
}

//添加不希望实现自动包装接口的注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoAPIResponse {
}

//如测试客户端client方法不需要包装为APIResponse

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

考虑接口变迁的版本控制策略

版本策略最好一开始就考虑

//通过URL Path实现版本控制
@GetMapping("/v1/api/user")
public int right1(){
    return 1;
}
//通过QueryString中的version参数实现版本控制
@GetMapping(value = "/api/user", params = "version=2")
public int right2(@RequestParam("version") int version) {
    return 2;
}
//通过请求头中的X-API-VERSION参数实现版本控制
@GetMapping(value = "/api/user", headers = "X-API-VERSION=3")
public int right3(@RequestHeader("X-API-VERSION") int version) {
    return 3;
}
//这样客户端就可以在配置中处理相关版本控制的参数,实现版本的动态切换
//其中URL Path的方式最直观也不容易出错;
//QueryString不易携带,不太推荐作为公开API的版本策略;
//HTTP头的方式比较没有入侵性,如果仅仅是部分接口需要进行版本控制,可以考虑;

版本实现方式要统一

//相比每一个接口URL Path中设置版本号,更理想的方式是在框架层面实现统一。
//使用Spring框架,自定义RequestMappingHandlerMapping来实现。
1. 创建一个注解来定义接口的版本。@APIVersion 自定义注解可以应用于方法或 Controller 上:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface APIVersion {
String[] value();
}

2. 定义一个 APIVersionHandlerMapping 类继承 RequestMappingHandlerMapping。
3. 通过注解的方式为接口增加基于 URL 的版本号:

//RequestMappingHandlerMapping 的作用,是根据类或方法上的 @RequestMapping 来生成 RequestMappingInfo 的实例。
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {
    @Override
    protected boolean isHandler(Class<?> beanType) {
        return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
    }


    @Override
    protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
        Class<?> controllerClass = method.getDeclaringClass();
        //类上的APIVersion注解
        APIVersion apiVersion = AnnotationUtils.findAnnotation(controllerClass, APIVersion.class);
        //方法上的APIVersion注解
        APIVersion methodAnnotation = AnnotationUtils.findAnnotation(method, APIVersion.class);
        //以方法上的注解优先
        if (methodAnnotation != null) {
            apiVersion = methodAnnotation;
        }

        String[] urlPatterns = apiVersion == null ? new String[0] : apiVersion.value();
       
        PatternsRequestCondition apiPattern = new PatternsRequestCondition(urlPatterns);
        PatternsRequestCondition oldPattern = mapping.getPatternsCondition();
        PatternsRequestCondition updatedFinalPattern = apiPattern.combine(oldPattern);
        //重新构建RequestMappingInfo
        mapping = new RequestMappingInfo(mapping.getName(), updatedFinalPattern, mapping.getMethodsCondition(),
                mapping.getParamsCondition(), mapping.getHeadersCondition(), mapping.getConsumesCondition(),
                mapping.getProducesCondition(), mapping.getCustomCondition());
        super.registerHandlerMethod(handler, method, mapping);
    }
}
//覆盖 registerHandlerMethod 方法的实现,从 @APIVersion 自定义注解中读取版本信息,
//拼接上原有的、不带版本号的 URL Pattern,
//构成新的 RequestMappingInfo,来通过注解的方式为接口增加基于 URL 的版本号。

4. 通过实现WebMvcRegistrations 接口,来生效自定义的 APIVersionHandlerMapping:

@SpringBootApplication
public class CommonMistakesApplication implements WebMvcRegistrations {
...
    @Override
    public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
        return new APIVersionHandlerMapping();
    }
}

5. 实现在 Controller 上或接口方法上通过注解,来统一 Pattern 进行版本号控制:

@GetMapping(value = "/api/user")
@APIVersion("v4")
public int right4() {
    return 4;
}

6. 使用框架来明确 API 版本的指定策略,不仅实现了标准化,更实现了强制的 API 版本控制。

接口处理方式要明确同步还是异步

初始文件上传服务

private ExecutorService threadPool = Executors.newFixedThreadPool(2);

//我没有贴出两个文件上传方法uploadFile和uploadThumbnailFile的实现,它们在内部只是随机进行休眠然后返回文件名,对于本例来说不是很重要

public UploadResponse upload(UploadRequest request) {
    UploadResponse response = new UploadResponse();
    //上传原始文件任务提交到线程池处理
    Future uploadFile = threadPool.submit(() -> uploadFile(request.getFile()));
    //上传缩略图任务提交到线程池处理
    Future uploadThumbnailFile = threadPool.submit(() -> uploadThumbnailFile(request.getFile()));
    //等待上传原始文件任务完成,最多等待1秒
    try {
        response.setDownloadUrl(uploadFile.get(1, TimeUnit.SECONDS));
    } catch (Exception e) {
        e.printStackTrace();
    }
    //等待上传缩略图任务完成,最多等待1秒
    try {
        response.setThumbnailDownloadUrl(uploadThumbnailFile.get(1, TimeUnit.SECONDS));
    } catch (Exception e) {
        e.printStackTrace();
    }
    return response;
}

//上传接口的请求和响应 传入二进制文件,传出原文件和缩略图下载地址:

@Data
public class UploadRequest {
    private byte[] file;
}

@Data
public class UploadResponse {
    private String downloadUrl;
    private String thumbnailDownloadUrl;
}

接口问题

1. 一旦遇到超时,接口就不能返回完整的数据
2. 不是无法拿到原文件下载地址,就是无法拿到缩略图下载地址,接口行为变得不可预测

优化改造

//让上传接口要么彻底同步处理,要么彻底异步处理
1. 同步处理,接口一定是同步上传原文件和缩略图的,调用方自己选择调用超时,如果来得及一直等到上传完成,如果等不及可以结束等待,下次重试;
2. 异步处理,接口两段式,上传接口本身只是返回一个任务ID,然后异步上传,上传接口响应很快,客户端需要之后再拿到任务ID调用任务查询接口查询上传文件URL。

//同步上传接口代码如下,把超时留给客户端
public SyncUploadResponse syncUpload(SyncUploadRequest request) {
    SyncUploadResponse response = new SyncUploadResponse();
    response.setDownloadUrl(uploadFile(request.getFile()));
    response.setThumbnailDownloadUrl(uploadThumbnailFile(request.getFile()));
    return response;
}
//这里的SyncUploadRequest 和 SyncUploadResponse 类,与之前定义的 UploadRequest 和 UploadResponse 是一致的
//接口入参和出参DTO命名使用接口名+Request 和 Response 后缀。

//异步上传文件接口代码,返回任务ID
@Data
public class AsyncUploadRequest {
    private byte[] file;
}

@Data
public class AsyncUploadResponse {
    private String taskId;
}
//在接口实现上,我们同样把上传任务提交到线程池处理,但是并不会同步等待任务完成,而是完成后把结果写入一个 HashMap,任务查询接口通过查询这个 HashMap 来获得文件的 URL:
//计数器,作为上传任务的ID
private AtomicInteger atomicInteger = new AtomicInteger(0);
//暂存上传操作的结果,生产代码需要考虑数据持久化
private ConcurrentHashMap downloadUrl = new ConcurrentHashMap<>();
//异步上传操作
public AsyncUploadResponse asyncUpload(AsyncUploadRequest request) {
    AsyncUploadResponse response = new AsyncUploadResponse();
    //生成唯一的上传任务ID
    String taskId = "upload" + atomicInteger.incrementAndGet();
    //异步上传操作只返回任务ID
    response.setTaskId(taskId);
    //提交上传原始文件操作到线程池异步处理
    threadPool.execute(() -> {
        String url = uploadFile(request.getFile());
        //如果ConcurrentHashMap不包含Key,则初始化一个SyncQueryUploadTaskResponse,然后设置DownloadUrl
        downloadUrl.computeIfAbsent(taskId, id -> new SyncQueryUploadTaskResponse(id)).setDownloadUrl(url);
    });
    //提交上传缩略图操作到线程池异步处理
    threadPool.execute(() -> {
        String url = uploadThumbnailFile(request.getFile());
        downloadUrl.computeIfAbsent(taskId, id -> new SyncQueryUploadTaskResponse(id)).setThumbnailDownloadUrl(url);
    });
    return response;
}

//文件上传查询接口以任务ID作为入参,返回两个文件下载地址,因为文件上传查询接口是同步的,所以命名为syncQueryUploadTask:
//syncQueryUploadTask接口入参
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskRequest {
    private final String taskId;//使用上传文件任务ID查询上传结果 
}
//syncQueryUploadTask接口出参
@Data
@RequiredArgsConstructor
public class SyncQueryUploadTaskResponse {
    private final String taskId; //任务ID
    private String downloadUrl; //原始文件下载URL
    private String thumbnailDownloadUrl; //缩略图下载URL
}

public SyncQueryUploadTaskResponse syncQueryUploadTask(SyncQueryUploadTaskRequest request) {
    SyncQueryUploadTaskResponse response = new SyncQueryUploadTaskResponse(request.getTaskId());
     //从之前定义的downloadUrl ConcurrentHashMap查询结果
response.setDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getDownloadUrl());
    response.setThumbnailDownloadUrl(downloadUrl.getOrDefault(request.getTaskId(), response).getThumbnailDownloadUrl());
    return response;
}
//经过改造FileService不再提供一个看起来是同步上传,内部却是异步上传的upload方法,改为提供很明确的:
//同步上传接口syncUpload;
//异步上传接口asyncUpload,搭配syncQueryUploadTask查询上传结果
//使用方可以根据业务性质选择合适的方法,如果是后端批处理使用,那么可以使用同步上传,多等待一些时间问题不大;
//如果是面向用户的接口,接口响应时间不宜过长,可以调用异步上传接口,定时轮询上传结果拿到结果再显示。

总结接口设计的三个问题

1. 针对响应体的设计混乱、响应结果的不明确问题,服务端需要明确响应体每一个字段的意义,以一致的方式进行处理,并确保不透传下游服务的错误。
2. 针对接口版本控制问题,主要就是在开发接口之前明确版本控制策略,以及尽量使用统一的版本控制策略两方面。
3. 针对接口的处理方式,需要明确要么是同步要么是异步。如果 API 列表中既有同步接口也有异步接口,那么最好直接在接口名中明确。

解决业务特别复杂的接口错误码的处理 

//服务端把错误码反馈给客户端有两个目的
1. 客户端可以展示错误码方便排查问题
2. 客户端可以根据不同的错误码来做交互区分
//针对1方便客户端排查问题,服务端应该进行适当的收敛和规整错误码,而不是把服务内可能遇到的、来自各个系统各个层次的错误码,一股脑地扔给客户端提示给用户。
//建议开发一个错误码服务来专门治理错误码,实现错误码的转码、分类和收敛逻辑,甚至可以开发后台,让产品来录入需要的错误码提示消息。
//建议错误码由一定的规则构成,比如错误码
//第一位可以是错误类型(比如 A 表示错误来源于用户;B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题;C 表示错误来源于第三方服务),
//第二、第三位可以是错误来自的系统编号(比如 01 来自用户服务,02 来自商户服务等等),后面三位是自增错误码 ID。

//针对2对不同错误码的交互区分更好的做法是服务端驱动模式,让服务端告知客户端如何处理,说白了就是客户端只需要照做即可,不需要感知错误码的含义(即便客户端显示错误码,也只是用于排错)。
//由服务端来明确客户端在请求 API 后的交互行为,主要的好处是灵活和统一两个方面。
//灵活在于两个方面:
//第一,在紧急的时候还可以通过 redirect 方式进行救急。比如,遇到特殊情况需要紧急进行逻辑修改的情况时,我们可以直接在不发版的情况下切换到 H5 实现。
//第二是,我们可以提供后台,让产品或运营来配置交互的方式和信息(而不是改交互,改提示还需要客户端发版)。
//统一:有的时候会遇到不同的客户端(比如 iOS、Android、前端),对于交互的实现不统一的情况,如果 API 结果可以规定这部分内容,那就可以彻底避免这个问题。

自定义RequestMappingHandlerMapping实现一套统一的基于请求头方式的版本控制

//定义自己的 RequestCondition 来做请求头的匹配:
public class APIVersionCondition implements RequestCondition {

    @Getter
    private String apiVersion;
    @Getter
    private String headerKey;

    public APIVersionCondition(String apiVersion, String headerKey) {
        this.apiVersion = apiVersion;
        this.headerKey = headerKey;
    }

    @Override
    public APIVersionCondition combine(APIVersionCondition other) {
        return new APIVersionCondition(other.getApiVersion(), other.getHeaderKey());
    }

    @Override
    public APIVersionCondition getMatchingCondition(HttpServletRequest request) {
        String version = request.getHeader(headerKey);
        return apiVersion.equals(version) ? this : null;
    }

    @Override
    public int compareTo(APIVersionCondition other, HttpServletRequest request) {
        return 0;
    }
}
//自定义 RequestMappingHandlerMapping,来把方法关联到自定义的 RequestCondition:
public class APIVersionHandlerMapping extends RequestMappingHandlerMapping {
    @Override
    protected boolean isHandler(Class<?> beanType) {
        return AnnotatedElementUtils.hasAnnotation(beanType, Controller.class);
    }

    @Override
    protected RequestCondition getCustomTypeCondition(Class<?> handlerType) {
        APIVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, APIVersion.class);
        return createCondition(apiVersion);
    }

    @Override
    protected RequestCondition getCustomMethodCondition(Method method) {
        APIVersion apiVersion = AnnotationUtils.findAnnotation(method, APIVersion.class);
        return createCondition(apiVersion);
    }

    private RequestCondition createCondition(APIVersion apiVersion) {
        return apiVersion == null ? null : new APIVersionCondition(apiVersion.value(), apiVersion.headerKey());
    }
}

原文链接:https://time.geekbang.org/column/article/228968

相关