接口设计注意的问题
设计接口
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 APIResponseserver(@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 { APIResponseapiResponse = 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
//添加不希望实现自动包装接口的注解
@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(); //上传原始文件任务提交到线程池处理 FutureuploadFile = 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 ConcurrentHashMapdownloadUrl = 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