场景
SpringBoot+Redis+自定义注解实现接口防刷(限制不同接口单位时间内最大请求次数):
https://blog.csdn.net/BADAO_LIUMANG_QIZHI/article/details/130594444以下接口幂等性的实现方式与上面博客类似,可参考。
接口幂等性
什么是幂等性?
幂等是一个数学与计算机学概念,在数学中某一元运算为幂等时,其作用在任一元素两次后
会和其作用一次的结果相同。在计算机中编程中,一个幂等操作的特点是其任意多次执行所
产生的影响均与一次执行的影响相同。幂等函数或幂等方法是指可以使用相同参数重复执行,
并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
什么是接口幂等性?
在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有
同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求
都不会再对资源产生副作用。这里的副作用是不会对结果产生破坏或者产生不可预料的结果。
也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
为什么需要实现幂等性?
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,
如:
前端重复提交表单:
在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,
致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
用户恶意进行刷单:
例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到
用户重复提交的投票信息,这样会使投票结果与事实严重不符。
接口超时重复提交:
很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络
波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
消息进行重复消费:
当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
实现接口幂等性的方案有很多,下面记录一种自定义注解加拦截器和redis的实现方式模拟防止订单重复提交。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
实现
1、新建SpringBoot项目并添加必要的依赖
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!--MySQL驱动--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> </dependency> <!--MyBatis整合SpringBoot框架的起步依赖--> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.1</version> </dependency> <!-- redis 缓存操作 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 阿里JSON解析器 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.75</version> </dependency> </dependencies>
2、修改配置文件yml添加相关配置
# 开发环境配置 server: # 服务器的HTTP端口,默认为8080 port: 996 servlet: # 应用的访问路径 context-path: / tomcat: # tomcat的URI编码 uri-encoding: UTF-8 # tomcat最大线程数,默认为200 max-threads: 800 # Tomcat启动初始化的线程数,默认值25 min-spare-threads: 30 # 数据源 spring: application: name: badao-tcp-demo datasource: url:jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver dbcp2: min-idle: 5 # 数据库连接池的最小维持连接数 initial-size: 5 # 初始化连接数 max-total: 5 # 最大连接数 max-wait-millis: 150 # 等待连接获取的最大超时时间 # redis 配置 redis: # 地址 #本地测试用 host: 127.0.0.1 port: 6379 password: 123456 # 连接超时时间 timeout: 10s lettuce: pool: # 连接池中的最小空闲连接 min-idle: 0 # 连接池中的最大空闲连接 max-idle: 8 # 连接池的最大数据库连接数 max-active: 8 # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms # mybatis配置 mybatis: mapper-locations: classpath:mapper/*.xml # mapper映射文件位置 type-aliases-package: com.badao.demo.entity # 实体类所在的位置 configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #用于控制台打印sql语句
3、新建Redis的两个配置类
RedisConfig:
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.context.annotation.Primary; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; /** * redis配置 * */ @Configuration @EnableCaching public class RedisConfig extends CachingConfigurerSupport { @Bean @SuppressWarnings(value = { "unchecked", "rawtypes" }) @Primary public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<Object, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); FastJson2JsonRedisSerializer serializer = new FastJson2JsonRedisSerializer(Object.class); ObjectMapper mapper = new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); serializer.setObjectMapper(mapper); template.setValueSerializer(serializer); // 使用StringRedisSerializer来序列化和反序列化redis的key值 template.setKeySerializer(new StringRedisSerializer()); template.afterPropertiesSet(); return template; } }
FastJson2JsonRedisSerializer:
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.parser.ParserConfig; import com.alibaba.fastjson.serializer.SerializerFeature; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.type.TypeFactory; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.util.Assert; import java.nio.charset.Charset; /** * Redis使用FastJson序列化 * */ public class FastJson2JsonRedisSerializer<T> implements RedisSerializer<T> { @SuppressWarnings("unused") private ObjectMapper objectMapper = new ObjectMapper(); public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8"); private Class<T> clazz; static { ParserConfig.getGlobalInstance().setAutoTypeSupport(true); } public FastJson2JsonRedisSerializer(Class<T> clazz) { super(); this.clazz = clazz; } @Override public byte[] serialize(T t) throws SerializationException { if (t == null) { return new byte[0]; } return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET); } @Override public T deserialize(byte[] bytes) throws SerializationException { if (bytes == null || bytes.length <= 0) { return null; } String str = new String(bytes, DEFAULT_CHARSET); return JSON.parseObject(str, clazz); } public void setObjectMapper(ObjectMapper objectMapper) { Assert.notNull(objectMapper, "'objectMapper' must not be null"); this.objectMapper = objectMapper; } protected JavaType getJavaType(Class<?> clazz) { return TypeFactory.defaultInstance().constructType(clazz); } }
4、新建Redis工具类
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.*; import org.springframework.stereotype.Component; import java.util.*; import java.util.concurrent.TimeUnit; /** * spring redis 工具类 * **/ @SuppressWarnings(value = { "unchecked", "rawtypes" }) @Component public class RedisCache { @Autowired public RedisTemplate redisTemplate; @Autowired public StringRedisTemplate stringRedisTemplate; /** * 缓存基本的对象,Integer、String、实体类等 * * @param key 缓存的键值 * @param value 缓存的值 * @return 缓存的对象 */ public <T> ValueOperations<String, T> setCacheObject(String key, T value) { ValueOperations<String, T> operation = redisTemplate.opsForValue(); operation.set(key, value); return operation; } /** * 缓存基本的对象,Integer、String、实体类等 * * @param key 缓存的键值 * @param value 缓存的值 * @param timeout 时间 * @param timeUnit 时间颗粒度 * @return 缓存的对象 */ public <T> ValueOperations<String, T> setCacheObject(String key, T value, Integer timeout, TimeUnit timeUnit) { ValueOperations<String, T> operation = redisTemplate.opsForValue(); operation.set(key, value, timeout, timeUnit); return operation; } /** * 获得缓存的基本对象。 * * @param key 缓存键值 * @return 缓存键值对应的数据 */ public <T> T getCacheObject(String key) { ValueOperations<String, T> operation = redisTemplate.opsForValue(); return operation.get(key); } /** * 删除单个对象 * * @param key */ public void deleteObject(String key) { redisTemplate.delete(key); } /** * 删除集合对象 * * @param collection */ public void deleteObject(Collection collection) { redisTemplate.delete(collection); } /** * 缓存List数据 * * @param key 缓存的键值 * @param dataList 待缓存的List数据 * @return 缓存的对象 */ public <T> ListOperations<String, T> setCacheList(String key, List<T> dataList) { ListOperations listOperation = redisTemplate.opsForList(); if (null != dataList) { int size = dataList.size(); for (int i = 0; i < size; i++) { listOperation.leftPush(key, dataList.get(i)); } } return listOperation; } /** * 获得缓存的list对象 * * @param key 缓存的键值 * @return 缓存键值对应的数据 */ public <T> List<T> getCacheList(String key) { List<T> dataList = new ArrayList<T>(); ListOperations<String, T> listOperation = redisTemplate.opsForList(); Long size = listOperation.size(key); for (int i = 0; i < size; i++) { dataList.add(listOperation.index(key, i)); } return dataList; } /** * 缓存Set * * @param key 缓存键值 * @param dataSet 缓存的数据 * @return 缓存数据的对象 */ public <T> BoundSetOperations<String, T> setCacheSet(String key, Set<T> dataSet) { BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key); Iterator<T> it = dataSet.iterator(); while (it.hasNext()) { setOperation.add(it.next()); } return setOperation; } /** * 获得缓存的set * * @param key * @return */ public <T> Set<T> getCacheSet(String key) { Set<T> dataSet = new HashSet<T>(); BoundSetOperations<String, T> operation = redisTemplate.boundSetOps(key); dataSet = operation.members(); return dataSet; } /** * 缓存Map * * @param key * @param dataMap * @return */ public <T> HashOperations<String, String, T> setCacheMap(String key, Map<String, T> dataMap) { HashOperations hashOperations = redisTemplate.opsForHash(); if (null != dataMap) { for (Map.Entry<String, T> entry : dataMap.entrySet()) { hashOperations.put(key, entry.getKey(), entry.getValue()); } } return hashOperations; } /** * 获得缓存的Map * * @param key * @return */ public <T> Map<String, T> getCacheMap(String key) { Map<String, T> map = redisTemplate.opsForHash().entries(key); return map; } /** * 获得缓存的基本对象列表 * * @param pattern 字符串前缀 * @return 对象列表 */ public Collection<String> keys(String pattern) { return redisTemplate.keys(pattern); } /** * 如果redis中不存在则存储进redis * @return */ public Boolean setIfAbsent(Object key,Object value,long timeout){ return redisTemplate.opsForValue().setIfAbsent(key, value,timeout, TimeUnit.SECONDS); } }
5、新建自定义注解类Idempotent
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { /** * 用于拼接幂等性判断的key的入参字段 * @return */ String[] fields(); /** * 用于接口幂等性校验的Redis中Key的过期时间,单位秒 * @return */ long timeout() default 10l; }
6、创建自定义拦截器IdempotentInterceptor
import com.badao.demo.annotation.Idempotent; import com.badao.demo.utils.RedisCache; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.OutputStream; @Component public class IdempotentInterceptor implements HandlerInterceptor { @Resource private RedisCache redisCache; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Idempotent idempotent = ((HandlerMethod)handler).getMethodAnnotation(Idempotent.class); if(idempotent == null){ return true; } String idempotentKey = this.idempotentKey(idempotent,request); Boolean success = redisCache.setIfAbsent(idempotentKey,1, idempotent.timeout()); if(Boolean.FALSE.equals(success)){ render(response,"请勿重复请求"); return false; } 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); idempotentKey.append(parameter); } return idempotentKey.toString(); } /** * 接口渲染 * @param response * @throws Exception */ private void render(HttpServletResponse response,String message)throws Exception { response.setContentType("application/json;charset=UTF-8"); OutputStream out = response.getOutputStream(); out.write(message.getBytes("UTF-8")); out.flush(); out.close(); } }
7、配置拦截器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 javax.annotation.Resource; /** * 配置拦截器IdempotentInterceptor注册到SpringMVC的拦截器链中 */ @Configuration public class WebConfig extends WebMvcConfigurationSupport { @Resource private IdempotentInterceptor idempotentInterceptor; @Override protected void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(idempotentInterceptor); } }
8、创建测试接口并添加自定义注解
import com.badao.demo.annotation.Idempotent; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class TestController { @Idempotent(fields = {"orderId"},timeout = 10) @GetMapping("/test") public String test(@RequestParam("orderId") String orderId, String remark){ return "success"; } }
这里指定orderId为订单唯一ID参数,并设置在10秒内如果此字段相同的请求会被拦截
9、创建测试方法
import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; @SpringBootTest class IdempotenceTest { @Autowired private WebApplicationContext webApplicationContext; @Test void test1() throws Exception { //初始化MockMvc MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); //循环调用5次进行测试 for (int i = 1; i <= 5; i++) { System.out.println("第"+i+"次调用接口"); //调用接口 String result = mockMvc.perform(MockMvcRequestBuilders.get("/test") .accept(MediaType.TEXT_HTML) .param("orderId","001") .param("remark","badao")) .andReturn() .getResponse() .getContentAsString(); System.out.println(result); } } }
测试结果
使用postman等接口测试工具复测验证