接口的幂等性是指在分布式系统中,一个操作或者请求无论执行多少次,其结果都是相同的。换句话说,即使多次执行同一个操作,它也不会产生副作用,或者不会改变系统的状态。幂等性是设计 RESTful API 时的一个重要原则。

幂等性通常适用于以下两种情况:

  1. 安全操作: 例如,GET 请求用于获取资源,不论执行多少次,都不会改变资源的状态,因此是幂等的。
  2. 状态改变操作: 例如,PUT 请求用于更新资源,如果资源已经处于请求中描述的状态,再次执行相同的 PUT 请求不会对资源造成进一步的改变,因此也是幂等的。

幂等性对于确保分布式系统的一致性和可靠性非常重要,特别是在网络请求可能会因为各种原因被重复发送的情况下。例如,如果一个用户提交了一个表单,但由于网络问题,表单被提交了两次,幂等性可以保证系统不会因为重复的提交而产生错误的状态或数据。

如何实现幂等性?

前端控制,在前端做拦截,比如按钮点击一次之后就置灰或者隐藏。但是往往前端并不可靠,还是得后端处理才更放心。

现在我们在后端通过一个注解Idempotent来一步步实现接口的幂等性。

  1. 首先定义一个幂等注解Idempotent,用于标注方法为幂等操作。
package org.jeecg.common.idempotent.annotation;

import org.jeecg.common.idempotent.keyresolver.IdempotentKeyResolver;
import org.jeecg.common.idempotent.keyresolver.impl.DefaultIdempotentKeyResolver;

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 ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:23
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

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

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

    /**
     * 提示信息,正在执行中的提示
     */
    String message() default "重复请求,请稍后重试";

    /**
     * 使用的 Key 解析器
     * 设置用于生成幂等键的解析器类。
     * 幂等键用于唯一标识一个幂等操作,通过解析器可以从方法参数等中提取出此键。
     * 默认解析器为DefaultIdempotentKeyResolver,它根据方法参数生成幂等键。
     */
    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;

    /**
     * 使用的 Key 参数
     * 设置用于生成幂等键的参数名。
     * 此参数名应对应方法的一个参数,解析器将根据此参数值生成幂等键。
     * 如果不设置,默认解析器将根据所有参数生成幂等键。
     * 注意,如果设置了keyResolver为自定义解析器,此参数可能被忽略。
     *
     * @return 用于生成幂等键的参数名。
     */
    String keyArg() default "";
}
  1. 定义幂等性切面类,用于对标注了{@link Idempotent}注解的方法进行幂等性校验。
package org.jeecg.common.idempotent.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.jeecg.common.idempotent.CollectionUtils;
import org.jeecg.common.idempotent.IdempotentRedisDAO;
import org.jeecg.common.idempotent.annotation.Idempotent;
import org.jeecg.common.idempotent.keyresolver.IdempotentKeyResolver;
import org.springframework.util.Assert;

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

/**
 * 幂等性切面类,用于对标注了{@link Idempotent}注解的方法进行幂等性校验。
 * 通过在方法执行前检查是否已处理过相同的请求,来防止重复操作。
 * @author ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:28
 */
@Slf4j
@Aspect
public class IdempotentAspect {

    /**
     * IdempotentKeyResolver 集合
     * 幂等键解析器的映射,用于根据注解中指定的类名获取对应的解析器实例。
     */
    private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;

    /**
     * Redis操作DAO,用于在Redis中进行幂等键的设置和查询。
     */
    private final IdempotentRedisDAO idempotentRedisDAO;

    /**
     * 构造函数,初始化幂等键解析器映射和Redis DAO。
     *
     * @param keyResolvers 幂等键解析器列表。
     * @param idempotentRedisDAO 幂等性Redis操作DAO。
     */
    public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
        this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
        this.idempotentRedisDAO = idempotentRedisDAO;
    }

    /**
     * 在方法执行前的切面逻辑,用于实现幂等性校验。
     * 通过注解@annotation(idempotent)来标识需要进行幂等性校验的方法。
     *
     * @param joinPoint 切点,用于获取方法参数和签名等信息。
     * @param idempotent 幂等性注解实例,包含幂等键解析器的类名、锁的超时时间等信息。
     * @throws RuntimeException 如果key已存在,即重复请求,抛出运行时异常。
     */
    @Before("@annotation(idempotent)")
    public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) {
        // 根据注解中指定的幂等键解析器类名,获取对应的幂等键解析器
        // 获得 IdempotentKeyResolver
        IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
        // 确保幂等键解析器不为空,否则抛出异常
        Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
        // 使用幂等键解析器解析出请求的幂等键
        // 解析 Key
        String key = keyResolver.resolver(joinPoint, idempotent);
        // 日志记录解析出的幂等键
        log.info("key: {}", key);
        // 尝试在Redis中设置幂等键,如果不存在则设置成功,表示该请求是第一次到来
        // 锁定 Key。
        boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
        // 如果设置失败,表示幂等键已存在,即该请求是重复的,抛出运行时异常
        // 锁定失败,抛出异常
        if (!success) {
            log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
            throw new RuntimeException(idempotent.message());
        }
    }

}
  1. Key 解析器接口
package org.jeecg.common.idempotent.keyresolver;

import org.aspectj.lang.JoinPoint;
import org.jeecg.common.idempotent.annotation.Idempotent;

/**
 * 幂等性键解析器接口。
 * 该接口用于解析方法调用的幂等性键,以确保重复调用的处理符合幂等性原则。
 * @author ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:21
 */
public interface IdempotentKeyResolver {

    /**
     * 解析幂等性键 key
     *
     * @param joinPoint 切点,包含方法调用的相关信息。
     * @param idempotent 幂等性注解,用于配置幂等性处理的相关属性。
     * @return 解析得到的幂等性键。
     * @description 该方法通过分析方法参数和注解属性,生成一个唯一的幂等性键,用于标识一个幂等操作。
     */
    String resolver(JoinPoint joinPoint, Idempotent idempotent);
}
  1. 定义两个 Key 解析器接口的实现类,一个默认的根据方法名和参数生成幂等性 key,一个基于 Spring Expression Language (SpEL) 首先是 DefaultIdempotentKeyResolver
/**
 * 默认的幂等性关键字解析器,实现了IdempotentKeyResolver接口。
 * 该解析器用于根据方法名和参数生成幂等性关键字。
 */
package org.jeecg.common.idempotent.keyresolver.impl;

import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import org.apache.shiro.web.servlet.ShiroHttpServletRequest;
import org.aspectj.lang.JoinPoint;
import org.jeecg.common.idempotent.annotation.Idempotent;
import org.jeecg.common.idempotent.keyresolver.IdempotentKeyResolver;

/**
 * 默认幂等性关键字解析器类。
 */
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {

    /**
     * 根据切面连接点和幂等注解,解析并返回幂等性关键字。
     *
     * @param joinPoint 切面连接点,包含目标方法和其参数信息。
     * @param idempotent 幂等注解,用于配置幂等性相关属性。
     * @return 生成的幂等性关键字。
     */
    /**
     * 解析一个 Key
     *
     * @param joinPoint  AOP 切面
     * @param idempotent 幂等注解
     * @return Key
     */
    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        // 获取目标方法名
        String methodName = joinPoint.getSignature().toString();

        // 创建一个数组,用于存储除ShiroHttpServletRequest外的所有参数
        Object[] objects = new Object[joinPoint.getArgs().length];
        for (int i = 0; i < joinPoint.getArgs().length; i++) {
            // 排除ShiroHttpServletRequest类型的参数,因为它们不参与幂等性关键字的生成
            if (!(joinPoint.getArgs()[i] instanceof ShiroHttpServletRequest)) {
                objects[i] = joinPoint.getArgs()[i];
            }
        }

        // 将参数数组转换为字符串,使用逗号分隔
        String argsStr = StrUtil.join(",", objects);

        // 使用methodName和argsStr拼接后的字符串进行MD5加密,生成幂等性关键字
        return SecureUtil.md5(methodName + argsStr);
    }
}

ExpressionIdempotentKeyResolver:

package org.jeecg.common.idempotent.keyresolver.impl;

import cn.hutool.core.util.ArrayUtil;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.jeecg.common.idempotent.annotation.Idempotent;
import org.jeecg.common.idempotent.keyresolver.IdempotentKeyResolver;
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 java.lang.reflect.Method;

/**
 * 基于Spring EL表达式
 *
 * 实现幂等性Key的解析,基于Spring Expression Language (SpEL)。
 * 该解析器通过评估给定的SpEL表达式来生成幂等性Key。
 *
 * @author ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:26
 */
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {

    /**
     * 用于发现方法参数名称的工具。
     */
    private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();

    /**
     * SpEL表达式解析器。
     */
    private final ExpressionParser expressionParser = new SpelExpressionParser();

    /**
     * 获取实际的方法对象,处理接口和实现类之间的映射。
     *
     * @param point 切点,包含方法调用的信息。
     * @return 方法对象。
     */
    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);
        }
    }

    /**
     * 解析一个 Key
     * 根据SpEL表达式解析出幂等性Key。
     *
     * @param joinPoint 切面连接点,包含当前的Method调用信息。
     * @param idempotent 幂等注解实例,包含SpEL表达式。
     * @return 解析出的幂等性Key。
     */
    @Override
    public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
        // 获取实际调用的方法
        Method method = getMethod(joinPoint);
        // 获取方法参数
        Object[] args = joinPoint.getArgs();
        // 获取方法参数名称
        String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method);
        // 创建SpEL表达式的评估上下文
        // 准备 Spring EL 表达式解析的上下文
        StandardEvaluationContext evaluationContext = new StandardEvaluationContext();
        // 设置参数名称和值到评估上下文中
        if (ArrayUtil.isNotEmpty(parameterNames)) {
            for (int i = 0; i < parameterNames.length; i++) {
                evaluationContext.setVariable(parameterNames[i], args[i]);
            }
        }

        // 解析注解中定义的SpEL表达式,获取幂等性Key
        // 解析参数
        Expression expression = expressionParser.parseExpression(idempotent.keyArg());
        return expression.getValue(evaluationContext, String.class);
    }
}
  1. 幂等性配置类,用于初始化幂等性相关的Bean
package org.jeecg.common.idempotent;

import org.jeecg.common.idempotent.aop.IdempotentAspect;
import org.jeecg.common.idempotent.keyresolver.IdempotentKeyResolver;
import org.jeecg.common.idempotent.keyresolver.impl.DefaultIdempotentKeyResolver;
import org.jeecg.common.idempotent.keyresolver.impl.ExpressionIdempotentKeyResolver;
import org.jeecg.common.modules.redis.config.RedisConfig;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.List;

/**
 * 幂等性配置类,用于初始化幂等性相关的Bean。
 *
 * @author ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:40
 */
@Configuration
@AutoConfigureAfter(RedisConfig.class) // 依赖Redis配置,确保在Redis配置之后初始化
public class IdempotentConfiguration {

    /**
     * 初始化幂等性切面。
     *
     * @param keyResolvers 幂等性键解析器列表,用于生成唯一的幂等性键。
     * @param idempotentRedisDAO 幂等性Redis操作DAO,用于存储和查询幂等性键。
     * @return 初始化后的幂等性切面实例。
     */
    @Bean
    public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
        return new IdempotentAspect(keyResolvers, idempotentRedisDAO);
    }

    /**
     * 初始化幂等性Redis DAO。
     *
     * @param stringRedisTemplate 字符串Redis模板,用于操作Redis。
     * @return 初始化后的幂等性Redis DAO实例。
     */
    @Bean
    public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {
        return new IdempotentRedisDAO(stringRedisTemplate);
    }

    // ========== 各种 IdempotentKeyResolver Bean ==========
    /**
     * 初始化默认幂等性键解析器。
     *
     * @return 默认幂等性键解析器实例。
     */
    @Bean
    public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {
        return new DefaultIdempotentKeyResolver();
    }

    /**
     * 初始化基于表达式幂等性键解析器。
     *
     * @return 基于表达式幂等性键解析器实例。
     */
    @Bean
    public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {
        return new ExpressionIdempotentKeyResolver();
    }
}
  1. 幂等性 Redis 数据访问对象
package org.jeecg.common.idempotent;

import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

import static org.jeecg.common.idempotent.RedisKeyDefine.KeyTypeEnum.STRING;

/**
 * 幂等性Redis数据访问对象,用于实现操作的幂等性。
 * 通过在Redis中设置和检查键值对,确保相同操作在重复请求时不会被多次执行。
 *
 * @author ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:36
 */
@AllArgsConstructor
public class IdempotentRedisDAO {

    /**
     * RedisKeyDefine对象,预定义了幂等键的模板和类型。
     */
    private static final RedisKeyDefine IDEMPOTENT = new RedisKeyDefine("幂等操作",
            "idempotent:%s", // 参数为 uuid
            STRING, String.class, RedisKeyDefine.TimeoutTypeEnum.DYNAMIC);

    /**
     * Redis模板,用于操作Redis数据库。
     */
    private final StringRedisTemplate redisTemplate;

    /**
     * 格式化Redis键。
     *
     * @param key 原始键。
     * @return 格式化后的Redis键。
     */
    private static String formatKey(String key) {
        return String.format(IDEMPOTENT.getKeyTemplate(), key);
    }

    /**
     * 如果键不存在,则设置键的值并返回true;如果键已存在,则返回false。
     *
     * @param key 键的标识。
     * @param timeout 键的过期时间。
     * @param timeUnit 时间单位。
     * @return 如果键被设置,则返回true;否则返回false。
     */
    public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
        String redisKey = formatKey(key);
        return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
    }
}
  1. Redis Key 定义类,用于定义和管理 Redis 键的相关属性
package org.jeecg.common.idempotent;

import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

/**
 * Redis Key定义类
 *
 * 用于定义和管理Redis键的相关属性,如键模板、键类型、值类型、超时类型和超时时间等。
 *
 * @author ZHANGCHAO
 * @version 1.0.0
 * @date 2023/5/15 14:30
 */
@Data
public class RedisKeyDefine {

    /**
     * Redis RedisKeyDefine 数组
     */
    private static final List<RedisKeyDefine> DEFINES = new ArrayList<>();
    /**
     * Key 模板
     */
    private final String keyTemplate;
    /**
     * Key 类型的枚举
     */
    private final KeyTypeEnum keyType;
    /**
     * Value 类型
     * <p>
     * 如果是使用分布式锁,设置为 {@link java.util.concurrent.locks.Lock} 类型
     */
    private final Class<?> valueType;
    /**
     * 超时类型
     */
    private final TimeoutTypeEnum timeoutType;
    /**
     * 过期时间
     */
    private final Duration timeout;
    /**
     * 备注
     */
    private final String memo;
    private RedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class<?> valueType,
                           TimeoutTypeEnum timeoutType, Duration timeout) {
        this.memo = memo;
        this.keyTemplate = keyTemplate;
        this.keyType = keyType;
        this.valueType = valueType;
        this.timeout = timeout;
        this.timeoutType = timeoutType;
        // 添加注册表
        add(this);
    }

    public RedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class<?> valueType, Duration timeout) {
        this(memo, keyTemplate, keyType, valueType, TimeoutTypeEnum.FIXED, timeout);
    }

    public RedisKeyDefine(String memo, String keyTemplate, KeyTypeEnum keyType, Class<?> valueType, TimeoutTypeEnum timeoutType) {
        this(memo, keyTemplate, keyType, valueType, timeoutType, Duration.ZERO);
    }

    public static void add(RedisKeyDefine define) {
        DEFINES.add(define);
    }

    /**
     * 格式化 Key
     * <p>
     * 注意,内部采用 {@link String#format(String, Object...)} 实现
     *
     * @param args 格式化的参数
     * @return Key
     */
    public String formatKey(Object... args) {
        return String.format(keyTemplate, args);
    }

    @Getter
    @AllArgsConstructor
    public enum KeyTypeEnum {

        STRING("String"),
        LIST("List"),
        HASH("Hash"),
        SET("Set"),
        ZSET("Sorted Set"),
        STREAM("Stream"),
        PUBSUB("Pub/Sub");

        /**
         * 类型
         */
        @JsonValue
        private final String type;

    }

    @Getter
    @AllArgsConstructor
    public enum TimeoutTypeEnum {

        FOREVER(1), // 永不超时
        DYNAMIC(2), // 动态超时
        FIXED(3); // 固定超时

        /**
         * 类型
         */
        @JsonValue
        private final Integer type;

    }
}
  1. 依赖的其他的一些工具类

CollectionUtils:

package org.jeecg.common.idempotent;

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import com.google.common.collect.ImmutableMap;

import java.util.*;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
 * Collection 工具类
 */
public class CollectionUtils {

    public static boolean containsAny(Object source, Object... targets) {
        return Arrays.asList(targets).contains(source);
    }

    public static boolean isAnyEmpty(Collection<?>... collections) {
        return Arrays.stream(collections).anyMatch(CollectionUtil::isEmpty);
    }

    public static <T> List<T> filterList(Collection<T> from, Predicate<T> predicate) {
        if (CollUtil.isEmpty(from)) {
            return new ArrayList<>();
        }
        return from.stream().filter(predicate).collect(Collectors.toList());
    }

    public static <T, R> List<T> distinct(Collection<T> from, Function<T, R> keyMapper) {
        if (CollUtil.isEmpty(from)) {
            return new ArrayList<>();
        }
        return distinct(from, keyMapper, (t1, t2) -> t1);
    }

    public static <T, R> List<T> distinct(Collection<T> from, Function<T, R> keyMapper, BinaryOperator<T> cover) {
        if (CollUtil.isEmpty(from)) {
            return new ArrayList<>();
        }
        return new ArrayList<>(convertMap(from, keyMapper, Function.identity(), cover).values());
    }

    public static <T, U> List<U> convertList(Collection<T> from, Function<T, U> func) {
        if (CollUtil.isEmpty(from)) {
            return new ArrayList<>();
        }
        return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toList());
    }

    public static <T, U> List<U> convertList(Collection<T> from, Function<T, U> func, Predicate<T> filter) {
        if (CollUtil.isEmpty(from)) {
            return new ArrayList<>();
        }
        return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toList());
    }

    public static <T, U> Set<U> convertSet(Collection<T> from, Function<T, U> func) {
        if (CollUtil.isEmpty(from)) {
            return new HashSet<>();
        }
        return from.stream().map(func).filter(Objects::nonNull).collect(Collectors.toSet());
    }

    public static <T, U> Set<U> convertSet(Collection<T> from, Function<T, U> func, Predicate<T> filter) {
        if (CollUtil.isEmpty(from)) {
            return new HashSet<>();
        }
        return from.stream().filter(filter).map(func).filter(Objects::nonNull).collect(Collectors.toSet());
    }

    public static <T, K> Map<K, T> convertMap(Collection<T> from, Function<T, K> keyFunc) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return convertMap(from, keyFunc, Function.identity());
    }

    public static <T, K> Map<K, T> convertMap(Collection<T> from, Function<T, K> keyFunc, Supplier<? extends Map<K, T>> supplier) {
        if (CollUtil.isEmpty(from)) {
            return supplier.get();
        }
        return convertMap(from, keyFunc, Function.identity(), supplier);
    }

    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1);
    }

    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, BinaryOperator<V> mergeFunction) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return convertMap(from, keyFunc, valueFunc, mergeFunction, HashMap::new);
    }

    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, Supplier<? extends Map<K, V>> supplier) {
        if (CollUtil.isEmpty(from)) {
            return supplier.get();
        }
        return convertMap(from, keyFunc, valueFunc, (v1, v2) -> v1, supplier);
    }

    public static <T, K, V> Map<K, V> convertMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc, BinaryOperator<V> mergeFunction, Supplier<? extends Map<K, V>> supplier) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return from.stream().collect(Collectors.toMap(keyFunc, valueFunc, mergeFunction, supplier));
    }

    public static <T, K> Map<K, List<T>> convertMultiMap(Collection<T> from, Function<T, K> keyFunc) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(t -> t, Collectors.toList())));
    }

    public static <T, K, V> Map<K, List<V>> convertMultiMap(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return from.stream()
                .collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toList())));
    }

    // 暂时没想好名字,先以 2 结尾噶
    public static <T, K, V> Map<K, Set<V>> convertMultiMap2(Collection<T> from, Function<T, K> keyFunc, Function<T, V> valueFunc) {
        if (CollUtil.isEmpty(from)) {
            return new HashMap<>();
        }
        return from.stream().collect(Collectors.groupingBy(keyFunc, Collectors.mapping(valueFunc, Collectors.toSet())));
    }

    public static <T, K> Map<K, T> convertImmutableMap(Collection<T> from, Function<T, K> keyFunc) {
        if (CollUtil.isEmpty(from)) {
            return Collections.emptyMap();
        }
        ImmutableMap.Builder<K, T> builder = ImmutableMap.builder();
        from.forEach(item -> builder.put(keyFunc.apply(item), item));
        return builder.build();
    }

    public static boolean containsAny(Collection<?> source, Collection<?> candidates) {
        return org.springframework.util.CollectionUtils.containsAny(source, candidates);
    }

    public static <T> T getFirst(List<T> from) {
        return !CollectionUtil.isEmpty(from) ? from.get(0) : null;
    }

    public static <T> T findFirst(List<T> from, Predicate<T> predicate) {
        if (CollUtil.isEmpty(from)) {
            return null;
        }
        return from.stream().filter(predicate).findFirst().orElse(null);
    }

    public static <T, V extends Comparable<? super V>> V getMaxValue(List<T> from, Function<T, V> valueFunc) {
        if (CollUtil.isEmpty(from)) {
            return null;
        }
        assert from.size() > 0; // 断言,避免告警
        T t = from.stream().max(Comparator.comparing(valueFunc)).get();
        return valueFunc.apply(t);
    }

    public static <T, V extends Comparable<? super V>> V getMinValue(List<T> from, Function<T, V> valueFunc) {
        if (CollUtil.isEmpty(from)) {
            return null;
        }
        assert from.size() > 0; // 断言,避免告警
        T t = from.stream().min(Comparator.comparing(valueFunc)).get();
        return valueFunc.apply(t);
    }

    public static <T, V extends Comparable<? super V>> V getSumValue(List<T> from, Function<T, V> valueFunc, BinaryOperator<V> accumulator) {
        if (CollUtil.isEmpty(from)) {
            return null;
        }
        assert from.size() > 0; // 断言,避免告警
        return from.stream().map(valueFunc).reduce(accumulator).get();
    }

    public static <T> void addIfNotNull(Collection<T> coll, T item) {
        if (item == null) {
            return;
        }
        coll.add(item);
    }

    public static <T> Collection<T> singleton(T deptId) {
        return deptId == null ? Collections.emptyList() : Collections.singleton(deptId);
    }

}

你自己项目中的RedisConfig

package org.jeecg.common.modules.redis.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.constant.CacheConstant;
import org.jeecg.common.constant.GlobalConstants;
import org.jeecg.common.modules.redis.receiver.RedisReceiver;
import org.jeecg.common.modules.redis.writer.JeecgRedisCacheWriter;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import javax.annotation.Resource;
import java.time.Duration;

import static java.util.Collections.singletonMap;

/**
 * 开启缓存支持
 *
 * @author zyf
 * @Return:
 */
@Slf4j
@EnableCaching
@Configuration
public class RedisConfig extends CachingConfigurerSupport {

    //不同的频道名
    //业务消息
    private static final String channel = "BusinessNews";

    @Resource
    private LettuceConnectionFactory lettuceConnectionFactory;

    /**
     * RedisTemplate配置
     *
     * @param lettuceConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(LettuceConnectionFactory lettuceConnectionFactory) {
        log.info(" --- redis config init --- ");
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = jacksonSerializer();
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<String, Object>();
        redisTemplate.setConnectionFactory(lettuceConnectionFactory);
        RedisSerializer<String> stringSerializer = new StringRedisSerializer();

        // key序列化
        redisTemplate.setKeySerializer(stringSerializer);
        // value序列化
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
        // Hash key序列化
        redisTemplate.setHashKeySerializer(stringSerializer);
        // Hash value序列化
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

    /**
     * 缓存配置管理器
     *
     * @param factory
     * @return
     */
    @Bean
    public CacheManager cacheManager(LettuceConnectionFactory factory) {
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = jacksonSerializer();
        // 配置序列化(解决乱码的问题),并且配置缓存默认有效期 6小时
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(6));
        RedisCacheConfiguration redisCacheConfiguration = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer));
        //.disableCachingNullValues();

        // 以锁写入的方式创建RedisCacheWriter对象
        //update-begin-author:taoyan date:20210316 for:注解CacheEvict根据key删除redis支持通配符*
        RedisCacheWriter writer = new JeecgRedisCacheWriter(factory, Duration.ofMillis(50L));
        //RedisCacheWriter.lockingRedisCacheWriter(factory);
        // 创建默认缓存配置对象
        /* 默认配置,设置缓存有效期 1小时*/
        //RedisCacheConfiguration defaultCacheConfig = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1));
        // 自定义配置test:demo 的超时时间为 5分钟
        RedisCacheManager cacheManager = RedisCacheManager.builder(writer).cacheDefaults(redisCacheConfiguration)
                .withInitialCacheConfigurations(singletonMap(CacheConstant.SYS_DICT_TABLE_CACHE,
                        RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(10)).disableCachingNullValues()
                                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jackson2JsonRedisSerializer))))
                .withInitialCacheConfigurations(singletonMap(CacheConstant.TEST_DEMO_CACHE, RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(5)).disableCachingNullValues()))
                .withInitialCacheConfigurations(singletonMap(CacheConstant.PLUGIN_MALL_RANKING, RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(24)).disableCachingNullValues()))
                .withInitialCacheConfigurations(singletonMap(CacheConstant.PLUGIN_MALL_PAGE_LIST, RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(24)).disableCachingNullValues()))
                .transactionAware().build();
        //update-end-author:taoyan date:20210316 for:注解CacheEvict根据key删除redis支持通配符*
        return cacheManager;
    }

    /**
     * redis 监听配置
     *
     * @param redisConnectionFactory redis 配置
     * @return
     */
    @Bean
    public RedisMessageListenerContainer redisContainer(RedisConnectionFactory redisConnectionFactory,
                                                        MessageListenerAdapter commonListenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(redisConnectionFactory);
        container.addMessageListener(commonListenerAdapter, new ChannelTopic(GlobalConstants.REDIS_TOPIC_NAME));
        //listenerAdapter的通道
//     container.addMessageListener(businessListenerAdapter, new PatternTopic(RedisConfig.channel));
        return container;
    }


    @Bean
    MessageListenerAdapter commonListenerAdapter(RedisReceiver redisReceiver) {
        MessageListenerAdapter messageListenerAdapter = new MessageListenerAdapter(redisReceiver, "onMessage");
        messageListenerAdapter.setSerializer(jacksonSerializer());
        return messageListenerAdapter;
    }

//  @Bean
//  MessageListenerAdapter businessListenerAdapter(RedisReceiver redisReceiver) {
//     MessageListenerAdapter messageListenerAdapter = new MessageListenerAdapter(redisReceiver, "receiveMessage");
//     messageListenerAdapter.setSerializer(jacksonSerializer());
//     return messageListenerAdapter;
//  }

    private Jackson2JsonRedisSerializer jacksonSerializer() {
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
        return jackson2JsonRedisSerializer;
    }
}

最终的项目结构:

手摸手系列之 - 什么是接口的幂等性以及 AOP+Redis 基于注解实现接口幂等性校验_JavaScript

测试接口幂等性

在相应的需要幂等性的接口上加Idempotent注解,如:

手摸手系列之 - 什么是接口的幂等性以及 AOP+Redis 基于注解实现接口幂等性校验_ide_02

这里设置的默认超时时间是 5 秒,即 5 秒内只允许相同参数的请求进来一次,前端重复点击审核按钮测试:

手摸手系列之 - 什么是接口的幂等性以及 AOP+Redis 基于注解实现接口幂等性校验_JavaScript_03

可以看到,请求已被拦截:

手摸手系列之 - 什么是接口的幂等性以及 AOP+Redis 基于注解实现接口幂等性校验_幂等_04

总结

我们定义了Idempotent注解,它允许我们标记方法为幂等操作,并提供了超时时间、提示信息和Key解析器等配置。然后,通过创建幂等性切面类IdempotentAspect,利用AOP在方法执行前进行幂等性校验。在实际测试中,通过在需要幂等性的接口上添加Idempotent注解,并设置适当的超时时间,可以观察到重复请求被成功拦截,证明了实现的有效性。 通过在项目中应用这些类和注解,可以有效地防止因重复请求导致的系统状态错误或数据不一致问题,从而提高系统的稳定性和可靠性。