接口設計注意的問題


設計接口

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM