JAVA 通过Redis、注解和切面的形式实现接口幂等

一、先看场景:

  1. 填写完页面表单数据,手抖或者恶意在极短的时间内连续多次调用保存操作,表中出现了业务数据完全重复的数据,只有ID不一样。
  2. 老生常谈的付款操作,正常操作,我们只触发一次扣款操作,即使遇到其他的情况发生了多次扣款,但是也只应该扣款一次。
  3. ...

不同的场景,需要不同的幂等操作方式实现。
今天主要针对,上述第一种场景,通过注解+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 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 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 cacheObjMap = (Map) cacheObject;
                if (cacheObjMap.containsKey(redisKey)) {
                    Map preDataMap = (Map) cacheObjMap.get(redisKey);
                    boolean result = compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap, period);
                    Assert.isTrue(!result, "您提交过快,稍后再试");
                }
            }
            Map 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 nowMap, Map preMap) {
        String nowParams = (String) nowMap.get(REPEAT_PARAMS);
        String preParams = (String) preMap.get(REPEAT_PARAMS);
        return nowParams.equals(preParams);
    }

    /**
     * 判断两次间隔时间
     */
    private boolean compareTime(Map nowMap, Map 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 list;
    Test1 test1;

    @Data
    public static class Test1 implements Serializable {
        private static final long serialVersionUID = -4262288319285897072L;
        String name;

        Integer age;
        List list;
    }
}

3、启动项目,通过postman调用看下效果

在设定的5秒内调用一次,可以正常返回,如下图:

正常调用返回

Redis中也存在了对应的key值,如下图:
Redis中存在key

如果在设定的时间内多次操作,则触发幂等校验,如下图:


触发幂等校验

总结

幂等性的问题确实是在很多种场景都会需要,实现的方式有很多种,找一种最合适自己的。

你可能感兴趣的:(JAVA 通过Redis、注解和切面的形式实现接口幂等)