场景:用于接口请求参数幂等,基于请求参数判断在3s(interval)时间内是否重复提交,重复提交,则直接返回 {"code":2500,"message":"重复提交"}
EVN : springboot 2.3.12 + jdk8
使用: 1.(在需要做类似幂等的接口加上注解)@AvoidResubmit interval: 两次相同请求的最小时间间隔(ms),小于这个时间,认为是重复提交
isLoc=true : 表示单机版本,使用本地缓存,不需要配置redis , isLoc = flase , 用于分布式服务,需要配置redis
2.请求时必须带上认证请求头 Authorization,如果接口不需要认证 Authorization 的值, 可以是当前sessionId or userId , 多次请求唯一即可
@PostMapping("add") @AvoidResubmit(isLoc = false,interval = 2000) public JSONObject add(@RequestBody @Validated AgreeDO agree) { // doingreturn ResponseUtil.getResult(ResponseCode.SUCCESS.getCode(),ResponseCode.SUCCESS.getMessage(),null); }
eg:
curl -X POST "http://localhost:7213/agree/add" -H "accept: */*" -H "Content-Type: application/json" -H "Authorization:a369361156674bb496fc94ee49c84bd6_1659490855779"
-d "{ \"avatar\": \"string\", \"remark\": \"string\", \"ts\": 0, \"type\": \"string\", \"typeId\": \"string\", \"userId\": \"string\", \"userName\": \"string\"}"
{"code":2500,"message":"数据重复提交"}
依赖:
<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>
添加如下三个类:
AvoidResubmit AvoidResubmitHandler, SeaRequestBodyHolder(解决获取body问题)
/** * 自定义注解,用于是否做方重复提交数据检测 */ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface AvoidResubmit { boolean isLoc() default true; //* 间隔时间(ms),小于此时间视为重复提交 int interval() default 3000; }
AvoidResubmitHandler
import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import com.sea.bx.common.config.SeaRequestBodyHolder; 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.core.annotation.Order; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; 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.security.MessageDigest; import java.util.ArrayList; import java.util.Base64; import java.util.Date; import java.util.List; 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.bx.handler * @File Name : ResubmitLock * @Author : Sea * @Date : 8/2/22 3:25 PM * @Purpose : 接口幂等 * @History : *</pre> ************* 基于请求参数校验的幂等操作 => **************/ /** * 目的: 自定义切片,防止表单重复提交,网络抖动, 尤其是服务间调用,retry,防止重复提交数据 * 如果在单位时间内(自定义2s内), 同一个用户请求的数据一样,则认为是重复提交 * 1. 提交数据,需要携带认证 AUTHORIZATION_TOKEN_HEADER = "Authorization"; * 2. 请求时,生成唯一 key uri+token+ body.md5 * 3. 验证 唯一 key 是否存在, 并且时间间隔是否小于设定 * 4. 如果存在,并且小于设定时间间隔, 则说明重复提交,响应: 重复提交 {"code":2500,message:"重复提交"} */ @Slf4j @Aspect @Component public class AvoidResubmitHandler{ private final static int RESUBMIT_CODE = 2500; private final static int RESUBMIT_NO_TOKEN = 2501; private final static String CODE = "code"; private final static String OK = "200"; /** * @param joinPoint * @param avoidResubmit * @return * @throws Throwable */ @Order(-1) @Around("@annotation(avoidResubmit)") public Object handlerAvoidResubmit(ProceedingJoinPoint joinPoint, AvoidResubmit avoidResubmit) throws Throwable { List<Object> cacheKeyList = new ArrayList<>(); try { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); //把body 放入list, 后面取出, 因为@RequestBody 在接口处已经获取了一次,后面通过HttpServletRequest,无法获取了 //另外 用于解决非 post方法(put del), 请求参数防抖 Object[] bodyArgs = joinPoint.getArgs(); cacheKeyList.add((bodyArgs!=null&&bodyArgs.length>0)?JSON.toJSONString(bodyArgs):null); String resubmitToken = request.getHeader(AUTHORIZATION_TOKEN_HEADER);// Authorization if(!this.checkTokenExist(resubmitToken,avoidResubmit,request,cacheKeyList)) { if(StringUtils.isBlank(resubmitToken)){ //2501 return new JSONObject(){{put(CODE,RESUBMIT_NO_TOKEN);put("message","没有认证信息,bad token");}};} //controller 我的接口层默认返回的对象是 JSONObject;//2500 return new JSONObject(){{put(CODE,RESUBMIT_CODE);put("message","数据重复提交");}}; } }catch (Exception e) { //操作过程出错,回滚 e.printStackTrace(); ifNotOkClearLockAndCache(null,avoidResubmit.isLoc(), cacheKeyList.isEmpty()?"":cacheKeyList.get(0)+""); } finally { //释放资源,避免内存泄露 SeaRequestBodyHolder.resetRequestBody(); } Object result = joinPoint.proceed();// // 可能存在网络问题,响应不 ok,需要立马再次提交,所以如果code 不是 200 , 可以再次立马提交 ifNotOkClearLockAndCache( result, avoidResubmit.isLoc(), cacheKeyList.isEmpty()?"":cacheKeyList.get(0)+""); return result; } /** * 如果code不是 200, 回滚,放行 * @param result : 此次result : JSONObject 我的ResponseUtils : 封装 new JSONObject(){{put("code",2500);put("message","xx","data":"");}} * @param isLoc * @param cacheKey */ private void ifNotOkClearLockAndCache(Object result,Boolean isLoc, String cacheKey){ try { if(StringUtils.isBlank(cacheKey)){return;} JSONObject myResult =result==null ? new JSONObject(): (JSONObject) result ; String code = myResult.get(CODE)==null?null:myResult.get(CODE)+"";//code code = StringUtils.isBlank(code)?myResult.get(CODE.toUpperCase())+"":code;//CODE //code:2500 数据重复提交 if(!OK.equalsIgnoreCase(code)&&!(RESUBMIT_CODE+"").equalsIgnoreCase(code)){ System.err.println("roll back"); if(isLoc){ cacheLoc.invalidate(cacheKey); MyNxLockUtils.unlock("lc"+cacheKey,"1"); }else { redisTemplate.delete(cacheKey); redisTemplate.delete("lc"+cacheKey); } } }catch (Exception e) { e.printStackTrace(); log.error(e+""); } } // ################### base logic handler ################### //对于用户唯一 private static String AUTHORIZATION_TOKEN_HEADER = "Authorization"; private static String RESUBMIT_PREFIX = "resubmit_"; private static long cacheTimeOutSec = 10;//3min public static Cache<String, Long> cacheLoc = CacheBuilder.newBuilder().expireAfterWrite(cacheTimeOutSec, TimeUnit.SECONDS).build(); private static RedisTemplate<String,Object> redisTemplate ; @Autowired public void setRedisTemplate(RedisTemplate redisTemplate) { StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); redisTemplate.setKeySerializer(stringRedisSerializer); redisTemplate.setValueSerializer(stringRedisSerializer); this.redisTemplate = redisTemplate; } /** * if true . psss * @param token * @param avoidResubmit * @param request * @return */ public Boolean checkTokenExist(String token, AvoidResubmit avoidResubmit,HttpServletRequest request,List<Object> cacheKeyList) { //token is Authorization if(StringUtils.isBlank(token)){return false;} if(avoidResubmit.isLoc()) { return checkExistInLoc(token,avoidResubmit,request,cacheKeyList); }else { return checkExistInRedis(token,avoidResubmit,request,cacheKeyList); } } /** * 检测请求body是否之前发送过,解决重复点击问题(抖动) * if exist, return false * if not exist , add key:uri , value: body MD5 * remark : 如果接口使用了@RequestBody ,就无法再次获取body, 并且出现:getInputStream() has already been called for this request * @param request */ private String getBodyMD5(HttpServletRequest request) { try { /*BufferedReader reader = request.getReader(); if(reader==null){return null;} String body = IOUtils.toString(reader);*/ String body = SeaRequestBodyHolder.getRequestBody(); if(body==null){return null;} System.err.println("body---"+body); //MD5 MessageDigest md5 = MessageDigest.getInstance("MD5"); return Base64.getEncoder().encodeToString(md5.digest(body.getBytes("utf-8"))); } catch (Exception e) { log.warn("get body from HttpServletRequest error , I will get it from request Args later "+e); return null; } } /** * if exist return true * @param token * @param avoidResubmit * @param request * @return */ private Boolean checkExistInLoc(String token,AvoidResubmit avoidResubmit,HttpServletRequest request,List<Object> cacheKeyList){ Boolean result = true; //检测body String bodyMD5 = getBodyMD5(request); if(bodyMD5==null&&cacheKeyList.get(0)!=null){// 用于get delete put 方法,使用请求参数 bodyMD5 = DigestUtils.md5DigestAsHex(cacheKeyList.remove(0).toString().getBytes()); } cacheKeyList.clear(); if(bodyMD5!=null) { //类似分布式锁 String key = DigestUtils.md5DigestAsHex((token + request.getRequestURI()+bodyMD5).getBytes()); String lcKey="lc"+key; Boolean lock = MyNxLockUtils.getLock(lcKey, "1"); if(!lock){return false;} //检测请求body是否之前发送过,解决重复点击问题 Long reqBeforeTs = cacheLoc.getIfPresent(key); System.err.println("reqBeforeTs"+reqBeforeTs); if(reqBeforeTs!=null)//之前请求过 { System.err.println("diff time :" +(new Date().getTime() - reqBeforeTs)); //2s 以内,认为是重复提交 if(new Date().getTime() - reqBeforeTs < avoidResubmit.interval()) { result = false; }else{ //超过规定的时间,删除 key ,放置新的值 cacheLoc.put(key,new Date().getTime()); } }else { cacheKeyList.add(key); //添加到List,后面异常情况补偿回滚处理,当前请求不ok的情况 cacheLoc.put(key,new Date().getTime()); } MyNxLockUtils.unlock(lcKey,"1"); } return result; } /** * if exist return true * @param token * @param avoidResubmit * @param request * @return */ private Boolean checkExistInRedis(String token,AvoidResubmit avoidResubmit,HttpServletRequest request,List<Object> cacheKeyList){ Boolean result = true; //检测body String bodyMD5 = getBodyMD5(request); if(bodyMD5==null&&cacheKeyList.get(0)!=null){ bodyMD5 = DigestUtils.md5DigestAsHex(cacheKeyList.remove(0).toString().getBytes()); } cacheKeyList.clear(); if(bodyMD5!=null) { //类似分布式锁 String key = RESUBMIT_PREFIX+DigestUtils.md5DigestAsHex((token + request.getRequestURI()+bodyMD5).getBytes()); String lcKey="lc"+key; Boolean getLock = redisTemplate.opsForValue().setIfAbsent( lcKey , "1", 5, TimeUnit.SECONDS); if(!getLock){return false;} //检测请求body是否之前发送过,解决重复点击问题 Object reqBeforeTs = redisTemplate.opsForValue().get(key); System.err.println("reqBeforeTs"+reqBeforeTs); if(reqBeforeTs!=null)//之前请求过 { System.err.println("diff time :" +(new Date().getTime() - Long.valueOf(reqBeforeTs+""))); //2s 以内,认为是重复提交 if(new Date().getTime() - Long.valueOf(reqBeforeTs+"") < avoidResubmit.interval()) { result = false; }else{ //超过规定的时间,删除 key ,放置新的值 redisTemplate.opsForValue().set(key,new Date().getTime()+""); } }else { cacheKeyList.add(key); //添加到List,后面异常情况补偿回滚处理,当前请求不ok的情况 redisTemplate.opsForValue().set(key,new Date().getTime()+""); } redisTemplate.delete(lcKey); } return result; } /** * 自定义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"; /** * 分段枷锁,提升效率 * @param k * @return */ 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(10, 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; } public static void unlock(String k, String v) { String ifPresent = lockCache.getIfPresent(k); if(ifPresent!=null&&ifPresent.equals(v)){ lockCache.invalidate(k); } } } }
SeaRequestBodyHolder
import com.sea.handler.AvoidResubmit; import org.apache.commons.io.IOUtils; import org.springframework.core.MethodParameter; import org.springframework.core.NamedThreadLocal; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdviceAdapter; import javax.servlet.http.HttpServletRequest; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; /*************************** *<pre> * @Project Name : Sea-blog-service * @Package : com.sea.common.config * @File Name : SeaRequestBodyHolder * @Author : Sea * @Date : 8/4/22 2:11 PM * @Purpose : body 默认只能获取一次: 如果接口使用了@RequestBody , HttpServletRequest在接口会获取一次body,后面就无法再次获取body, * 并且出现:getInputStream() has already been called for this request * 此处在 获取body后,优选缓存一份body * @History : *</pre> ***************************/ @ControllerAdvice public class SeaRequestBodyHolder extends RequestBodyAdviceAdapter { private static String requestBody = "body"; private static final ThreadLocal<Map<String,String>> requestBodyHolder = new NamedThreadLocal("Sea Request body holder"); public static String getRequestBody(){ try{ String body = requestBodyHolder.get().get(requestBody); resetRequestBody(); return body; }catch (Exception e){ return null; } } public static void resetRequestBody(){ requestBodyHolder.remove(); } @Override public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) { return true; } //如果仅仅是post 方法防抖,可以在此处操作 @Override public HttpInputMessage beforeBodyRead(HttpInputMessage httpInputMessage, MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) throws IOException { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); // System.err.println(request.getMethod()); // 不包含注解 @CheckResubmit 不需要读body 直接返回 if(!methodParameter.hasMethodAnnotation(AvoidResubmit.class)){ return super.beforeBodyRead(httpInputMessage, methodParameter, type, aClass); } // 第一次 读取body String bodyStr = IOUtils.toString(httpInputMessage.getBody(), StandardCharsets.UTF_8); // 重新new 一个 HttpInputMessage return new HttpInputMessage() { @Override public InputStream getBody() throws IOException { String body = bodyStr; //放入requestBodyHolder,后面获取 requestBodyHolder.set(new HashMap<String, String>(){{put(requestBody,body);}}); // 重新写入 body return new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)); } @Override public HttpHeaders getHeaders() { return httpInputMessage.getHeaders(); } }; } }