OpenAPI 接口冪等實現

OpenAPI 接口冪等實現

1、冪等性是啥?

進行一次接口調用與進行多次相同的接口調用都能得到與預期相符的結果。

通俗的講,創建資源或更新資源的操作在多次調用後只生效一次。

2、什麼情況會需要保證冪等性

比如,購物時的下單操作,如前端提交按鈕未做並發、抖動控制,那麼用戶點擊一次。可能因為某些原因導致 Http 請求了多次,這就會導致用戶生成多個相同訂單。

再有,在我們的分佈式項目中,為了提高通行的可靠性,通信框架/MQ 可能會向數據服務推送多條相同的消息,如果不做冪等性控制,消息會被多次消費。

等等。。。

上述說了需要保證冪等性的場景,但我們實現冪等還要考慮下述條件:

  1. 如果服務接受了多個請求,且冪等 token請求參數完全一樣,服務應該保證冪等直接返回相似數據。
  2. 如果服務接受了多個請求,且冪等 token請求參數不完全一樣,服務應該拒絕冪等。
    即:冪等 token 不一致直接拒絕冪等直接走正常邏輯;
    冪等 token 一致但請求參數卻不一致,我們返回 token 異常,也可以拒絕冪等。
  3. 不同用戶之間的請求不能相互影響。
  4. 不同接口之間的請求不能相互影響。
    即:不同接口不能被相同 token 影響。
  5. 更新接口不能使用緩存數據,需要特殊處理。
    比如:客戶端帶了 冪等 token請求了會員續費接口,此時響應了新的會員過期時間,然後客戶端又未攜帶了 冪等 token請求了會員續費接口,此時用戶會員到期時間得到了更新,用戶再次攜帶了 冪等 token 進行請求,響應的緩存的相似數據就明顯不對了。
  6. 這裡為啥說更新不能緩存,而創建未提呢?因為大多數更新需要考慮緩存一致性問題,而創建本身就是從無到有的過程,一般無需考慮,但也要根據實際業務來進行判斷,這裡後續實現方案為:創建直接走緩存,更新為重新查庫。

3、如何保證冪等性

這裡提供一種無侵入的冪等處理方案,構建冪等表

![image-冪等實現流程](/Users/yijun.wen/Library/Application Support/typora-user-images/image-20221024155920677.png)

流程解析:

  1. 客戶端請求時,為相關接口(所有創建資源的接口、部分更新接口)添加一個請求頭參數:clientToken ,
    clientToken 是一個由客戶端生成的唯一的、大小寫敏感、不超過64個 ASCII 字符的字符串。例如,clientToken=123e4567-e89b-12d3-a456-426655440000
    clientToken 可以由服務端提供單獨的接口生成。生成方式很多這裡不做討論。

  2. 服務端對相關接口做 AOP 切入,處理進行冪等判斷、冪等記錄、數據緩存。

  3. 依據 Redis 中 clientToken 的狀態信息返回相似信息

4、具體實現

4.1 創建切入點註解類

/**
 * 冪等註解
 *
 * @author Eajur.wen
 * @version 1.0
 * @date 2022-10-19 11:25:09
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IdempotentAnnotation {
    /**
     * 是否緩存獲取
     *
     * @return
     */
    boolean cache() default true;

    /**
     * 需要特殊處理的接口標識
     *
     * @return
     */
    String name() default "";
}

cache 默認為 true ,會緩存第一次響應數據,後續冪等的請求直接走緩存響應數據

name 為需要特殊處理接口的標識,在 cache 為 false 時,根據此標識做特殊處理

4.2 冪等 AOP 實現

/**
 * 冪等 AOP 實現
 *
 * @author Eajur.wen
 * @version 1.0
 * @date 2022-10-19 11:26:45
 */
@Component
@Aspect
@Slf4j
public class IdempotentAspect2 {

    public static final String CLIENT_TOKEN = "clientToken";
    public static final String RENEWAL_NO_CACHE = "renewal";
    public static final int CLIENT_TOKEN_MAX_LENGTH = 64;
    public static final String CLIENT_TOKEN_KEY_PRE = "client:token:";
    public static final String CLIENT_TOKEN_DATA_KEY_PRE = "client:token:data:";
    public static final String CLIENT_TOKEN_DATA_ID_KEY_PRE = "client:token:data:id:";
    public static final String CLIENT_TOKEN_DATA_ABSTRACT_KEY_PRE = "client:token:data:abstract:";
    public static final long CLIENT_TOKEN_TIMEOUT_MINUTES = 5;
    /**
     * 請求中 處理中
     */
    public static final int CLIENT_TOKEN_REQUEST_STATUS = 1;
    public static final int CLIENT_TOKEN_SUCCESS_STATUS = 2;

    @Autowired
    private HttpServletRequest request;

    @Autowired
    private RedisTemplate redisTemplate;

    @Pointcut("@annotation(com.eajur.idempotent.annotation.IdempotentAnnotation)")
    public void pt() {
    }

    @Around("pt()")
    public Object idempotent(ProceedingJoinPoint joinPoint) throws Throwable {
        // 沒有註解直接放行
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        IdempotentAnnotation annotation = method.getAnnotation(IdempotentAnnotation.class);
        if (annotation == null) {
            return joinPoint.proceed();
        }
        boolean cache = annotation.cache();
        String clientToken = request.getHeader(CLIENT_TOKEN);
        // 沒有請求頭直接放行
        if (!StringUtils.hasText(clientToken)) {
            return joinPoint.proceed();
        }
        // clientToken 不能過長
        if (clientToken.length() > CLIENT_TOKEN_MAX_LENGTH) {
            return new ViewData(ErrorCodeEnum.REPEATED_REQUEST_ERROR);
        }
        // 未登錄接口暫不做冪等
        Long memberId = SubjectUtil.getMemberId();
        if (memberId == null) {
            return joinPoint.proceed();
        }
        //獲取參數名稱和值
        Map<String, Object> nameAndArgs = CommonUtil.getNameAndValue(joinPoint);
        String jsonStr = JSONUtil.toJsonStr(nameAndArgs);
        String abstractData = SmUtil.sm3(jsonStr);

        // 記錄請求 clientToken
        String methodName = method.getName();
        String baseKey = memberId + ":" + methodName + ":" + clientToken;
        String key = CLIENT_TOKEN_KEY_PRE + baseKey;
        String dataKey = CLIENT_TOKEN_DATA_KEY_PRE + baseKey;
        String abstractKey = CLIENT_TOKEN_DATA_ABSTRACT_KEY_PRE + baseKey;
        ValueOperations ops = redisTemplate.opsForValue();
        Object flag = ops.getAndExpire(key, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
        if (flag == null) {
            ops.set(key, CLIENT_TOKEN_REQUEST_STATUS, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            Object proceed;
            try {
                proceed = joinPoint.proceed();
            } catch (Throwable throwable) {
                // 請求失敗清除冪等信息
                redisTemplate.delete(key);
                throw throwable;
            }
            ops.set(key, CLIENT_TOKEN_SUCCESS_STATUS, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            ops.set(abstractKey, abstractData, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            if (cache) {
                ops.set(dataKey, proceed, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            }
            return proceed;
        }
        // 請求參數不一致不做冪等
        Object oldAbstractData = ops.getAndExpire(abstractKey, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
        if (!abstractData.equals(oldAbstractData)) {
            Object proceed;
            try {
                proceed = joinPoint.proceed();
            } catch (Throwable throwable) {
                // 請求失敗清除冪等信息
                redisTemplate.delete(key);
                redisTemplate.delete(dataKey);
                redisTemplate.delete(abstractKey);
                throw throwable;
            }
            ops.set(key, CLIENT_TOKEN_SUCCESS_STATUS, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            ops.set(abstractKey, abstractData, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            if (cache) {
                ops.set(dataKey, proceed, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            }
            return proceed;
        }

        // 上次請求未完成
        if (flag.equals(CLIENT_TOKEN_REQUEST_STATUS)) {
            return new ViewData().error(ErrorCodeEnum.REPEATED_REQUEST_ERROR);
        }

        // 響應相似數據並刷新過期時間
        if (flag.equals(CLIENT_TOKEN_SUCCESS_STATUS) && cache) {
            Object data = ops.getAndExpire(dataKey, CLIENT_TOKEN_TIMEOUT_MINUTES, TimeUnit.MINUTES);
            return data;
        } else {
            String name = annotation.name();
            switch (name) {
                case RENEWAL_NO_CACHE:
                    // 特殊處理 我在這的處理是直接查庫獲取最新數據返回
                		// 可以通過 CLIENT_TOKEN_DATA_ID_KEY_PRE 緩存主鍵信息,也可以根據上面的 nameAndArgs 做處理
                    return new ViewData();
                default:
                    return joinPoint.proceed();
            }
        }
    }
}

4.3 在需要冪等的接口 Controller 方法上添加 @IdempotentAnnotation 註解即可