接口防止重复提交
防重
防重的目的是防止重复数据的产生,比如save操作时,用户快速点击两次,如果没做防重,就会产生重复数据。
幂等
比如请求多次,只有第一次请求才会做数据处理,后面的请求不会产生数据改变,例如退款接口,第一次退款成功后,后面的请求,不会再次退款成功。
可以说,幂等和防重其实是做一样的事情,区别在于,防重往往是只需要在几秒钟内防止重复提交,成功一次,而幂等是任何时间都只能成功一次。
场景说明:按照某个业务维度来说,需要限制数据的唯一性。
举个例子,一个用户在一天之内只能领一张优惠券,也就是说这几个参数组合起来是唯一的:用户id、日期、是否已领取优惠券标识(receiveFlag)。
存在问题,如果接口里面有复杂逻辑,查询及更新、插入数据等等,执行完成需要3s,在接口代码最前面需要判断这个人今天是否已经领取过优惠券,领取过就直接返回提示,没有领取则执行完整的方法。
假设现在用户没有领取过,然后用户快速点击领取按钮2次,调用了2次接口,按正常来说,第一次请求执行完,领取成功之后,第二次会返回已领取过的提示,但是因为这个方法执行完成需要3s,可能第一次请求还没有操作数据库,第二次清楚查询receiveFlag状态还是未领取,然后也往后执行了完整的代码,就是说这个接口被执行了2次,用户领取了2次优惠券。所以在接口里面去做这种唯一性的校验是不可靠的。
针对这种情况,从数据库的维度可以加上唯一性索引,但如果涉及的业务字段较多,数据库会频繁新增数据,需要频繁维护索引,这种方式并不好;本文要提到的,接口防重复提交,也可以说是在某个时间断内保证请求的幂等性
在上面例子里可以说,3s内防重+方法代码内判断唯一性并拦截=一直幂等【3s内接口执行完的前提下】
主要原理:定义切面拦截请求,利用Redis的分布式锁Redisson,对接口的入参比如用户token设置成一个key,或者基于业务去组合一个唯一key,再给这个key设置一个过期时间,这样就保证了在一段时间内同样的请求只能取到一个锁,从而保证接口的幂等性。具体实现主要通过切面织入设置redis的key以及加锁等逻辑。用全局拦截方式不够灵活,所以用注解的方式,灵活调用。
在本文里,因为我的应用场景其实是保持接口的幂等性,所以代码写了“幂等”,但实际上实现的功能是“防重”。
实现:
1、自定义注解
import java.lang.annotation.*;
/**
* @description: 幂等注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 参数的表达式,用来确定key值,例如:"#req.userInfo.userId,#req.seqId"
* @return
*/
String key();
/**
* 幂等过期时间,即:在此时间段内,对API进行幂等处理。
*/
long expireTime();
}
2、幂等性切面
/**
* @description: 幂等性切面
*/
@Slf4j
@Aspect
@Component
public class IdempotentAspect {
/**
* redis缓存key的模板
*/
private static final String KEY_TEMPLATE = "idempotent:%s";
@Resource
RedissonDistributedLocker redissonDistributedLocker;
@Autowired
RedisRepository redisRepository;
/**
* 定义切点
*/
@Pointcut("@annotation(cn.com.project.spring.annotation.Idempotent)")
public void executeIdempotent() {
}
@Around("executeIdempotent()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
//获取方法
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//获取幂等注解
Idempotent idempotent = method.getAnnotation(Idempotent.class);
// joinPoint获取参数值
Object[] args = joinPoint.getArgs();
String expressKey = idempotent.key();
if (!expressKey.contains("#")) {
//注解的值非SPEL表达式
throw new Exception("表达式错误",new Throwable() );
}
String key = parseKey(expressKey, method, args);
String cacheKey = String.format("MYAPP" + KEY_TEMPLATE, key);
//通过加锁确保只有一个接口能够正常访问
//尝试加锁
//这里是不限制时间,直到接口执行为止才释放 boolean tryLock = redissonDistributedLocker.tryLock(cacheKey, TimeUnit.SECONDS, 0, -1);
//这里根据入参设置锁过期时间,注意应该得确保在这个时间内接口能执行完
boolean tryLock = redissonDistributedLocker.tryLock(cacheKey, TimeUnit.SECONDS, 0, idempotent.expireTime());
if (tryLock) {
log.debug("get tryLock ,key:{}", cacheKey);
try {
Object proceed = joinPoint.proceed();
return proceed;
} finally {
log.debug("lease lock");
redissonDistributedLocker.unlock(cacheKey);
}
} else {
log.debug("get tryLock fail,key:{}", cacheKey);
throw new RuntimeException("请求已接收,请不要重复操作!");
}
}
/**
* 获取缓存的key
* key 定义在注解上,支持SPEL表达式
*
* @return
*/
private String parseKey(String key, Method method, Object[] args) {
if (StringUtils.isEmpty(key)) return null;
//获取被拦截方法参数名列表(使用Spring支持类库)
LocalVariableTableParameterNameDiscoverer u = new LocalVariableTableParameterNameDiscoverer();
String[] paraNameArr = u.getParameterNames(method);
//使用SPEL进行key的解析
ExpressionParser parser = new SpelExpressionParser();
//SPEL上下文
StandardEvaluationContext context = new StandardEvaluationContext();
//把方法参数放入SPEL上下文中
for (int i = 0; i < paraNameArr.length; i++) {
context.setVariable(paraNameArr[i], args[i]);
}
return parser.parseExpression(key).getValue(context, String.class);
}
}
3、使用:
在controller上,通过接口入参判断,加锁时间为2秒
@Idempotent(key = "#dto.userId", expireTime = 2L)
AOP-面向切面编程
特征:多个步骤间的隔离性、原代码无关性
相关注解:
1、@Aspect
切面,作用在定义的切面类上,在里面做需要织入的增强逻辑,一个Java类加了这个注解之后就声明这是一个切面类
2、@Pointcut
切点、连接点,在哪个层面使用的你的增强逻辑。可通配路径到所有controller,也可自定义一个注解,切点通过注解去作用于某个“点”。
3、@Before 前置通知,在某连接点(JoinPoint)之前执行的通知
4、@After 前置通知,在某连接点(JoinPoint)之后执行的通知
5、@Around 环绕通知,等价于@After 和 @Before,但又有点区别
@Around和@Before+@After的区别
@Around用这个注解的方法入参传的是ProceedingJionPoint pjp,可以决定当前线程能否进入核心方法中——通过调用pjp.proceed(),
而@After 和 @Before的方法入参只能是JoinPoint joinPoint,这个类没有proceed()方法,所以不能决定线程是否进入核心方法中
eg:
(1)自定义一个注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface MyAnnotation {
/**
* 参数的表达式,用来确定key值
* @return
*/
String key();
/**
* 幂等过期时间,即:在此时间段内,对API进行幂等处理
*/
long expireTime() default -1;
}
(2)aop切面
@Component
public class MyAspect {
/**
* 定义切点,此处切点为自定义注解。
* 【如果不通过注解进行切面织入,也可以使用通配对所有controller进行切面加强,比如@Pointcut("execution(public * com.myproject..*.*(..))"),
* 如果使用这种,直接@Before("Pointcut()")、@After("Pointcut()")、@Around("Pointcut()")即可】
*/
@Pointcut("@annotation(cn.com.myproject.annotation.MyAnnotation)")
public void executeMyAnnotation() {
}
@Before("executeMyAnnotation()")
public void beforeMethod(JoinPoint joinPoint) {
//dosomething
}
@After("executeMyAnnotation()")
public void afterMethod(JoinPoint joinPoint) {
//dosomething
}
/**
* @Around注解 环绕执行,就是在调用目标方法之前和调用之后都会执行你的代码逻辑,划分点是执行Object proceed = joinPoint.proceed()。等价于@After 和 @Before
*/
@Around("executeMyAnnotation()")
public Object Around(ProceedingJoinPoint pjp) throws Throwable {
//dosomething=>@Before
// 调用执行目标方法(result为目标方法执行结果)
Object result = pjp.proceed();
//dosomething=>@After
return result;
}