在SpringBoot使用AOP防止接口重复提交

前言

防止接口重复提交有跟多种方法,可以在前端做处理。同样在后端也能处理,而且后端的处理也有很多中方法。最先能想到的就是加锁,也可以直接在该接口的实现过程中进行处理(可以参考防止数据重复提交的6种方法(超简单)!),本文主要介绍另一种借助AOP实现的方法。

AOP

关于AOP就不做过多赘述,可以参考我的另一篇文章Spring框架(下半部分 -AOP)。主要是借助它能增强方法的功能,对接口做以下处理,这个方法跟直接在接口种处理相似,话不多说,我们直接开始吧。

自定义注解

我们要灵活的使用AOP,注解是必不可少的,能帮我们更加便捷灵活的处理。我们先创建一个Submit注解,有该注解的接口就是我们要使用AOP处理的接口。

package com.blog.annotation;


import java.lang.annotation.*;

@Documented
@Retention(RetentionPolicy.RUNTIME) // 注解的存活时间
@Target(ElementType.METHOD) // 作用在方法上
public @interface Submit {
    /**
     * 提交的间隔时间
     * 默认是10s
     * @return
     */
    long expire() default 10000;
}

AOP的实现

其实使用AOP都有一个很创建的模板,我先贴出来,然后解释。

@Aspect
@Component
@Slf4j
public class SubmitAspect {

    @Pointcut("@annotation(com.blog.annotation.Submit)")
    public void pt() {
    
    }
    
    @Around("pt()")
    public Object around(ProceedingJoinPoint point) {
    
    }
}

@Pointcut("@annotation(com.blog.annotation.Submit)")就是切入点表达式,它的参数就是指定我们要处理,@Around("pt()")表明我们使用环绕通知来处理。具体的在我刚刚提到的另一篇博客中,感兴趣的可以仔细的了解一下。

接下来我们就要考虑该如何实现,防止接口重复提交就是说如果该接口提交过了,再来一次提交我们就不让他去执行,直接返回。现在就有一个问题了,我们该如何知道这个接口提交没提交过?我们是不是可以把提交过的接口保存下来,如果来了一个提交我们就去查找,如果找到了我们就不如他提交。

if (接口 not in 接口集合) {
	return "请勿重复提交";
}
// 说明接口没有提交,我们就执行该接口的方法
// 最重要的一点是把该接口存储到接口集合中
...执行提交操作...
接口集合.insert(接口)

所以我们就需要考虑使用哪些集合?这个接口该怎么存储?怎么执行原方法的操作?什么时候用户还能再次提交代码?等等,这些都是我们要考虑的问题。

关于集合的使用,我们首先能想到的是list、set、map等等,但是考虑到并发安全,我们应该使用线程安全的集合例如ConcurrentHashMap、CopyOnWriteArrayList等等。我们还要解决什么时候用户还能再次提交代码,我们可以设置一个实现,所以更加推荐ConcurrentHashMap,其key值就是我们为每一个接口构建的key(使用类名+方法名),value就是我们设置的时间。

想到这还有一个问题,我们为每一个接口构建key,如果有多个用户那么他们的key就是一样的,可事实上每个用户的同一接口的key一定是不能一样的,否则他提交了我提交不了,这凭什么?所以我们再构建每一个接口的key时加上当前用户的唯一标识,使用该用户的id就行。

那么又该如何获取到当前用户的id呢? 在这里我们ThreadLocal就可以,ThreadLocal也是很重要的,如果不是很了解,建议花点时间去认识它。在这里我们只需要知道,他是独立于线程之外的,每一个线程又一个独自的ThreadLocal ,也就是说,我们把每一个用户都存储在ThreadLocal 中。要的时候直接get就行。

到这里其实核心的问题都已经解决了,剩下的就是一些细节问题,在自己写的时候就能注意到。这里给出我的实现。我使用的redis实现,因为它设置过期时间会自动清除,不需要我们手动去清除,再加上redis是天生支持高并发。
SUBMIT_KEY_PREFIX和NOT_SUBMIT_REPEATEDLY都是一个常量而已,不用过多注意。

package com.blog.aspect;

import com.alibaba.fastjson.JSON;
import com.blog.annotation.Submit;
import com.blog.utils.JWTUtils;
import com.blog.utils.Result;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.time.Duration;

import static com.blog.domain.vo.ErrorCode.NOT_SUBMIT_REPEATEDLY;
import static com.blog.utils.ConstantValue.SUBMIT_KEY_PREFIX;

@Aspect
@Component
@Slf4j
public class SubmitAspect {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Pointcut("@annotation(com.blog.annotation.Submit)")
    public void pt() {
    }


    @Around("pt()")
    public Object around(ProceedingJoinPoint point) {
        try {
//            User user = UserThreadLocal.get();
//            String UserId = user.getId();
            // 假设这里是从ThreadLocal获取到的用户id。
            String UserId = "123456";
            Signature signature = point.getSignature();
            // 获取当前类名
            String className = point.getTarget().getClass().getSimpleName();
            // 获取当前方法名
            String methodName = signature.getName();
            // 拿到该方法
            Method method = ((MethodSignature) signature).getMethod();
            // 获取Submit注解
            Submit annotation = method.getAnnotation(Submit.class);
            // 获取过期时间
            long expire = annotation.expire();

            // 设置key值,每个用户对与每一个接口的key都是一样的
            String key = SUBMIT_KEY_PREFIX + DigestUtils.md5Hex(UserId) + "::" + className + "::" + methodName;

            // 首先查看是否已经提交过
            String value = redisTemplate.opsForValue().get(key);
            if (StringUtils.isNoneEmpty(value)) {
                return Result.error(NOT_SUBMIT_REPEATEDLY.getCode(), NOT_SUBMIT_REPEATEDLY.getMsg());
            }

            // 没有提交过就执行原方法
            Object proceed = point.proceed();
            redisTemplate.opsForValue().set(key, JSON.toJSONString(proceed), Duration.ofMillis(expire));
            return proceed;
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        return Result.error(-999, "系统异常");
    }
}

测试

接下来我们使用ApiPost进行测试,由于我们给定了id,所以我们只能测试单用户的,如果想测试多用户的,可以在请求路径中加上一个id,来模拟多用户。

间隔0ms,调用5次,只有一次成功,失败的几次,这里就不截图了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e2fHCLrs-1720583474961)(https://i-blog.csdnimg.cn/direct/e0b09889def54e4494172c9edc1571e1.png)]

间隔11000ms,调用2次,每次都成功,这是因为我们的冷静窗口是10000ms。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zG3MVA4q-1720583474962)()]

  • 23
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值