基于自定义SprngAOP注解+redis方式简实现防止重复提交

142 阅读4分钟

重复提交可能的原因


  • 前端重复点击
  • 网关超时重试
  • 服务有多个节点,定时任务不支持分布式(例:JDK Timer、SpringTask、delayQueue等)

常见解决方案


  • 前端提交后按钮置灰(前端可能会被绕过,所以后端也要同步校验)
  • 后端提供一个接口生成唯一的uuid,前段提交时带上uuid信息,后端校验uuid是否重复,重复即为相同请求(uuid可存在redis里)
  • 后端数据库设置唯一索引

以上方式都可以解决一部分重复提交的问题。但是往往在我们实际项目基本所有提交都要做防重处理,很自然就能想到可以把这一操作抽出来做成一个spring的AOP切面。而为了更简单的实现,我们还你能进一步的结合redis去做。

前置知识


1、熟练StringRedisTemplate基本操作

String操作的setIfAbsent命令,如果key不存在则写入这个key并设置过期时间,等价于setNX和setPX命令的组合。

Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);

2、理解SpringAOP中AspecJ基本原理及基本使用

AspectJ定义的通知类型

  • Before(前置通知):目标对象的方法调用之前触发
  • After (后置通知):目标对象的方法调用之后触发
  • AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发
  • AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。
  • Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法

执行顺序

此执行顺序是基于spring-aop-5.2.15.RELEASE版本,在Spring5.2.7之前around after@Afer之前执行,之后顺序是调转过来的详细看 这里:Spring AOP 的执行顺序详解 – 源码巴士

  • 正常return: @aroundjoinPoint.proceed()前的代码->@Before->被代理的方法体->@AfterReturning->@After->@aroundjoinPoint.proceed()后的代码

image.png

  • 被代理的方法发生异常:@aroundjoinPoint.proceed()前的代码->@Before->被代理的方法体->@AfterThrowing->@After 此时joinPoint.proceed()后的代码并不会执行

image.png

功能实现


自定义注解

/**
 * @author CVToolMan
 * @create 2024/1/21 10:35
 */
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface CheckRepeat {
    /**
     * 加锁过期时间,默认是5秒
     * @return
     */
    long lockTime() default 5;

    String msg() default "请忽重复提交!";
}

定义切面

通过切面拦截标注了@CheckRepeat注解的方法,并通过类的全路径+方法名+userId作为key写入redis,在一段时间内限制用户重复请求,默认为5S。注意代码里还有一段如果发生异常的时候要删除key的逻辑,为什么这样处理呢,试想一下如果请求过程中由于某种原因发了异常,这个时候按常理来说保存是没有成功,就不应该限制用户第二次发起的请求。所以这时我们应该把key删除。


/**
 * @author CVToolMan
 * @create 2024/1/21 10:34
 */
@Aspect
@Component
@Slf4j
public class CheckRepeatAspect {
    private StringRedisTemplate stringRedisTemplate;

    private static final ThreadLocal<String> KEY = new ThreadLocal<>();

    public CheckRepeatAspect(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @Pointcut("@annotation(checkRepeat)")
    public void pointCutNoRepeatSubmit(CheckRepeat checkRepeat) {

    }

    /**
     * 类名+方法名+userId作为redis的key
     *
     * @param joinPoint
     * @param userId
     * @return
     */
    private String formatKey(ProceedingJoinPoint joinPoint, Long userId) {
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String path = method.getDeclaringClass().getName();
        String methodName = method.getName();
        String key = "check:repeat:" + String.format("%s-%s-%s", path, methodName, userId);
        KEY.set(key);
        return key;
    }

    @Around("pointCutNoRepeatSubmit(checkRepeat)")
    public Object around(ProceedingJoinPoint joinPoint, CheckRepeat checkRepeat) throws Throwable {
        log.info("[CheckRepeatAspect around]方法执行前>>>>>>>");
//        Long userId = UserHolder.getUser().getId();
        //实际项目中用户登录的信息可能存在redis里并放到ThreadLocal线程上下文中获取,这里直接写死一个
        Long userId =2343243L;
        if (null != userId) {
            long lockTime = checkRepeat.lockTime();
            String msg = checkRepeat.msg();

            String key = formatKey(joinPoint, userId);
            //加锁
            boolean isLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
            if (!isLock) {
                //抛异常
                throw new RuntimeException(msg);
            }
        }
        Object obj = joinPoint.proceed();
        log.info("[CheckRepeatAspect around]方法执行后>>>>>>>");
        return obj;
    }

    @Before("pointCutNoRepeatSubmit(checkRepeat)")
    public void before(CheckRepeat checkRepeat) {
        log.info("[CheckRepeatAspect before],执行了>>>>>>>");
    }

    @AfterReturning("pointCutNoRepeatSubmit(checkRepeat)")
    public void afterReturning(CheckRepeat checkRepeat) {
        log.info("[CheckRepeatAspect afterReturning],执行了>>>>>>>");
    }

    @After("pointCutNoRepeatSubmit(checkRepeat)")
    public void after(CheckRepeat checkRepeat) {
        log.info("[CheckRepeatAspect after],执行了>>>>>>>");
        KEY.remove();
    }

    /**
     * 发生异常删除key
     *
     * @param checkRepeat
     * @param e
     */
    @AfterThrowing(value = "pointCutNoRepeatSubmit(checkRepeat)", throwing = "e")
    public void doAfterThrowing(CheckRepeat checkRepeat, Exception e) {
        log.info("[CheckRepeatAspect doAfterThrowing],方法执行了>>>>>>" );
        if (StringUtils.isNotBlank(KEY.get())) {
            stringRedisTemplate.delete(KEY.get());
        }
    }

}

使用

在Controller加注解,注解支持自定义指定key的失效时间和拦截下来的描述信息。

    @PostMapping
    @CheckRepeat(lockTime = 10,msg = "请求已提交,请刷新页面!")
    public Result saveShop(@RequestBody Shop shop) throws Exception{
        log.info("进入saveShop方法>>>>>>>>");
//        if (true){
//            throw new Exception("发生错误");
//        }
        // 写入数据库
        shopService.save(shop);
        // 返回店铺id
        return Result.ok(shop.getId());
    }

效果展示

用postMan10s内连续发起两次请求

image.png