/**
* 目的: 自定义切片,防止表单重复提交, 尤其是服务间调用,retry,防止重复提交数据
* 1. 表单提交时 , 优先获取 一个token
* 2. 提交表单时, 携带token 请求头 (header name: rdtc_token)
* 3. 如果重复提交,响应: 重复提交 {"code":2500,message:"重复提交"}
* eg:
* 支付分为两个步骤:
* 1.1 获取全局唯一token
* 接口处理生成唯一标识(token) 存储到redis中,并返回给调用客户端。
* 1.2 发起支付操作并附带token
* 接口处理:
* 1.2.1 获得分布式锁(处理并发情况)
* 1.2.2 判断redis中是否存在token
* 1.2.3 存在 执行支付业务逻辑,否则返回该订单已经支付
* 1.2.4 释放分布式锁(自动)
*/
主要逻辑解析:
使用方法:
1.getToken (请求前先获取token) 请求时header携带 token ( header name: rdtc_token )
@GetMapping("getToken")
public String getSubCode(Boolean isLoc){
String token = ResubmitHandler.createToken(isLoc);
return token;
}
2 .controller 层 接口方法上添加注解 (需要幂等的方法)
@AvoidResubmit(isLoc = false)
isLoc = true 单机:默认使用本地缓存实现,
isloc = flase 集群:则使用redis ,才是真正意义上的分布式幂等
@PostMapping("add") @AvoidResubmit(isLoc = false) public JSONObject add(@RequestBody @Validated AgreeDO agree) { //doingreturn ResponseUtil.getResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMessage(),null); }
测试: 1. 请求前现获取一个token, 一次性token
请求时header携带 token ( header name: rdtc_token ) resubmit data code token
curl -X POST "http://localhost:7213/agree/add" -H "accept: */*" -H "Content-Type: application/json" -H "rdtc_token:a369361156674bb496fc94ee49c84bd6_1659490855779"
-d "{ \"avatar\": \"string\", \"remark\": \"string\", \"ts\": 0, \"type\": \"string\", \"typeId\": \"string\", \"userId\": \"string\", \"userName\": \"string\"}"
如果再次提交: return {"code":2500,"message":"数据重复提交"}
配置: 添加如下两个类:
AvoidResubmitHandler
添加自定义注解
/** * 自定义注解,用于是否做方重复提交数据检测 isLoc 表示是单节点,还是多个节点, 单机默认使用内存, 集群则 采用redis */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface AvoidResubmit { boolean isLoc() default true; }
添加 AvodiResubmitHandler
/*************************** *<pre> * @Project Name : sea-blog-service * @Package : com.sea.xx.handler * @File Name : AvoidResubmitHandler * @Author : Sea * @Date : 8/5/22 10:53 AM * @Purpose : * @History : *</pre> ***************************/ import com.alibaba.fastjson.JSONObject; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.Date; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * 依赖: * <dependency> * <groupId>org.springframework.boot</groupId> * <artifactId>spring-boot-starter-data-redis</artifactId> * </dependency> * <dependency> * <groupId>com.google.guava</groupId> * <artifactId>guava</artifactId> * <version>22.0</version> * </dependency> */ /*************************** *<pre> * @Project Name : sea-blog-service * @Package : com.sea.handler * @File Name : ResubmitLock * @Author : Sea * @Date : 8/2/22 3:25 PM * @Purpose : 接口幂等 * @History : *</pre> ***************************/ @Slf4j @Aspect @Component public class AvoidResubmitHandler{ /** * @param joinPoint * @param avoidResubmit * @throws Throwable */ @Around("@annotation(avoidResubmit)") public Object handleSeaAnnotionAOPMethod(ProceedingJoinPoint joinPoint, AvoidResubmit avoidResubmit) throws Throwable { ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); boolean isLoc = avoidResubmit.isLoc(); String resubmitToken = sra.getRequest().getHeader(RESUBMIT_TOKEN_NAME);// uuid|ts if(!this.checkTokenExist(resubmitToken,isLoc)){ //{"code":2500,message:"重复提交"} return new JSONObject(){{put("code",2500);put("message","数据重复提交");}}; } Object object = joinPoint.proceed(); //删除token // delToken(resubmitToken,isLoc); return object; } // ################### base logic handler ################### private static String RESUBMIT_TOKEN_NAME = "rdtc_token"; private static String RESUBMIT_PREFIX = "resubmit_"; private static long tokenTimeOutSec = 180;//3min private static Cache<String, Long> cacheLoc = CacheBuilder.newBuilder().expireAfterWrite(tokenTimeOutSec, TimeUnit.SECONDS).build(); //避免直接注入报错 private static RedisTemplate<String,Object> redisTemplate ;//= getBean("redisTemplate", new RedisTemplate<String, Object>().getClass());; @Autowired public void setRedisTemplate(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } /** * token name [rdtc_token] resubmit data code * 1.基于本地缓存 + token 实现防止重读提交 * 2.基于redis + token * token 的设计 : UUID+"|" 时间戳 */ public static String createToken(Boolean isLoc){ String token = UUID.randomUUID().toString().replaceAll("-",""); long time = new Date().getTime(); if(isLoc){cacheLoc.put(token,time);} else { redisTemplate.opsForValue().set(RESUBMIT_PREFIX+token,time+"",tokenTimeOutSec,TimeUnit.SECONDS); } return token+"_"+new Date().getTime(); } /** * 删除token * @param token * @param isLoc * @return */ public void delToken(String token,Boolean isLoc){ try { if(token.contains("_")){token=token.split("_")[0];} if(isLoc){ cacheLoc.invalidate(token);} else { redisTemplate.delete(RESUBMIT_PREFIX+token); } }catch (Exception e){ e.printStackTrace(); log.error(e+""); } } /** * token name [rdtc_token] resubmit data code * token : UUID+时间戳 校验机制 * 1.验证时间,if ts> 60s return not ok * 2.get token from cache, if not exist return no ok * @param token uuid|ts * @param isLoc * @return if exits return true */ public Boolean checkTokenExist(String token , Boolean isLoc){ if(StringUtils.isBlank(token)||!token.contains("_")){return false;} String[] split = token.split("_"); token= split[0]; //如果严重超时,直接返回失败 if(new Date().getTime() - Long.valueOf(split[1])>180*1000){ return false;} if(isLoc){return checkExistInLoc(token);} return checkExistInRedis(token); } /** * @param token * @return */ private Boolean checkExistInLoc(String token){ //类似分布式锁 Boolean lock = MyNxLockUtils.getLock(token, "1"); if(!lock){return false;} Long value = cacheLoc.getIfPresent(token); if(value==null){return false;} return true; } /** * @param token * @return */ private Boolean checkExistInRedis(String token){ try{ Boolean lock = redisTemplate.opsForValue().setIfAbsent("lc" + token, "1", 10, TimeUnit.SECONDS); if(!lock){return false;} return redisTemplate.hasKey(RESUBMIT_PREFIX + token); }catch (Exception e){ e.printStackTrace(); log.error(e+""); return true; } } /** * 自定义loc类分布式锁 */ public static class MyNxLockUtils { private static volatile String lcPoint0="xx0", lcPoint1="xx1", lcPoint2="xx2" ,lcPoint3="xx3", lcPoint4="xx4", lcPoint5="xx5", lcPoint6="xx6", lcPoint7="xx7", lcPoint8="xx8", lcPoint9="xx9"; // 分段枷锁,提升效率 private static String getLcPoint(String k){ switch (k.hashCode()%10){ case 1: return lcPoint1; case 2: return lcPoint2; case 3: return lcPoint3; case 4: return lcPoint4; case 5: return lcPoint5; case 6: return lcPoint6; case 7: return lcPoint7; case 8: return lcPoint8; case 9: return lcPoint9;default: return lcPoint0; } } private static Cache<String, String> lockCache = null; static { lockCache = CacheBuilder.newBuilder().expireAfterWrite(8, TimeUnit.SECONDS).build(); } /** * @param k * @param v * @return */ public static Boolean getLock(String k, String v){ if(lockCache.getIfPresent(k)==null) { synchronized (getLcPoint(k)){ if(lockCache.getIfPresent(k)==null){ lockCache.put(k,v); return true; }} } return false; } } }