《开发实战》15 | 接口设计:系统间对话的语言,一定要统一

news/2024/7/20 22:12:59 标签: 数据库, ios, oracle

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

我曾遇到过一个处理收单的收单中心项目,下单接口返回的响应体中,包含了 success、code、info、message 等属性,以及二级嵌套对象 data 结构体。在对项目进行重构的时候,我们发现真的是无从入手,接口缺少文档,代码一有改动就出错。
有时候,下单操作的响应结果是这样的:success 是 true、message 是 OK,貌似代表下单成功了;但 info 里却提示订单存在风险,code 是一个 5001 的错误码,data 中能看到订单状态是 Cancelled,订单 ID 是 -1,好像又说明没有下单成功。
有些时候,这个下单接口又会返回这样的结果:success 是 false,message 提示非法用户ID,看上去下单失败;但 data 里的 orderStatus 是 Created、info 是空、code 是 0。那么,这次下单到底是成功还是失败呢?
疑惑:

  • 结构体的 code 和 HTTP 响应状态码,是什么关系?
  • success 到底代表下单成功还是失败?
  • info 和 message 的区别是什么?
  • data 中永远都有数据吗?什么时候应该去查询 data?

造成如此混乱的原因是:这个收单服务本身并不真正处理下单操作,只是做一些预校验和预处理;真正的下单操作,需要在收单服务内部调用另一个订单服务来处理;订单服务处理完成后,会返回订单状态和 ID。
如此混乱的接口定义和实现方式,是无法让调用者分清到底应该怎么处理的。为了将接口设计得更合理,我们需要考虑如下两个原则

  • 对外隐藏内部实现。虽然说收单服务调用订单服务进行真正的下单操作,但是直接接口其实是收单服务提供的,收单服务不应该“直接”暴露其背后订单服务的状态码、错误描述。
  • 设计接口结构时,明确每个字段的含义,以及客户端的处理方式。

基于这两个原则,我们调整一下返回结构体,去掉外层的 info,即不再把订单服务的调用结果告知客户端:
调用远程服务的时候,返回的数据需要重新包装,有异常就直接抛,要使用全局异常处理。 @RestControllerAdvice

  1. 通过实现 ResponseBodyAdvice 接口的 beforeBodyWrite 方法,来处理成功请求的响应体转换。
  2. 实现一个 @ExceptionHandler 来处理业务异常时,APIException 到 APIResponse 的转换。

这样,业务代码已经不需要特地的去封装一些异常的信息。

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

接口不可能一成不变,需要根据业务需求不断增加内部逻辑。如果做大的功能调整或重构,涉及参数定义的变化或是参数废弃,导致接口无法向前兼容,这时接口就需要有版本的概念。
第一,版本策略最好一开始就考虑
既然接口总是要变迁的,那么最好一开始就确定版本策略。比如,确定是通过 URL Path 实现,是通过 QueryString 实现,还是通过 HTTP 头实现。这三种实现方式的代码如下:

//通过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 头的方式比较没有侵入性,如果仅仅是部分接口需要进行版本控制,可以考虑这种方式。

第二,版本实现方式要统一
遇到过一个 O2O 项目,需要针对商品、商店和用户实现 REST 接口。虽然大家约定通过 URL Path 方式实现 API 版本控制,但实现方式不统一,有的是 /api/item/v1,有的是 /api/v1/shop,还有的是 /v1/api/merchant:
相比于在每一个接口的 URL Path 中设置版本号,更理想的方式是在框架层面实现统一。如果你使用 Spring 框架的话,可以按照下面的方式自定义 RequestMappingHandlerMapping来实现。
首先,创建一个注解来定义接口的版本。@APIVersion 自定义注解可以应用于方法或Controller 上:

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

然后,定义一个 APIVersionHandlerMapping 类继承RequestMappingHandlerMapping。
RequestMappingHandlerMapping 的作用,是根据类或方法上的 @RequestMapping 来生成 RequestMappingInfo 的实例。我们覆盖 registerHandlerMethod 方法的实现,从@APIVersion 自定义注解中读取版本信息,拼接上原有的、不带版本号的 URL Pattern,构成新的 RequestMappingInfo,来通过注解的方式为接口增加基于 URL 的版本号:

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);
    }
}

最后,也是特别容易忽略的一点,要通过实现 WebMvcRegistrations 接口,来生效自定义的 APIVersionHandlerMapping:

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

这样,就实现了在 Controller 上或接口方法上通过注解,来实现以统一的 Pattern 进行版本号控制:

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

加上注解后,访问浏览器查看效果:

在这里插入图片描述

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

有一个文件上传服务 FileService,其中一个 upload 文件上传接口特别慢,原因是这个上传接口在内部需要进行两步操作,首先上传原图,然后压缩后上传缩略图。如果每一步都耗时 5秒的话,那么这个接口返回至少需要 10 秒的时间。
于是,开发同学把接口改为了异步处理,每一步操作都限定了超时时间,也就是分别把上传原文件和上传缩略图的操作提交到线程池,然后等待一定的时间:

private ExecutorService threadPool = Executors.newFixedThreadPool(2);

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

public UploadResponse upload(UploadRequest request) {
    UploadResponse response = new UploadResponse();
    //上传原始文件任务提交到线程池处理
    Future<String> uploadFile = threadPool.submit(() -> uploadFile(request.getFile()));
    //上传缩略图任务提交到线程池处理
    Future<String> 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;
}

从接口命名上看虽然是同步上传操作,但其内部通过线程池进行异步上传,并因为设置了较短超时所以接口整体响应挺快。但是,一旦遇到超时,接口就不能返回完整的数据,不是无法拿到原文件下载地址,就是无法拿到缩略图下载地址,接口的行为变得不可预测
这种优化接口响应速度的方式并不可取,更合理的方式是,让上传接口要么是彻底的同步处理,要么是彻底的异步处理

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

异步方式:
这里的 SyncUploadRequest 和 SyncUploadResponse 类,与之前定义的 UploadRequest和 UploadResponse 是一致的。对于接口的入参和出参 DTO 的命名,我比较建议的方式是,使用接口名 +Request 和 Response 后缀。
接下来,我们看看异步的上传文件接口如何实现。异步上传接口在出参上有点区别,不再返回文件 URL,而是返回一个任务 ID:

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

@Data
public class AsyncUploadResponse {
    private String taskId;
}

//计数器,作为上传任务的ID
private AtomicInteger atomicInteger = new AtomicInteger(0);
//暂存上传操作的结果,生产代码需要考虑数据持久化
private ConcurrentHashMap<String, SyncQueryUploadTaskResponse> 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;
}

//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;
}

异步上传接口 asyncUpload,搭配 syncQueryUploadTask 查询上传结果。
使用方可以根据业务性质选择合适的方法:如果是后端批处理使用,那么可以使用同步上传,多等待一些时间问题不大;
如果是面向用户的接口,那么接口响应时间不宜过长,可以调用异步上传接口,然后定时轮询上传结果,拿到结果再显示。
最后,我再额外提一下,对于服务端出错的时候是否返回 200 响应码的问题,其实一直有争论。从 RESTful 设计原则来看,我们应该尽量利用 HTTP 状态码来表达错误,但也不是这么绝对。
如果我们认为 HTTP 状态码是协议层面的履约,那么当这个错误已经不涉及 HTTP 协议时(换句话说,服务端已经收到请求进入服务端业务处理后产生的错误),不一定需要硬套协议本身的错误码。但涉及非法 URL、非法参数、没有权限等无法处理请求的情况,还是应该使用正确的响应码来应对。


http://www.niftyadmin.cn/n/5044545.html

相关文章

怎样防止员工泄露技术?(十条避免公司泄密的措施)

在当今信息化社会&#xff0c;公司信息的安全性和保密性显得尤为重要。一旦公司信息泄露&#xff0c;不仅会对公司的经营造成严重影响&#xff0c;还可能引发法律纠纷。因此&#xff0c;采取有效的措施来防止公司信息泄露是非常必要的。以下是一些具体的措施&#xff1a; 部署洞…

运行springBoot项目

本文背景&#xff1a;接手后端Maven管理的springBoot项目&#xff0c;但是不太清楚具体怎么运行项目 写此文章作为纪念 参考链接&#xff1a;IDEA运行SpringBoot项目&#xff08;图文详细讲解&#xff09;_idea项目运行_叫我小楠的博客-CSDN博客 步骤如下&#xff1a; 1.设…

requests爬虫详解

Requests 安装 pip install requests 示例 from fake_useragent import UserAgent import requestsdef cra1_1(): url http://xx/front/website/findAllTypes headers {User-Agent: UserAgent().chrome} resp requests.get(url, headersheaders) result resp.json()i…

博客摘录「 dubbo默认超时设置容易引发的问题(默认1秒)」2023年9月23日

超时是针对消费端还是服务端&#xff1f;超时在哪设置&#xff1f;超时设置的优先级是什么&#xff1f;超时的实现原理是什么&#xff1f;超时解决的是什么问题 &#xff1f; 回答: 1.超时是针对消费端的&#xff0c;消费端会抛出TimeoutException 而服务器端仅仅是一个 warn…

kafka消费/发送消息,消息过大报错解决whose size is larger than the fetch size 1048576

目录 一、kafka消费报错原因二、解决方案1、原因一的解决方案2、原因二的解决方案 一、kafka消费报错原因 问题原因一&#xff1a;个是kafka本身的配置没有调整到上限 问题原因二&#xff1a;就是我们自己写python消费kafka代码的时候没有参数配置没有限制 RecordTooLargeE…

mysql-3:SQL语句

SQL语句 文章目录 SQL语句DDL语句数据库操作&#xff1a;database创建数据库查看数据库删除数据库其他数据库操作命令 表操作&#xff1a;table创建表查看表删除表修改表 DML语句插入记录&#xff1a;insert更新记录&#xff1a; update删除记录&#xff1a;delete DCL语句用户…

自己写过比较蠢的代码:从失败中学习的经验

文章目录 引言1. 代码没有注释2. 长函数和复杂逻辑3. 不恰当的变量名4. 重复的代码5. 不适当的异常处理6. 硬编码的敏感信息7. 没有单元测试结论 &#x1f389; 自己写过比较蠢的代码&#xff1a;从失败中学习的经验 ☆* o(≧▽≦)o *☆嗨~我是IT陈寒&#x1f379;✨博客主页&a…

由iframe引起的无法返回上一页的问题

问题描述 使用的框架: vue 页面里面嵌套了 iframe&#xff0c; 在设置iframe的src后&#xff0c;不知道为什么使用 router.back() 无法跳转到上一页&#xff0c;必须使用 router.go(-2) 才可以。但问题是有的时候用 router.go(-1) 可以&#xff0c;有的时候却不行。因为iframe…