接口幂等方案实现(Spring拦截器+Redis)

引读

目的:保证接口幂等,也就是使用重复数据访问多次接口(重复请求)和仅访问一次接口对于系统造成的影响都一样;例如,同一笔交易,无论调用多少次创建订单接口,都只生成一个订单。同一个订单,无论访问多少次支付接口,都只收取一次费用。
基本问题:如何判定一个请求属于重复请求?接口入参能区分则使用入参区分,如果不能则生成全局唯一的值(token)来标记。
主要技术:Spring拦截器 + Redis
预先准备(读者自行准备,本文不涉及)

  • SpringBoot应用搭建(本文使用版本2.6.8)
  • Redis服务搭建(本文使用版本6.2.6)

使用接口入参实现

  1. 依赖
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>
  1. 创建用于标记需要接口幂等的注解类Idempotent
import java.lang.annotation.*;

/**
 * <h3>本注解用于需要控制幂等的接口</h3>
 * <p>被本注解标记的方法,被拦截器前置方法拦截,在其中进行防重复调用校验,实现接口幂等</p>
 *
 * @author 我昭
 * @version 1.0
 * @since 2022/07/31
 */
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 用于拼接幂等性判断的key的入参字段
     */
    String[] fields();

    /**
     * 用于接口幂等性验证的Redis中Key的过期时间,单位秒
     */
    long timeout() default 10L;
}
  1. 创建RedisTemplate配置RedisTempldateConfig
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

@Configuration
public class RedisTempldateConfig {
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        template.setKeySerializer(stringRedisSerializer);
        template.setHashKeySerializer(stringRedisSerializer);

        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);
        serializer.setObjectMapper(objectMapper);
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}
  1. 创建Redis工具类RedisUtil
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisUtil {
    @Resource
    private RedisTemplate redisTemplate;

    public Boolean setIfAbsent(String key, Object value, long timeout) {
        return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, TimeUnit.SECONDS);
    }
}
  1. 创建接口拦截器IdempotentInterceptor
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import xyz.zzrt.activity.idempotent.annotation.Idempotent;
import xyz.zzrt.activity.util.RedisUtil;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <h3>接口幂等性校验的拦截器</h3>
 * <p>每个请求都会经过本拦截器,其中的preHandle方法是在被拦截方法之前执行,我们在其中进行幂等性判断</p>
 *
 * @author 我昭
 * @version 1.0
 * @since 2022/08/03
 */
@Slf4j
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
    @Resource
    private RedisUtil redisUtil;

    @Override
    public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
        Idempotent idempotent = ((HandlerMethod) handler).getMethodAnnotation(Idempotent.class);
        if (idempotent == null) {
            return true;
        }
        String idempotentKey = this.idempotentKey(idempotent, request);
        log.info("用于验证接口幂等的Reids的Key={}", idempotentKey);
        Boolean success = redisUtil.setIfAbsent(idempotentKey, 1, idempotent.timeout());
        if (Boolean.FALSE.equals(success)) {
            throw new RuntimeException("请勿重复请求");
        }
        return true;
    }

    private String idempotentKey(Idempotent idempotent, HttpServletRequest request) {
        String[] fields = idempotent.fields();
        StringBuilder idempotentKey = new StringBuilder();
        for (String field : fields) {
            String parameter = request.getParameter(field);
            if (!StringUtils.hasText(parameter)) {
                throw new RuntimeException("接口开启了幂等性验证," + field + "字段必传且不为空");
            }
            idempotentKey.append(parameter);
        }
        return idempotentKey.toString();
    }
}
  1. 配置拦截器IdempotentInterceptor注册到SpringMVC的拦截器链中
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;
import xyz.zzrt.activity.idempotent.interceptor.IdempotentInterceptor;

import javax.annotation.Resource;

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
    @Resource
    private IdempotentInterceptor idempotentInterceptor;

    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor);
    }
}
  1. 创建测试方法idepotentTest,方法上添加注解@Idempotent(fields = {"dept", "orderId"}, timeout = 5),表示在5秒之内,如果dept和orderId字段值相同的重复请求会被拦截住
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import xyz.zzrt.activity.idempotent.annotation.Idempotent;

@RestController
public class IdempotentController {

    @Idempotent(fields = {"dept", "orderId"}, timeout = 5)
    @GetMapping("/idepotentTest")
    public String idepotentTest(String dept, String orderId) {
        return "OK";
    }
}
  1. 访问测试,连续访问接口,重复请求被拦截器拦截

使用Token实现

  1. 依赖(同上)
  2. 创建用于标记方法幂等的注解Idempotent
import java.lang.annotation.*;

/**
 * <h3>本注解用于需要控制幂等的接口</h3>
 * <p>被本注解标记的方法,被拦截器前置方法拦截,在其中进行防重复调用校验,实现接口幂等</p>
 *
 * @author 我昭
 * @version 1.0
 * @since 2022/07/31
 */
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
    /**
     * 用于接口幂等性验证的Redis中Key的过期时间,单位秒
     */
    long timeout() default 10L;
}
  1. 创建生成Token的接口/createToken
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import xyz.zzrt.activity.idempotent.annotation.Idempotent;
import xyz.zzrt.activity.util.RedisUtil;

import javax.annotation.Resource;
import java.util.Map;
import java.util.UUID;

@RestController
public class IdempotentController {
    /**
     * 最大过期时间
     */
    private static final int MAX_TIMEOUT = 60 * 30;

    @Resource
    private RedisUtil redisUtil;

    @GetMapping("/createToken")
    public Map<String, Object> createToken(int timeout) {
        String uuid = UUID.randomUUID().toString().replace("-", "");
        timeout = Math.min(timeout, MAX_TIMEOUT);
        redisUtil.set(uuid, 1, timeout);
        return Map.of("token", uuid, "expire", timeout * 1000L + System.currentTimeMillis());
    }
}
  1. 创建RedisTemplate配置RedisTempldateConfig(同上)
  2. 创建Redis工具类RedisUtil
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;

@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisUtil {
    @Resource
    private RedisTemplate redisTemplate;

    public Boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }

    public Boolean del(Object key) {
        return redisTemplate.delete(key);
    }
}
  1. 创建接口拦截器IdempotentInterceptor
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import xyz.zzrt.activity.idempotent.annotation.Idempotent;
import xyz.zzrt.activity.util.RedisUtil;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * <h3>接口幂等性校验的拦截器</h3>
 * <p>每个请求都会经过本拦截器,其中的preHandle方法是在被拦截方法之前执行,我们在其中进行幂等性判断</p>
 *
 * @author 我昭
 * @version 1.0
 * @since 2022/08/03
 */
@Slf4j
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
    @Resource
    private RedisUtil redisUtil;

    @Override
    public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
        Idempotent idempotent = ((HandlerMethod) handler).getMethodAnnotation(Idempotent.class);
        if (idempotent == null) {
            return true;
        }
        String idempotentKey = request.getHeader("idempotentKey");
        Assert.hasText(idempotentKey, "请求头中必须携带接口参数idempotentKey");
        log.info("用于验证接口幂等的Reids的Key={}", idempotentKey);
        try {
            Boolean success = redisUtil.hasKey(idempotentKey);
            if (Boolean.FALSE.equals(success)) {
                throw new RuntimeException("令牌已过期或重复请求");
            }
        } finally {
            Boolean del = redisUtil.del(idempotentKey);
            log.info("idempotentKey移除: {}", del);
        }
        return true;
    }
}
  1. 创建测试方法idepotentTest
@Idempotent(timeout = 5)
@GetMapping("/idepotentTest")
public String idepotentTest() {
    return "OK";
}
  1. 重复请求被拦截器拦截

总结

  1. 保证接口幂等先考虑如何判定重复请求
  2. 幂等的最终目的是保证数据的一致性,接口幂等是通过防止重复请求来保证数据一致性,例如通过数据库唯一索引也可以实现效果,不必要局限思维
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容