設計接口
1. 接口的設計需要考慮,比如接口的命名、參數列表、包裝結構體、接口粒度、版本策略、冥等性實現、同步異步處理方式等。
2. 其中和接口設計相關重要的有三點:包裝結構體、版本策略、同步異步處理方式。
接口的響應要明確處理結果
兩個原則
1. 對外隱藏內部實現。
2. 設計接口結構時,明確每個字段的含義,以及客戶端的處理方式。
@Data public class APIResponse<T> { 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<OrderInfo> 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; }
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<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 ""; }
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<Object> { //自動處理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<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; }
//上傳接口的請求和響應 傳入二進制文件,傳出原文件和縮略圖下載地址:
@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<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; } //文件上傳查詢接口以任務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<APIVersionCondition> { @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<APIVersionCondition> getCustomTypeCondition(Class<?> handlerType) { APIVersion apiVersion = AnnotationUtils.findAnnotation(handlerType, APIVersion.class); return createCondition(apiVersion); } @Override protected RequestCondition<APIVersionCondition> getCustomMethodCondition(Method method) { APIVersion apiVersion = AnnotationUtils.findAnnotation(method, APIVersion.class); return createCondition(apiVersion); } private RequestCondition<APIVersionCondition> createCondition(APIVersion apiVersion) { return apiVersion == null ? null : new APIVersionCondition(apiVersion.value(), apiVersion.headerKey()); } }
原文鏈接:https://time.geekbang.org/column/article/228968