前言:
在项目中经常会遇到调接口时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题,如:
-
前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
-
用户恶意进行刷单: 例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
-
接口超时重复提交: 很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
-
消息进行重复消费: 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
1.使用自定义注解实现:定义注解
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* @create 2023-12-01 13:40
* 实现接口幂等性注解
**/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
long expireTime() default 10000;
}
2.在通知中实现接口幂等,我使用的是分布式来实现的
首先引入Redission
<!--使用redisson作为分布式锁-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.8</version>
</dependency>
配置Redission
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.password}")
private String password;
@Value("${spring.redis.port}")
private String port;
/**
* 所有对Redisson的使用都是通过RedissonClient对象
* @return redissonClient
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient(){
// 创建配置 指定redis地址及节点信息
Config config = new Config();
config.useSingleServer().setAddress("redis://"+host+":"+port).setPassword(password);
// 根据config创建出RedissonClient实例
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
然后对请求参数进行MD5摘要,下面是代码
@Slf4j
public class ReqDedupHelper {
private final Gson gson = new Gson();
/**
* @param reqJSON 请求的参数,这里通常是JSON
* @param excludeKeys 请求参数里面要去除哪些字段再求摘要
* @return 去除参数的MD5摘要
*/
public String dedupParamMD5(final String reqJSON, String... excludeKeys) {
String decreptParam = reqJSON;
TreeMap paramTreeMap = gson.fromJson(decreptParam, TreeMap.class);
if (excludeKeys!=null) {
List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);
if (!dedupExcludeKeys.isEmpty()) {
for (String dedupExcludeKey : dedupExcludeKeys) {
paramTreeMap.remove(dedupExcludeKey);
}
}
}
String paramTreeMapJSON = gson.toJson(paramTreeMap);
String md5deDupParam = jdkMD5(paramTreeMapJSON);
log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON);
return md5deDupParam;
}
private static String jdkMD5(String src) {
String res = null;
try {
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] mdBytes = messageDigest.digest(src.getBytes());
res = DatatypeConverter.printHexBinary(mdBytes);
} catch (Exception e) {
log.error("",e);
}
return res;
}
//测试方法
// public static void main(String[] args) {
// Gson gson = new Gson();
// Account user1 = new Account();
// user1.setName("名称");
// user1.setPassword("123456");
// user1.setCountryCode("1");
// Object[] objects = new Object[]{"sss",11,user1};
//
// Map<String, Object> maps = new HashMap<>();
// maps.put("参数1",objects[0]);
// maps.put("参数2",objects[1]);
// maps.put("参数3",objects[2]);
// String json1 = gson.toJson(maps);
// System.out.println(json1);
// TreeMap paramTreeMap = gson.fromJson(json1, TreeMap.class);
// System.out.println(gson.toJson(paramTreeMap));
//
// }
}
就是把接口中的参数用MD5解析,避免参数很大,我这里是用IP+接口+参数来作为Key
3.然后就是对自定义注解的业务实现了
@Aspect
@Component
@Slf4j
public class AutoIdempontentHandler {
private final Gson gson = new Gson();
private static final String EXCLUDE_KEY = "";
private static final String METHOD_NAME = "";
@Autowired
private TokenService tokenService;
@Autowired
WebApplicationContext applicationContext;
@Pointcut("@annotation(cn.sckr.overseas.funds.common.base.annotation.AutoIdempotent)")
public void autoIdempontentHandler() {
}
@Before("autoIdempontentHandler()")
public void doBefore() {
log.info("idempontentHandler..doBefore()");
}
@Around("autoIdempontentHandler()")
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
boolean check = this.handleRequest(joinPoint);
if(check){
log.info("重复性请求:====>"+CommonUtil.getUrl());
throw new BusinessException(HttpStatus.REQUEST_TOO_MANY,"您已经操作过了,请勿重复操作!");
}
return joinPoint.proceed();
}
private Boolean handleRequest(ProceedingJoinPoint joinPoint) {
boolean result = false;
String arg = "";
String ipAddr = IpUtils.getIpAddr();
String requestURL = CommonUtil.getUrl();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
//获取自定义注解值
AutoIdempotent autoIdempotent = methodSignature.getMethod().getDeclaredAnnotation(AutoIdempotent.class);
long expireTime = autoIdempotent.expireTime();
String[] params = methodSignature.getParameterNames();
//获取参数值
Object[] args = joinPoint.getArgs();
Map<String, Object> reqMaps = new HashMap<>();
for(int i=0; i<params.length; i++){
reqMaps.put(params[i], args[i]);
}
String reqJSON = gson.toJson(reqMaps);
result = tokenService.checkRequest(ipAddr, requestURL, expireTime, reqJSON,true, EXCLUDE_KEY);
return result;
}
@AfterReturning(returning = "retVal", pointcut = "autoIdempontentHandler()")
public void doAfter(Object retVal) {
log.debug("{}", retVal);
}
下面是工具类
@Slf4j
public class CommonUtil {
public static String getUUid() {
String uuid = UUID.randomUUID().toString();
return uuid.replace("-", "");
}
public static String getUrl() {
ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
//获取请求属性中的请求头
HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(servletRequestAttributes)).getRequest();
return request.getRequestURI();
}
}
4.里面的核心方法,使用的是Redission的tryLock()
import cn.sckr.overseas.funds.common.base.redis.RedisUtil;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* @author xxx
* @create 2023-12-01 16:44
**/
@Service
@Slf4j
public class TokenService {
@Autowired
RedissonClient redissonClient;
public boolean checkRequest(String ipAddress, String requestURL, long expireTime, String reqJsonParam,Boolean boo, String... excludeKeys) {
boolean isConsiderDup = true;
String dedupMD5 = new ReqDedupHelper().dedupParamMD5(reqJsonParam, excludeKeys);
String redisKey = "IP:=" + ipAddress + "U=" + requestURL + "P=" + dedupMD5;
log.info("redisKey:{}", redisKey);
long expireAt = System.currentTimeMillis() + expireTime;
String val = "expireAt@" + expireAt;
// SETNX要自己实现锁与过期时间的原子操作 所以引入了redision
// 1.获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redissonClient.getLock(redisKey);
try {
if (lock.tryLock(0,expireTime,TimeUnit.MILLISECONDS)) {
isConsiderDup = false;
}
} catch (Exception e) {
e.printStackTrace();
log.info("加锁失败 failed!!key:{},value:{}", redisKey, val);
}
return isConsiderDup;
}
}
其中tryLock()里面的时间参数,第一个是锁等待时间,第二个是释放的时间 ps:我自己这么理解的哈哈哈
5.最后就是在接口controller中使用注解了
@ApiOperation(value = "测试幂等")
@PostMapping("/testTT")
@AutoIdempotent(expireTime = 5000)
public Result testTT(@Valid @RequestBody String data) {
System.out.println("==============测试幂等=========");
return Result.success();
}
5秒内如果IP+接口+参数一样的话,就回返回失败
参考文献:https://baijiahao.baidu.com/s?id=1763156313166040577&wfr=spider&for=pc