一、先看场景:
- 填写完页面表单数据,手抖或者恶意在极短的时间内连续多次调用保存操作,表中出现了业务数据完全重复的数据,只有ID不一样。
- 老生常谈的付款操作,正常操作,我们只触发一次扣款操作,即使遇到其他的情况发生了多次扣款,但是也只应该扣款一次。
- …
不同的场景,需要不同的幂等操作方式实现。
今天主要针对,上述第一种场景,通过注解+Redis+aop切面的形式处理。
二、撸码
废话不多说,直接撸码。
定义注解
package com.aida.annotation.common.annotation;
import com.aida.annotation.common.aspect.em.VariableProvider;
import java.lang.annotation.*;
/**
* 防止重复提交
*
* @author Mr.SoftRock
* @Date 2021/7/13 17:14
**/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 参数的提供方式
* @return
*/
VariableProvider variableProvider() default VariableProvider.PATH_VARIABLE;
/**
* 待校验 属性或者变量名称
* @return
*/
String variableName();
/**
* 参数变量位置
* @return
*/
int variablePosition() default 0;
/**
* 要切的资源名称,用于描述接口功能
* @return
*/
String name() default "";
/**
* key 前缀
* @return
*/
String prefix() default "";
/**
* 时间显示
* 这个参数,我们可以随便设定,默认单位是 秒
* 可以根据不通的业务要求去设定
* @return
*/
int period();
}
变量提供方式枚举VariableProvider
这个地方,可以去根据自己的实际业务去扩展。
package com.aida.annotation.common.aspect.em;
/**
* 变量提供方式
*
* @author Mr.SoftRock
* @Date 2021/7/13 17:23
**/
public enum VariableProvider {
/**
* 通过PATH路径提供
* url/{p}
*/
PATH_VARIABLE,
/**
* 通过请求参数提供
* url?p=1
*/
REQUEST_PARAMETER,
/**
* 通过请求体提供
*/
REQUEST_BODY,
}
定义切面类:
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、自定义业务防止重复提交切面 在controller层注入,标记key变量获取的方式和变量名称
* 2、本切面主要是用来识别解析得到的key
* 3、将获取到的key,根据业务规则去执行相应的处理
* 4、如果判断重复操作,直接断言出异常
*
* @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是一个不可变、线程安全的列表集合,它只会获取传入对象的一个副本。
ImmutableList<Object> keys = ImmutableList.of(StringUtils.join(limit.prefix(),
"_", limit.name(), "_", handler.getUserId(), request.getRequestURI().replaceAll("/", "_")));
//redis key
//我们使用 (前缀)+ 用户标识 + 调用url 做redis的key,将防重幂等的粒度缩小。
String redisKey = keys.toString();
//这个地方,我只使用了其中一种,可以根据自己的实际需求去调整。
if (Objects.equals(VariableProvider.REQUEST_BODY, repeatSubmit.variableProvider())) {
//如果参数提供者是REQUEST_BODY,则直接按照参数位置获取
Object arg = this.getArg(joinPoint.getArgs(), repeatSubmit.variablePosition());
if (Objects.isNull(arg)) {
log.error(String.format("无法执行重复提交的判断:请求类[%s]切片参数配置错误,无法获取指定位置的参数对象", aopTarget));
}
Assert.notNull(arg, String.format("无法执行重复提交的判断:请求[%s]切片参数配置有误,无法获取指定位置的参数对象", aopTarget));
Assert.isTrue(arg.getClass().getName().equals(repeatSubmit.variableName()), String.format("无法执行重复提交的判断:请求类[%s]切片参数配置错误,所配置的参数类与指定位置的参数类实际不一致", aopTarget));
//拿到接口传参
String strArg = arg.toString();
log.info("切面传参:-->{},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, "您提交过快,稍后再试");
}
}
Map<String, Object> cacheMap = new HashMap<>();
cacheMap.put(redisKey, nowDataMap);
redisCache.setCacheObject(CACHE_REPEAT_KEY, cacheMap, period, TimeUnit.SECONDS);
}
}
/**
* 获取切片目标信息
*
* @param joinPoint
* @return
*/
private String getAopTarget(JoinPoint joinPoint) {
String method = joinPoint.getSignature().getName();
String clazz = joinPoint.getTarget().getClass().getName();
return String.join("#", clazz, method);
}
/**
* 根据位置序号获取请求参数
*
* @param args
* @param position
* @return
*/
private Object getArg(Object[] args, int position) {
if (args == null || position + 1 > args.length) {
return null;
}
return args[position];
}
/**
* 判断参数是否相同
*/
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);
}
/**
* 判断两次间隔时间
*/
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);
}
}
三、验证
1、新建一个controller 调用方法
@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、用到的测试class类对象
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、启动项目,通过postman调用看下效果
在设定的5秒
内调用一次,可以正常返回,如下图:
Redis中也存在了对应的key值,如下图:
如果在设定的时间内多次操作,则触发幂等校验,如下图:
总结
幂等性的问题确实是在很多种场景都会需要,实现的方式有很多种,找一种最合适自己的。