RedissonDistributedLocker+自定义注解实现接口防止重复提交

接口防止重复提交

防重
防重的目的是防止重复数据的产生,比如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;
    }
  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值