OpenAPI 介面冪等實現
OpenAPI 介面冪等實現
1、冪等性是啥?
進行一次介面調用與進行多次相同的介面調用都能得到與預期相符的結果。
通俗的講,創建資源或更新資源的操作在多次調用後只生效一次。
2、什麼情況會需要保證冪等性
比如,購物時的下單操作,如前端提交按鈕未做並發、抖動控制,那麼用戶點擊一次。可能因為某些原因導致 Http 請求了多次,這就會導致用戶生成多個相同訂單。
再有,在我們的分散式項目中,為了提高通行的可靠性,通訊框架/MQ 可能會向數據服務推送多條相同的消息,如果不做冪等性控制,消息會被多次消費。
等等。。。
上述說了需要保證冪等性的場景,但我們實現冪等還要考慮下述條件:
- 如果服務接受了多個請求,且
冪等 token
和請求參數
完全一樣,服務應該保證冪等直接返回相似數據。 - 如果服務接受了多個請求,且
冪等 token
和請求參數
不完全一樣,服務應該拒絕冪等。
即:冪等 token 不一致直接拒絕冪等直接走正常邏輯;
冪等 token 一致但請求參數卻不一致,我們返回 token 異常,也可以拒絕冪等。 - 不同用戶之間的請求不能相互影響。
- 不同介面之間的請求不能相互影響。
即:不同介面不能被相同 token 影響。 - 更新介面不能使用快取數據,需要特殊處理。
比如:客戶端帶了冪等 token
請求了會員續費介面,此時響應了新的會員過期時間,然後客戶端又未攜帶了冪等 token
請求了會員續費介面,此時用戶會員到期時間得到了更新,用戶再次攜帶了冪等 token
進行請求,響應的快取的相似數據就明顯不對了。 - 這裡為啥說更新不能快取,而創建未提呢?因為大多數更新需要考慮快取一致性問題,而創建本身就是從無到有的過程,一般無需考慮,但也要根據實際業務來進行判斷,這裡後續實現方案為:創建直接走快取,更新為重新查庫。
3、如何保證冪等性
這裡提供一種無侵入的冪等處理方案,構建冪等表
。

流程解析:
-
客戶端請求時,為相關介面(所有創建資源的介面、部分更新介面)添加一個請求頭參數:clientToken ,
clientToken 是一個由客戶端生成的唯一的、大小寫敏感、不超過64個 ASCII 字元的字元串。例如,clientToken=123e4567-e89b-12d3-a456-426655440000
clientToken 可以由服務端提供單獨的介面生成。生成方式很多這裡不做討論。 -
服務端對相關介面做 AOP 切入,處理進行冪等判斷、冪等記錄、數據快取。
-
依據 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();
}
}
}
}