当前位置:   article > 正文

自定义注解实现复杂情况的幂等校验(接口防重提交)_注解方式 实现幂等

注解方式 实现幂等

背景

软件开发过程中,我们都会遇到一个问题,那就是接口的重复提交,特别是对于新增操作,这种需要控制幂等的接口,如果不处理好,系统可能会产生大量重复的垃圾数据。

对于防重复提交,分为前端和后端处理,前端处理方法通常为,当用户点击按钮后,禁用按钮,再发出接口请求,直到得到接口的响应,进行后续逻辑处理后,才允许再次点击按钮,前端防重提交,可靠性不是那么的高,如果想要实现高可靠的接口防重,还是需要在后端处理,下文,我们将一步步实现自定义注解的接口幂等校验。

设计思路

幂等key的生成

对于最简单的防重方式,就是通过用户提交的参数,判断在一定时间段内,是否重复提交,如果在规定时间内,同一接口发生了参数相同的多次请求,可视为重复,由于接口参数往往是比较多的,我们会将(接口全路径 + 参数)进行哈希计算,得到一个较短的 key ,添加自动过期时间存入 redis中用于判重,我们可以通过Spring提供的Aop操作,对接口进行前置的幂等校验处理。

上述的实现方式,粗略看来没啥问题,但是遇到某些特定场景的时候,就不行了,比如有一个接口,提供签到操作,接口只提供了一个签到时间的参数,如果通过上述方式防重,那么,对于并发很大的时候,大部分的请求无法成功,因为并发访问,大部分的签到时间是相同的,就会被误判为接口被重复请求了,但其实是不同的用户。

为了解决上述问题,我们可以在注解中添加一个属性,可以让用户针对不同的接口,自定义生成幂等校验的key,比如上述的签到接口,应该要加上用户ID来防重,这里我们可以通过 Spring EL表达式来实现用户的自定义规则。

此外,可能还会遇到另一种情况,使用实体来接收参数,但是实体有一些字段有默认值(随机的),这种情况下,用实体的键值对来生成幂等校验key就会没啥效果,因为实体中有随机字段,即使用户重复提交,也会出现不同,因此,我们可能需要在注解中添加一个属性,用来排除不参与生成幂等key的字段。

幂等校验的条件

对于设计规范的接口,他们的功能是分离开的,比如新增和编辑,是两个不同的接口,但是某些系统,新增和编辑会是同一个接口,通过参数来判断是新增还是编辑,这样的情况下,我们就不能对这个接口的所有请求都做幂等校验,而是只对新增操作的时候才校验,如同自定义幂等key的实现方式,我们可以通过Spring EL表达式来定义需要校验的条件,当满足这个条件,才对接口进行幂等校验,否则不处理。

我们的目的是实现一个可扩展、能满足更多复杂情况的注解,因此,对于幂等key和启用条件,我们会通过定义接口的方式,来满足更多情况的扩展性。

实现代码

注解

首先,我们需要定义一个注解,用于添加到接口方法上,做幂等校验的参数获取,代码如下:

import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver;
import cn.springcoder.common.aop.resolver.IdempotentKeyResolver;
import cn.springcoder.common.aop.resolver.impl.DefaultIdempotentEnabledResolver;
import cn.springcoder.common.aop.resolver.impl.DefaultIdempotentKeyResolver;
import cn.springcoder.common.aop.resolver.impl.ExpressionIdempotentEnabledResolver;
import cn.springcoder.common.aop.resolver.impl.ExpressionIdempotentKeyResolver;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;

/**
 * 接口幂等(防重提交)注解
 *
 * @author zhufeihong
 * @since 2023/10/13 10:31
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    /**
     * 幂等的超时时间,默认为 3 秒
     * <p>
     * 注意,如果执行时间超过它,请求还是会进来
     */
    int timeout() default 3;

    /**
     * 时间单位,默认为 SECONDS 秒
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 重复请求的提示
     */
    String message() default "重复请求,请稍后重试";

    /**
     * 使用的 Key 解析器
     */
    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;

    /**
     * 使用的 Key 参数
     * <p>
     * 通过 Spring EL {@link ExpressionIdempotentKeyResolver}表达式生成幂等 key 示例:
     * 1. 根据登录用户 id 控制 test 接口访问
     * keyArg = "T(cn.hutool.crypto.SecureUtil).md5('cn.springcoder.api.test.test' + T(cn.springcoder.modules.sys.utils.UserUtils).getUser().getId())"
     * 2. 根据登录用户 id + 参数 param.code 控制接口访问
     * keyArg = "T(cn.hutool.crypto.SecureUtil).md5(#param.getId() + T(cn.springcoder.modules.sys.utils.UserUtils).getUser().getId())"
     * </p>
     */
    String keyArg() default "";

    /**
     * 生成幂等 key 忽略的参数字段名
     */
    String keyFilterFields() default "tempId, page";

    /**
     * 动态判断是否启用幂等校验解析器
     * 如同一个接口,新增需要校验,修改不校验
     */
    Class<? extends IdempotentEnabledResolver> enabledResolver() default DefaultIdempotentEnabledResolver.class;

    /**
     * 启用幂等校验的表达式
     * <p>
     * 使用 Spring EL {@link ExpressionIdempotentEnabledResolver}表达式示例:
     * 1. 当参数 param.id 为空时启用幂等校验
     * enabledArg = "T(cn.hutool.core.util.StrUtil).isBlank(#param.id)"
     * 2. 当参数对象 param.getIsNewRecord() == true 时启用幂等校验
     * enabledArg = "#param.getIsNewRecord()"
     * </p>
     */
    String enabledArg() default "";
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80

注解中各个属性解释:

timeout本次幂等的超时时间,请求后,需要间隔多久才允许再次的重复请求,需要注意控制业务处理时间的大小;

timeUnit定义超时时间的单位,默认为秒;

message重复请求时接口返回的提示信息;

keyResolver生成幂等key的解析器,通过实现接口的方式,增加扩展性,满足更多场景下key的生成规则;

keyArg生成幂等key规则中需要用到的参数,例如,使用Spring EL解析器生成幂等key,这里填写的就是Spring EL表达式内容;

keyFilterFields通过参数来生成幂等key时,忽略的字段,多字段英文逗号分割;

enabledResolver是否启用幂等校验的解析器,通过实现接口的方式,增加扩展性,满足更多场景下是否启用幂等校验的规则定义;

enabledArg判断是否启用幂等校验中需要用到的参数,例如,使用Spring EL解析器做条件判断,这里填写的就是Spring EL表达式内容。

幂等key解析器接口

接口中,我们定义三个方法,

import cn.hutool.core.util.StrUtil;
import cn.springcoder.common.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;

/**
 * 幂等 Key 解析器接口
 *
 * @author zhufeihong
 * @since 2023/10/13 10:36
 */
public interface IdempotentKeyResolver {

    /**
     * 解析一个 Key
     *
     * @param idempotent 幂等注解
     * @param joinPoint  AOP 切面
     * @return Key
     */
    String resolver(JoinPoint joinPoint, Idempotent idempotent);

    /**
     * 默认的 key 解析表达式
     *
     * @return java.lang.String
     */
    default String defKeyArg() {
        return "";
    }

    /**
     * 获取 key 解析表达式
     *
     * @param idempotent 注解
     * @return java.lang.String
     */
    default String getKeyArg(Idempotent idempotent) {
        return StrUtil.isBlank(idempotent.keyArg()) ? defKeyArg() : idempotent.keyArg();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

接口中的方法解释:

resolver()通过传入的参数,按照实现类定义的处理逻辑,生成一个幂等校验的key字符串,建议用哈希算法处理得到一个较短的key

defKeyArg()定义当前解析器的默认参数;

getKeyArg()获取解析规则需要用到的参数,如果用户没在注解中自定义,就用当前解析器的默认值。

基于参数生成key的解析器实现类

import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.serializer.SimplePropertyPreFilter;
import cn.springcoder.common.annotation.Idempotent;
import cn.springcoder.common.aop.resolver.IdempotentKeyResolver;
import org.aspectj.lang.JoinPoint;
import org.springframework.stereotype.Component;

/**
 * 默认幂等 Key 解析器,通过 MD5(方法名 + 方法参数),组装成一个 Key
 *
 * @author zhufeihong
 * @since 2023/10/13 10:37
 */
@Component
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {

    /**
     * 生成幂等 key 忽略的字段
     */
    public SimplePropertyPreFilter getKeyFilter(String excludeFields) {
        excludeFields = StrUtil.replace(excludeFields, " ", "");
        SimplePropertyPreFilter filter = new SimplePropertyPreFilter();
        if (StrUtil.isBlank(excludeFields)) {
            return filter;
        }

        for (String field : StrUtil.split(excludeFields, ",")) {
            filter.getExcludes().add(field);
        }
        return filter;
    }

    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        String methodName = joinPoint.getSignature().toString();
        Object[] args = joinPoint.getArgs();
        StringBuilder argsStr = new StringBuilder();
        for (Object arg : args) {
            argsStr.append(JSONObject.toJSONString(arg, getKeyFilter(idempotent.keyFilterFields())));
        }
        return SecureUtil.md5(methodName + argsStr);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45

这里我们定义了一个默认的幂等key解析器,基于参数的方式来生成,同时可以支持排除某些字段,比如tempId字段的值,不作为判断是否重复提交的条件,我们将接口的多个参数,转为json字符串后拼接到一起,再加上接口方法的完整路径,进行一次简单的MD5哈希运算,得到一个长度固定的幂等校验key字符串。

基于SpringEL表达式生成key的解析器实现类

import cn.hutool.core.util.ArrayUtil;
import cn.springcoder.common.annotation.Idempotent;
import cn.springcoder.common.aop.IdempotentException;
import cn.springcoder.common.aop.resolver.IdempotentKeyResolver;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
 * 幂等 Key 解析器,基于 Spring EL 表达式生成 key
 *
 * @author zhufeihong
 * @since 2023/10/19 17:24
 */
@Component
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {

    private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
    private final ExpressionParser expressionParser = new SpelExpressionParser();

    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        // 获得被拦截方法参数名列表
        Method method = getMethod(joinPoint);
        Object[] args = joinPoint.getArgs();
        String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
        // 准备 Spring EL 表达式解析的上下文
        StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
        if (ArrayUtil.isNotEmpty(parameterNames)) {
            for (int i = 0; i < parameterNames.length; i++) {
                evaluationContext.setVariable(parameterNames[i], args[i]);
            }
        }

        // 解析参数
        Expression expression = expressionParser.parseExpression(getKeyArg(idempotent));
        return expression.getValue(evaluationContext, String.class);
    }

    private static Method getMethod(JoinPoint point) {
        // 处理,声明在类上的情况
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        if (!method.getDeclaringClass().isInterface()) {
            return method;
        }

        // 处理,声明在接口上的情况
        try {
            return point.getTarget().getClass().getDeclaredMethod(
                    point.getSignature().getName(), method.getParameterTypes());
        } catch (NoSuchMethodException e) {
            throw new IdempotentException(e);
        }
    }
}

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65

幂等校验启用条件解析器接口

import cn.hutool.core.util.StrUtil;
import cn.springcoder.common.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;

/**
 * 幂等校验是否启用解析器接口
 *
 * @author zhufeihong
 * @since 2023/10/13 11:39
 */
public interface IdempotentEnabledResolver {

    /**
     * 解析 当前是否进行幂等校验
     * true:本次进行幂等校验
     * false:本次忽略幂等校验
     *
     * @param idempotent 幂等注解
     * @param joinPoint  AOP 切面
     * @return 是否启用
     */
    boolean enabled(JoinPoint joinPoint, Idempotent idempotent);

    /**
     * 默认 enabled 解析表达式
     *
     * @return String
     */
    String defEnabledArg();

    /**
     * 获取 enabledArg 解析表达式
     *
     * @param idempotent 注解
     * @return String
     */
    default String getEnabledArg(Idempotent idempotent) {
        return StrUtil.isBlank(idempotent.enabledArg()) ? defEnabledArg() : idempotent.enabledArg();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40

接口中的方法解释:

enabled()定义判断逻辑,本次请求是否启用幂等校验,如果接口所有请求都要做校验,直接定义一个布尔解析器,返回true即可;

defEnabledArg()定义当前解析器的默认参数;

getEnabledArg()获取解析规则需要用到的参数,如果用户没在注解中自定义,就用当前解析器的默认值。

基于布尔的启用条件解析器实现类

import cn.hutool.core.convert.Convert;
import cn.springcoder.common.annotation.Idempotent;
import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver;
import org.aspectj.lang.JoinPoint;
import org.springframework.stereotype.Component;

/**
 * 默认幂等校验 enabled 解析器,基于布尔值
 *
 * @author zhufeihong
 * @since 2023/10/19 10:48
 */
@Component
public class DefaultIdempotentEnabledResolver implements IdempotentEnabledResolver {

    @Override
    public boolean enabled(JoinPoint joinPoint, Idempotent idempotent) {
        return Convert.toBool(getEnabledArg(idempotent), true);
    }

    @Override
    public String defEnabledArg() {
        return "true";
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

对于功能分离清晰的接口,往往是对某个接口的所有请求都需要做幂等校验的,比如新增接口,我们就可以通过基于布尔的实现方式,定义对当前接口的每次请求都做幂等校验。

基于SpringEL表达式的启用条件解析器实现类

import cn.hutool.core.util.ArrayUtil;
import cn.springcoder.common.annotation.Idempotent;
import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

/**
 * 幂等校验 enabled 解析器,基于 Spring EL 表达式
 *
 * @author zhufeihong
 * @since 2023/10/13 11:42
 */
@Component
public class ExpressionIdempotentEnabledResolver implements IdempotentEnabledResolver {

    private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
    private final ExpressionParser expressionParser = new SpelExpressionParser();

    @Override
    public boolean enabled(JoinPoint joinPoint, Idempotent idempotent) {
        // 获得被拦截方法参数名列表
        Method method = getMethod(joinPoint);
        Object[] args = joinPoint.getArgs();
        String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
        // 准备 Spring EL 表达式解析的上下文
        StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
        if (ArrayUtil.isNotEmpty(parameterNames)) {
            for (int i = 0; i < parameterNames.length; i++) {
                evaluationContext.setVariable(parameterNames[i], args[i]);
            }
        }

        // 解析参数
        Expression expression = expressionParser.parseExpression(getEnabledArg(idempotent));
        return Boolean.TRUE.equals(expression.getValue(evaluationContext, Boolean.class));
    }

    @Override
    public String defEnabledArg() {
        // 参数名称不是 entity 的需要在注解中进行自定义覆盖
        return "#entity.isAdd()";
    }

    private static Method getMethod(JoinPoint point) {
        // 处理,声明在类上的情况
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        if (!method.getDeclaringClass().isInterface()) {
            return method;
        }

        // 处理,声明在接口上的情况
        try {
            return point.getTarget().getClass().getDeclaredMethod(
                    point.getSignature().getName(), method.getParameterTypes());
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69

对于新增和保存使用的是同一个接口的情况,我们只需要对新增操作做幂等校验,此时我们就可以通过基于Spring EL表达式的方式来实现解析器,比如判断参数中id字段是否为空,为空则是新增,就做幂等校验,或者调用接收参数的实体的方法isAdd(),为true则是新增,需要做幂等校验。这样,我们就可以通过定义各种表达式,来满足对一个接口的幂等校验控制,而不是加了注解,就对这个接口的所有请求都做幂等校验。大大提升了适用性,满足更多更复杂的情况。

幂等校验异常类

/**
 * 接口重复提交异常
 *
 * @author zhufeihong
 * @since 2023/10/13 11:05
 */
public class IdempotentException extends RuntimeException {

    public IdempotentException() {
        super();
    }

    public IdempotentException(String message) {
        super(message);
    }

    public IdempotentException(String message, Throwable cause) {
        super(message, cause);
    }

    public IdempotentException(Throwable cause) {
        super(cause);
    }

    protected IdempotentException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

我们定义一个异常类,用于在幂等校验中抛出指定异常,方便其他业务捕捉和处理。

Aop切面实现类

有了上述准备,我们比较核心的代码来了,通过切面,拦截添加了注解@Idempotent的请求,在请求进入接口方法体之前,处理幂等校验逻辑。

import cn.springcoder.common.annotation.Idempotent;
import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver;
import cn.springcoder.common.aop.resolver.IdempotentKeyResolver;
import cn.springcoder.common.utils.RedisUtils;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.Assert;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 拦截声明了 {@link Idempotent} 注解的方法,实现接口幂等
 *
 * @author zhufeihong
 * @since 2023/10/13 10:32
 */
@Aspect
@Configuration
public class IdempotentAspect {

    protected Logger logger = LoggerFactory.getLogger(getClass());

    /**
     * IdempotentKeyResolver 集合
     */
    private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;

    /**
     * IdempotentEnabledResolver 集合
     */
    private final Map<Class<? extends IdempotentEnabledResolver>, IdempotentEnabledResolver> enabledResolvers;

    public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, List<IdempotentEnabledResolver> enabledResolvers) {
        this.keyResolvers = new HashMap<>();
        this.enabledResolvers = new HashMap<>();
        keyResolvers.forEach(v -> this.keyResolvers.put(v.getClass(), v));
        enabledResolvers.forEach(v -> this.enabledResolvers.put(v.getClass(), v));
    }

    @Before("@annotation(idempotent)")
    public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) {
        // 获得 IdempotentEnabledResolver
        IdempotentEnabledResolver enabledResolver = enabledResolvers.get(idempotent.enabledResolver());
        Assert.notNull(enabledResolver, "找不到对应的 IdempotentEnabledResolver");
        // 判断当前条件是否启用幂等校验
        if (!enabledResolver.enabled(joinPoint, idempotent)) {
            return;
        }

        // 获得 IdempotentKeyResolver
        IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
        Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
        // 解析 Key
        String key = "idempotent:" + keyResolver.resolver(joinPoint, idempotent);

        // 锁定 Key。
        boolean success = RedisUtils.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit().toSeconds(idempotent.timeout()));
        // 锁定失败,抛出异常
        if (!success) {
            logger.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
            throw new IdempotentException(idempotent.message());
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70

到此,我们已经通过代码,完成了所有的逻辑处理,改注解,不仅能实现接口幂等控制,还能做接口访问频率限制,例如,验证码发送接口,一个用户1分钟内只能请求一次,也可通过注解控制。下面,我们通过使用案例,来看看它的强大性与实用性。

使用案例

案列1

业务逻辑:

有一个新增接口,需要根据提交参数做幂等校验,只要提交参数相同,就认为是重复提交,5秒内不得再次提交。

@RequestMapping("test")
@Idempotent(timeout = 5)
public String test(@RequestBody InsertParam param) {
    return "SUCCESS";
}
  • 1
  • 2
  • 3
  • 4
  • 5

案例2

业务逻辑:

有一个新增接口,需要根据提交参数做幂等校验,只要提交参数相同(参数实体中,有一个字段含随机默认值,譬如private String tempId = IdUtil.uuid();),就认为是重复提交,5秒内不得再次提交。

@RequestMapping("test")
@Idempotent(timeout = 5, keyFilterFields = "tempId")
public String test(@RequestBody InsertParam param) {
    return "SUCCESS";
}
  • 1
  • 2
  • 3
  • 4
  • 5

案例3

业务逻辑:

有一个签到接口,无任何参数,通过获取登录用户id来进行签到,要求同一个账号,一分钟内不能重复请求。

@RequestMapping("test")
@Idempotent(timeout = 5,
            keyResolver = ExpressionIdempotentKeyResolver.class,
            keyArg = "T(cn.hutool.crypto.SecureUtil).md5('cn.springcoder.api.Test.test' + T(cn.springcoder.utils.UserUtils).getUser().getId())"
)
public String test() {
    return "SUCCESS";
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

案例4

业务逻辑:

有一个发送验证码接口,需要控制对同一个号码,一分钟内只能发送一次。

@RequestMapping("test")
@Idempotent(timeout = 60)
public String test(@RequestParam String phone) {
    return "SUCCESS";
}
  • 1
  • 2
  • 3
  • 4
  • 5

案例5

业务逻辑:

有一个保存接口,可以操作新增和修改,需要对新增操作做幂等控制,根据参数中字段id是否为空来判断是不是新增,为空是新增,5秒内不得重复提交。

@RequestMapping("test")
@Idempotent(timeout = 5, 
            enabledResolver = ExpressionIdempotentEnabledResolver.class,
            enabledArg = "T(cn.hutool.core.util.StrUtil).isBlank(#param.id)"
)
public String test(@RequestBody InsertParam param) {
    return "SUCCESS";
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

案例6

业务逻辑:

有一个保存接口,可以操作新增和修改,需要对新增操作做幂等控制,参数实体中方法isAdd() == true是新增,5秒内不得重复提交。

@RequestMapping("test")
@Idempotent(timeout = 5, 
            enabledResolver = ExpressionIdempotentEnabledResolver.class,
            enabledArg = "#param.isAdd()"
)
public String test(@RequestBody InsertParam param) {
    return "SUCCESS";
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/凡人多烦事01/article/detail/416807
推荐阅读
相关标签
  

闽ICP备14008679号