一并徘、先看場(chǎng)景:
- 填寫(xiě)完頁(yè)面表單數(shù)據(jù),手抖或者惡意在極短的時(shí)間內(nèi)連續(xù)多次調(diào)用保存操作扰魂,表中出現(xiàn)了業(yè)務(wù)數(shù)據(jù)完全重復(fù)的數(shù)據(jù)麦乞,只有ID不一樣。
- 老生常談的付款操作劝评,正常操作姐直,我們只觸發(fā)一次扣款操作,即使遇到其他的情況發(fā)生了多次扣款蒋畜,但是也只應(yīng)該扣款一次声畏。
- ...
不同的場(chǎng)景,需要不同的冪等操作方式實(shí)現(xiàn)百侧。
今天主要針對(duì)砰识,上述第一種場(chǎng)景能扒,通過(guò)注解+Redis+aop切面的形式處理佣渴。
二、擼碼
廢話(huà)不多說(shuō)初斑,直接擼碼辛润。
定義注解
package com.aida.annotation.common.annotation;
import com.aida.annotation.common.aspect.em.VariableProvider;
import java.lang.annotation.*;
/**
* 防止重復(fù)提交
*
* @author Mr.SoftRock
* @Date 2021/7/13 17:14
**/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 參數(shù)的提供方式
* @return
*/
VariableProvider variableProvider() default VariableProvider.PATH_VARIABLE;
/**
* 待校驗(yàn) 屬性或者變量名稱(chēng)
* @return
*/
String variableName();
/**
* 參數(shù)變量位置
* @return
*/
int variablePosition() default 0;
/**
* 要切的資源名稱(chēng),用于描述接口功能
* @return
*/
String name() default "";
/**
* key 前綴
* @return
*/
String prefix() default "";
/**
* 時(shí)間顯示
* 這個(gè)參數(shù)见秤,我們可以隨便設(shè)定砂竖,默認(rèn)單位是 秒
* 可以根據(jù)不通的業(yè)務(wù)要求去設(shè)定
* @return
*/
int period();
}
變量提供方式枚舉VariableProvider
這個(gè)地方,可以去根據(jù)自己的實(shí)際業(yè)務(wù)去擴(kuò)展鹃答。
package com.aida.annotation.common.aspect.em;
/**
* 變量提供方式
*
* @author Mr.SoftRock
* @Date 2021/7/13 17:23
**/
public enum VariableProvider {
/**
* 通過(guò)PATH路徑提供
* url/{p}
*/
PATH_VARIABLE,
/**
* 通過(guò)請(qǐng)求參數(shù)提供
* url?p=1
*/
REQUEST_PARAMETER,
/**
* 通過(guò)請(qǐng)求體提供
*/
REQUEST_BODY,
}
定義切面類(lèi):
package com.aida.annotation.common.aspect;
import com.aida.annotation.common.annotation.RepeatSubmit;
import com.aida.annotation.common.aspect.em.VariableProvider;
import com.aida.annotation.common.redis.CommonRedisCache;
import com.aida.annotation.common.utils.ServletUtils;
import com.aida.annotation.support.security.AccountPrincipalUtils;
import com.aida.annotation.support.security.userdetails.AccountPrincipal;
import com.baomidou.mybatisplus.core.toolkit.SystemClock;
import com.google.common.collect.ImmutableList;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
* 1乎澄、自定義業(yè)務(wù)防止重復(fù)提交切面 在controller層注入,標(biāo)記key變量獲取的方式和變量名稱(chēng)
* 2测摔、本切面主要是用來(lái)識(shí)別解析得到的key
* 3置济、將獲取到的key,根據(jù)業(yè)務(wù)規(guī)則去執(zhí)行相應(yīng)的處理
* 4锋八、如果判斷重復(fù)操作浙于,直接斷言出異常
*
* @author Mr.SoftRock
* @Date 2021/7/13 17:28
**/
@Aspect
@Component
@Slf4j
public class RepeatSubmitAspect {
public final String CACHE_REPEAT_KEY = "repeatSubmitData:";
public final String REPEAT_TIME = "repeatTime";
public final String REPEAT_PARAMS = "repeatParams";
@Autowired
CommonRedisCache redisCache;
@Before("@annotation(repeatSubmit)")
public void repeatSubmitCheck(JoinPoint joinPoint, RepeatSubmit repeatSubmit) {
AccountPrincipal handler = AccountPrincipalUtils.getCurrentHandler();
String aopTarget = this.getAopTarget(joinPoint);
HttpServletRequest request = ServletUtils.getRequest();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method signatureMethod = signature.getMethod();
RepeatSubmit limit = signatureMethod.getAnnotation(RepeatSubmit.class);
int period = limit.period();
//ImmutableList是一個(gè)不可變、線(xiàn)程安全的列表集合挟纱,它只會(huì)獲取傳入對(duì)象的一個(gè)副本羞酗。
ImmutableList<Object> keys = ImmutableList.of(StringUtils.join(limit.prefix(),
"_", limit.name(), "_", handler.getUserId(), request.getRequestURI().replaceAll("/", "_")));
//redis key
//我們使用 (前綴)+ 用戶(hù)標(biāo)識(shí) + 調(diào)用url 做redis的key,將防重冪等的粒度縮小紊服。
String redisKey = keys.toString();
//這個(gè)地方檀轨,我只使用了其中一種胸竞,可以根據(jù)自己的實(shí)際需求去調(diào)整。
if (Objects.equals(VariableProvider.REQUEST_BODY, repeatSubmit.variableProvider())) {
//如果參數(shù)提供者是REQUEST_BODY,則直接按照參數(shù)位置獲取
Object arg = this.getArg(joinPoint.getArgs(), repeatSubmit.variablePosition());
if (Objects.isNull(arg)) {
log.error(String.format("無(wú)法執(zhí)行重復(fù)提交的判斷:請(qǐng)求類(lèi)[%s]切片參數(shù)配置錯(cuò)誤裤园,無(wú)法獲取指定位置的參數(shù)對(duì)象", aopTarget));
}
Assert.notNull(arg, String.format("無(wú)法執(zhí)行重復(fù)提交的判斷:請(qǐng)求[%s]切片參數(shù)配置有誤撤师,無(wú)法獲取指定位置的參數(shù)對(duì)象", aopTarget));
Assert.isTrue(arg.getClass().getName().equals(repeatSubmit.variableName()), String.format("無(wú)法執(zhí)行重復(fù)提交的判斷:請(qǐng)求類(lèi)[%s]切片參數(shù)配置錯(cuò)誤,所配置的參數(shù)類(lèi)與指定位置的參數(shù)類(lèi)實(shí)際不一致", aopTarget));
//拿到接口傳參
String strArg = arg.toString();
log.info("切面?zhèn)鲄?-->{},redisKey==>{}", strArg, redisKey);
Map<String, Object> nowDataMap = new HashMap<>();
nowDataMap.put(REPEAT_PARAMS, strArg);
nowDataMap.put(REPEAT_TIME, SystemClock.now());
Object cacheObject = redisCache.getCacheObject(CACHE_REPEAT_KEY);
if (Objects.nonNull(cacheObject)) {
Map<String, Object> cacheObjMap = (Map<String, Object>) cacheObject;
if (cacheObjMap.containsKey(redisKey)) {
Map<String, Object> preDataMap = (Map<String, Object>) cacheObjMap.get(redisKey);
boolean result = compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, period);
Assert.isTrue(!result, "您提交過(guò)快,稍后再試");
}
}
Map<String, Object> cacheMap = new HashMap<>();
cacheMap.put(redisKey, nowDataMap);
redisCache.setCacheObject(CACHE_REPEAT_KEY, cacheMap, period, TimeUnit.SECONDS);
}
}
/**
* 獲取切片目標(biāo)信息
*
* @param joinPoint
* @return
*/
private String getAopTarget(JoinPoint joinPoint) {
String method = joinPoint.getSignature().getName();
String clazz = joinPoint.getTarget().getClass().getName();
return String.join("#", clazz, method);
}
/**
* 根據(jù)位置序號(hào)獲取請(qǐng)求參數(shù)
*
* @param args
* @param position
* @return
*/
private Object getArg(Object[] args, int position) {
if (args == null || position + 1 > args.length) {
return null;
}
return args[position];
}
/**
* 判斷參數(shù)是否相同
*/
private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap) {
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}
/**
* 判斷兩次間隔時(shí)間
*/
private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap, int period) {
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
return (time1 - time2) < (period * 1000);
}
}
三拧揽、驗(yàn)證
1剃盾、新建一個(gè)controller 調(diào)用方法
@RepeatSubmit(variableProvider = VariableProvider.REQUEST_BODY, variableName = "com.aida.annotation.common.controller.dto.Test", period = 5,
name = "testRepeatSubmit", prefix = "repeat")
@PostMapping("/repeat")
public int testRepeatSubmit(@RequestBody Test test) {
return ATOMIC_INTEGER.incrementAndGet();
}
2、用到的測(cè)試class類(lèi)對(duì)象
package com.aida.annotation.common.controller.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* @author Mr.SoftRock
* @Date 2021/7/13 19:28
**/
@Data
public class Test {
String name;
Integer age;
List<String> list;
Test1 test1;
@Data
public static class Test1 implements Serializable {
private static final long serialVersionUID = -4262288319285897072L;
String name;
Integer age;
List<String> list;
}
}
3淤袜、啟動(dòng)項(xiàng)目痒谴,通過(guò)postman調(diào)用看下效果
在設(shè)定的5秒
內(nèi)調(diào)用一次,可以正常返回铡羡,如下圖:
Redis中也存在了對(duì)應(yīng)的key值积蔚,如下圖:
如果在設(shè)定的時(shí)間內(nèi)多次操作,則觸發(fā)冪等校驗(yàn)烦周,如下圖:
總結(jié)
冪等性的問(wèn)題確實(shí)是在很多種場(chǎng)景都會(huì)需要尽爆,實(shí)現(xiàn)的方式有很多種,找一種最合適自己的读慎。