如何使用注解实现接口的幂等性校验
- 背景
- 什么是幂等性
- 为什么要实现幂等性校验
- 如何实现接口的幂等性校验
- 1. 数据库唯一主键
- 2. 数据库乐观锁
- 3. 防重 Token 令牌
- 4. redis
- 如何将这几种方式都组装到一起
- 结语
背景
最近在小组同学卷的受不了的情况下,我决定换一个方向卷去,在算法上还是认命吧,跟他们差距太大了, 在最近一段时间偶然看到网上关于接口幂等性校验的文章,在我一番思索下,发现他们的实现原理各有不同而且每种实现原理各有不同,加之最近恰好在学设计模式,我就在想怎样利用设计模式让我们可以随意选择不同的实现方式。在此声明一下,笔者仅仅是一个学生,对于正式的业务流程开发并不太懂,只是利用自己现有的知识储备,打造一个让自己使用起来更方便的小demo, 如果有大佬觉得哪儿有问题,欢迎指出。
什么是幂等性
在数学领域中对于幂等性的解释是
f(f(x)) = f(x)
即幂等元素x在函数f的多次作用下,其效果和在f的一次作用下相同。在编程上可以理解为,如果某个函数(方法)或接口被调用多次其行为结果和被调用一次相同,则这种函数或接口就具有幂等性。
简单举个例子,天然幂等性:
假设对象Person中有个name属性,有个
setName(String name){this.name = name
}
的方法,那这个方法就是天然幂等的哦,你输入相同的“小明”参数,不论你重复调用多少次都是将名字设置为“小明”,其对对象Person的影响都是一样的。这就是天然幂等性。
非幂等性:
还是拿对象Person举例子,假设对象中有个age属性,有个
increaseAge(){this.age++;
}
方法,我们按正常的步骤一次一次调用是不会有问题的,如果调用者没有控制好逻辑,一次流程重复调用好几次,这时候影响效果和一次是有非常大区别,代码编写者以为它只会调用一次,结果出现了意外调用了很多次,恰好方法不具有幂等性,于是就会出现和预期不一样的效果。这个方法本身是不具备幂等性的,我们可以修改这个方法,让其传入一个标识符,每一次重复的请求会有相同的标识符,方法内部可以根据标识符查数据库是不是已经处理过,如果处理过就不重复处理。这样方法就具备了幂等性。
更通俗一点就是:
当在进行转账的时候,我们分了两个系统来处理这个转账的流程:
①系统A负责收集转账人和接收人还有金额的信息然后传给系统B进行转账,将控制逻辑留在系统A。
②系统B读取系统A传过来的信息,负责更改数据库的金额。如果操作成功,就回复系统A成功,如果失败就回复系统A失败。
③系统A可以接受系统B操作成功或失败的回复,但是我们知道,系统A这个交易流程是有等待时间的,如果等待超时,它不确认是否是转账成功或失败,于是系统A会重试调用直到得到一个明确的回复。
这是系统大致的交易流程。这个流程是有问题的,系统B提供的操作接口不是幂等性的,因为A会重复调用接口,导致出现一个接口被同一个数据源发送相同数据切想要达到请求一次接口的效果的现象。
常见请求方式的幂等性
- √ 满足幂等
- x 不满足幂等
- 可能满足也可能不满足幂等,根据实际业务逻辑有关
方法类型 | 是否幂等 | 描述 |
---|---|---|
Get | √ | Get 方法用于获取资源。其一般不会也不应当对系统资源进行改变,所以是幂等的。 |
Post | x | Post 方法一般用于创建新的资源。其每次执行都会新增数据,所以不是幂等的。 |
Put | _ | Put 方法一般用于修改资源。该操作则分情况来判断是不是满足幂等,更新操作中直接根据某个值进行更新,也能保持幂等。不过执行累加操作的更新是非幂等。 |
Delete | _ | Delete 方法一般用于删除资源。该操作则分情况来判断是不是满足幂等,当根据唯一值进行删除时,删除同一个数据多次执行效果一样。不过需要注意,带查询条件的删除则就不一定满足幂等了。例如在根据条件删除一批数据后,这时候新增加了一条数据也满足条件,然后又执行了一次删除,那么将会导致新增加的这条满足条件数据也被删除。 |
为什么要实现幂等性校验
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:
- 前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
- 用户恶意进行刷单: 例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
- 接口超时重复提交: 很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
- 消息进行重复消费: 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
如何实现接口的幂等性校验
网上流传最多的应该是四种方式去实现接口的幂等性校验,接下来我们来一个个盘点。
1. 数据库唯一主键
方案描述
数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。
使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键(或者使用其他算法生成的全局唯一的id),这样才能能保证在分布式环境下 ID 的全局唯一性。
适用操作:
插入操作
删除操作
使用限制:
需要生成全局唯一主键 ID;
主要流程:
① 客户端执行创建请求,调用服务端接口。
② 服务端执行业务逻辑,生成一个分布式 ID,将该 ID 充当待插入数据的主键,然后执数据插入操作,运行对应的 SQL 语句。
③ 服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。
2. 数据库乐观锁
方案描述:
数据库乐观锁方案一般只能适用于执行“更新操作”的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值。
适用操作:
更新操作
使用限制:
需要数据库对应业务表中添加额外字段;
3. 防重 Token 令牌
方案描述:
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。
适用操作:
插入操作
更新操作
删除操作
使用限制:
需要生成全局唯一 Token 串;
需要使用第三方组件 Redis 进行数据效验;
4. redis
方案描述:
第四种是我觉着用着挺方便的,但是实用性应该不大,而且和第三种类似,我们可以把接口名加请求参数通过算法生成一个全局唯一的id,然后 存到redis中,如果在一定时间请求多次,我们就直接拒绝。
适用操作:
插入操作
更新操作
删除操作
使用限制:
需要使用第三方组件 Redis 进行数据效验;
如何将这几种方式都组装到一起
我使用了Java自带的注解以及设计模式中的策略模式,我们可以在注解中直接指定幂等性校验的方式,当然也可以在配置文件中指定,但是直接在注解中指定更加灵活。
但是,由于最近时间比较忙,天天被某些人卷,很少有时间去完善,目前只是实现了redis和防重 Token 令牌两种方式的。
以下是部分代码
自定义注解
package org.example.annotation;import java.lang.annotation.*;/*** @author zrq*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface RequestMany {/*** 策略* @return*/String value() default "";/*** 过期时间* @return*/long expireTime() default 0;
}
定义切面
package org.example.aop;import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.example.annotation.RequestMany;
import org.example.factory.RequestManyStrategy;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
import java.util.stream.Collectors;/*** @author zrq* @ClassName RequestManyValidationAspect* @date 2023/11/22 9:14* @Description TODO*/
@Aspect
@Component
public class RequestManyValidationAspect {@Autowiredprivate Map<String, RequestManyStrategy> idempotentStrategies;@Around("@annotation(org.example.annotation.RequestMany)")public Object validateIdempotent(ProceedingJoinPoint joinPoint) throws Throwable {MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();Method method = methodSignature.getMethod();RequestMany requestMany = method.getAnnotation(RequestMany.class);String strategy = requestMany.value(); // 获取注解中配置的策略名称Integer time = (int)requestMany.expireTime(); // 获取注解中配置的策略名称if (!idempotentStrategies.containsKey(strategy)) {throw new IllegalArgumentException("Invalid idempotent strategy: " + strategy);}String key = generateKey(joinPoint); // 根据方法参数等生成唯一的keyRequestManyStrategy idempotentStrategy = idempotentStrategies.get(strategy);idempotentStrategy.validate(key, time);return joinPoint.proceed();}private String generateKey(ProceedingJoinPoint joinPoint) {// 获取类名String className = joinPoint.getTarget().getClass().getSimpleName();// 获取方法名MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();String methodName = methodSignature.getMethod().getName();// 获取方法参数Object[] args = joinPoint.getArgs();String argString = Arrays.stream(args).map(Object::toString).collect(Collectors.joining(","));// 获取请求携带的 TokenHttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();String token = request.getHeader("token");// 生成唯一的 keyString key = className + ":" + methodName + ":" + argString + ":" + token;String md5Password = DigestUtils.md5DigestAsHex(key.getBytes());return md5Password;}}
处理异常
package org.example.exception;/*** 运行时异常* @author binbin.hou* @since 0.0.1*/
public class RequestManyValidationException extends RuntimeException {public RequestManyValidationException() {}public RequestManyValidationException(String message) {super(message);}public RequestManyValidationException(String message, Throwable cause) {super(message, cause);}public RequestManyValidationException(Throwable cause) {super(cause);}public RequestManyValidationException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {super(message, cause, enableSuppression, writableStackTrace);}
}
模式工厂
package org.example.factory;import org.example.exception.RequestManyValidationException;/*** @author zrq* @ClassName RequestManyStrategy* @date 2023/11/22 9:04* @Description TODO*/
public interface RequestManyStrategy {void validate(String key, Integer time) throws RequestManyValidationException;
}
模式实现01
package org.example.factory.impl;import org.example.exception.RequestManyValidationException;
import org.example.factory.RequestManyStrategy;
import org.example.utils.RedisCache;
import org.springframework.stereotype.Component;import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;/*** @author zrq* @ClassName RedisIdempotentStrategy* @date 2023/11/22 9:07* @Description TODO*/
@Component
public class RedisIdempotentStrategy implements RequestManyStrategy {@Resourceprivate RedisCache redisCache;@Overridepublic void validate(String key, Integer time) throws RequestManyValidationException {if (redisCache.hasKey(key)) {throw new RequestManyValidationException("请求次数过多");} else {redisCache.setCacheObject(key,"1", time, TimeUnit.MINUTES);}}
}
模式实现02
package org.example.factory.impl;import org.example.exception.RequestManyValidationException;
import org.example.factory.RequestManyStrategy;
import org.example.utils.RedisCache;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;/*** @author zrq* @ClassName TokenIdempotentStrategy* @date 2023/11/22 9:13* @Description TODO*/
@Component
public class TokenIdempotentStrategy implements RequestManyStrategy {@Resourceprivate RedisCache redisCache;@Overridepublic void validate(String key, Integer time) throws RequestManyValidationException {HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();String token = request.getHeader("token");if (token == null || token.isEmpty()) {throw new RequestManyValidationException("未授权的token");}// 根据 key 和 token 执行幂等性校验boolean isDuplicateRequest = performTokenValidation(key, token);if (!isDuplicateRequest) {throw new RequestManyValidationException("多次请求");}}private boolean performTokenValidation(String key, String token) {// 执行根据 Token 进行幂等性校验的逻辑// 这里可以使用你选择的合适的方法,比如将 Token 存储到数据库或缓存中,然后检查是否已存在String storedToken = redisCache.getCacheObject(key);// 比较存储的 Token 和当前请求的 Token 是否一致return token.equals(storedToken);}}
redisutil类
package org.example.utils;import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.*;
import org.springframework.stereotype.Component;import java.util.*;
import java.util.concurrent.TimeUnit;@SuppressWarnings(value = { "unchecked", "rawtypes" })
@Component
@Slf4j
public class RedisCache
{@Autowiredpublic RedisTemplate redisTemplate;@Autowiredprivate StringRedisTemplate stringRedisTemplate;/*** 缓存基本的对象,Integer、String、实体类等** @param key 缓存的键值* @param value 缓存的值*/public <T> void setCacheObject(final String key, final T value){redisTemplate.opsForValue().set(key, value);}/*** 缓存基本的对象,Integer、String、实体类等** @param key 缓存的键值* @param value 缓存的值* @param timeout 时间* @param timeUnit 时间颗粒度*/public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit){redisTemplate.opsForValue().set(key, value, timeout, timeUnit);}/*** 设置有效时间** @param key Redis键* @param timeout 超时时间* @return true=设置成功;false=设置失败*/public boolean expire(final String key, final long timeout){return expire(key, timeout, TimeUnit.SECONDS);}public boolean hasKey(final String key){return Boolean.TRUE.equals(redisTemplate.hasKey(key));}/*** 设置有效时间** @param key Redis键* @param timeout 超时时间* @param unit 时间单位* @return true=设置成功;false=设置失败*/public boolean expire(final String key, final long timeout, final TimeUnit unit){return redisTemplate.expire(key, timeout, unit);}/*** 获得缓存的基本对象。** @param key 缓存键值* @return 缓存键值对应的数据*/public <T> T getCacheObject(final String key){ValueOperations<String, T> operation = redisTemplate.opsForValue();return operation.get(key);}/*** 删除单个对象** @param key*/public boolean deleteObject(final String key){return redisTemplate.delete(key);}/*** 删除集合对象** @param collection 多个对象* @return*/public long deleteObject(final Collection collection){return redisTemplate.delete(collection);}/*** 缓存List数据** @param key 缓存的键值* @param dataList 待缓存的List数据* @return 缓存的对象*/public <T> long setCacheList(final String key, final List<T> dataList){Long count = redisTemplate.opsForList().rightPushAll(key, dataList);return count == null ? 0 : count;}/*** 获得缓存的list对象** @param key 缓存的键值* @return 缓存键值对应的数据*/public <T> List<T> getCacheList(final String key){return redisTemplate.opsForList().range(key, 0, -1);}/*** 缓存Set** @param key 缓存键值* @param dataSet 缓存的数据* @return 缓存数据的对象*/public <T> BoundSetOperations<String, T> setCacheSet(final String key, final 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(final String key){return redisTemplate.opsForSet().members(key);}/*** 缓存Map** @param key* @param dataMap*/public <T> void setCacheMap(final String key, final Map<Integer, T> dataMap){if (dataMap != null) {redisTemplate.opsForHash().putAll(key, dataMap);}}/*** 获得缓存的Map** @param key* @return*/public <T> Map<String, T> getCacheMap(final String key){return redisTemplate.opsForHash().entries(key);}/*** 往Hash中存入数据** @param key Redis键* @param hKey Hash键* @param value 值*/public <T> void setCacheMapValue(final String key, final String hKey, final T value){redisTemplate.opsForHash().put(key, hKey, value);}/*** 获取Hash中的数据** @param key Redis键* @param hKey Hash键* @return Hash中的对象*/public <T> T getCacheMapValue(final String key, final String hKey){HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();return opsForHash.get(key, hKey);}/*** 删除Hash中的数据** @param key* @param hkey*/public void delCacheMapValue(final String key, final String hkey){HashOperations hashOperations = redisTemplate.opsForHash();hashOperations.delete(key, hkey);}/*** 获取多个Hash中的数据** @param key Redis键* @param hKeys Hash键集合* @return Hash对象集合*/public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys){return redisTemplate.opsForHash().multiGet(key, hKeys);}/*** 获得缓存的基本对象列表** @param pattern 字符串前缀* @return 对象列表*/public Collection<String> keys(final String pattern){return redisTemplate.keys(pattern);}public Boolean sign(String key, int day, boolean sign) {return redisTemplate.opsForValue().setBit(key, day, sign);}public List<Long> result(String key, int day, int num){List<Long> result = stringRedisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(day)).valueAt(0));return result;}public Long bigCount(String key) {Long execute = (Long)redisTemplate.execute((RedisCallback) cbk -> cbk.bitCount(key.getBytes()));return execute;}public <T> void setCacheZSet(String name,T key, double num) {if (key != null) {redisTemplate.opsForZSet().add(name,key,num);}}public <T> Long getCacheZSetRanking(String name,String key) {Long aLong = null;if (key != null) {aLong = redisTemplate.opsForZSet().reverseRank(name, key);}return aLong;}public <T> Double getCacheZSetScore(String name,T key) {Double score = null;if (key != null) {score = redisTemplate.opsForZSet().score(name, key);}return score;}public Set getCacheZSetLookTop(String name,int nums) {Set set = null;if (name != null) {set = redisTemplate.opsForZSet().reverseRange(name, 0, nums);}return set;}public Long getCacheZSetSize(String key) {Long aLong = null;if (key != null) {aLong = redisTemplate.opsForZSet().zCard(key);}return aLong;}public <T> Long deleteCacheZSet(String key, T value) {Long remove = null;if (key != null) {remove = redisTemplate.opsForZSet().remove(key, value);}return remove;}public List<Long> getBitMap(String key,Integer day) {List<Long> bitFieldList = (List<Long>) redisTemplate.execute((RedisCallback<List<Long>>) cbk-> cbk.bitField(key.getBytes(), BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(day)).valueAt(0)));return bitFieldList;}public Long incrExpire(String key, long time) {Long count = redisTemplate.opsForValue().increment(key, 1);if (count != null && count == 1) {redisTemplate.expire(key, time, TimeUnit.SECONDS);}return count;}public boolean removeList(String listName, Integer count, String value) {redisTemplate.opsForList().remove(listName,count,value);return true;}
}
配置文件
如果要实现其他方式的话只需要实现下RequestManyStrategy模板方法,然后编写自己的校验逻辑就可以。欢迎大佬指正错误。
以上代码已经上传到github 仓库地址点这里
结语
大学过的可真快,转眼就大三了,自己的技术还是不行,跟别人的差距还有很大距离,希望自己能在有限的时间里学到更多有用的知识,同时也希望在明年的这个时候可以坐在办公室里敲代码。突然想到高中时中二的一句话“听闻少年二字,应与平庸相斥”,谁不希望这样呢,奈何身边大佬太多,现在只能追赶别人的脚步。。。