引读
目的:保证接口幂等,也就是使用重复数据访问多次接口(重复请求)和仅访问一次接口对于系统造成的影响都一样;例如,同一笔交易,无论调用多少次创建订单接口,都只生成一个订单。同一个订单,无论访问多少次支付接口,都只收取一次费用。
基本问题:如何判定一个请求属于重复请求?接口入参能区分则使用入参区分,如果不能则生成全局唯一的值(token)来标记。
主要技术:Spring拦截器 + Redis
预先准备(读者自行准备,本文不涉及)
- SpringBoot应用搭建(本文使用版本2.6.8)
- Redis服务搭建(本文使用版本6.2.6)
使用接口入参实现
- 依赖
<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>
- 创建用于标记需要接口幂等的注解类
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;
}
- 创建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;
}
}
- 创建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);
}
}
- 创建接口拦截器
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();
}
}
- 配置拦截器
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);
}
}
- 创建测试方法
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";
}
}
- 访问测试,连续访问接口,重复请求被拦截器拦截
使用Token实现
- 依赖(同上)
- 创建用于标记方法幂等的注解
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;
}
- 创建生成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());
}
}
- 创建RedisTemplate配置
RedisTempldateConfig
(同上) - 创建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);
}
}
- 创建接口拦截器
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;
}
}
- 创建测试方法
idepotentTest
@Idempotent(timeout = 5)
@GetMapping("/idepotentTest")
public String idepotentTest() {
return "OK";
}
- 重复请求被拦截器拦截
总结
- 保证接口幂等先考虑如何判定重复请求
- 幂等的最终目的是保证数据的一致性,接口幂等是通过防止重复请求来保证数据一致性,例如通过数据库唯一索引也可以实现效果,不必要局限思维