通常我们可以在前端通过防抖和节流来解决短时间内请求重复提交的问题,如果因网络问题、Nginx重试机制、微服务Feign重试机制或者用户故意绕过前端防抖和节流设置,直接频繁发起请求,都会导致系统防重请求失败,甚至导致后台产生多条重复记录,此时我们需要考虑在后台增加防重设置。 考虑到微服务分布式的场景,这里通过使用Redisson分布式锁自定义注解AOP的方式来实现后台防止重复请求的功能,基本实现思路:通过在需要防重的接口添加自定义防重注解,设置防重参数,通过AOP拦截请求参数,根据注解配置,生成分布式锁的Key,并设置有效时间。每次请求访问时,都会尝试获取锁,如果获取到,则执行,如果获取不到,那么说明请求在设置的重复请求间隔内,返回请勿频繁请求提示信息。1、自定义防止重复请求注解,根据业务场景设置了以下参数:interval:防止重复提交的时间间隔。timeUnit:防止重复提交的时间间隔的单位。currentSession:是否将sessionId作为防重参数(微服务及跨域前后端分离时,无法使用,Chrome等浏览器跨域时禁止携带cookie,每次sessionId都是新的)。currentUser:是否将用户id作为防重参数。keys:可以作为防重参数的字段(通过SpringExpression表达式,可以做到多参数时,具体取哪个参数的值)。ignoreKeys:需要忽略的防重参数字段,例如有些参数中的时间戳,此和keys互斥,当keys配置了之后,ignoreKeys失效。conditions:当参数中的某个字段达到条件时,执行防重配置,默认不需要配置。argsIndex:当没有配置keys参数时,防重拦截后会对所有参数取值作为分布式锁的key,这里时,当多参数时,配置取哪一个参数作为key,可以多个。此和keys互斥,当keys配置了之后,argsIndex配置失效。packagecom。gitegg。platform。base。annotation。resubmit;importjava。lang。annotation。;importjava。util。concurrent。TimeUnit;防止重复提交注解1、当设置了keys时,通过表达式确定取哪几个参数作为防重key2、当未设置keys时,可以设置argsIndex设置取哪几个参数作为防重key3、argsIndex和ignoreKeys是未设置keys时生效,排除不需要防重的参数4、因部分浏览器在跨域请求时,不允许request请求携带cookie,导致每次sessionId都是新的,所以这里默认使用用户id作为key的一部分,不使用sessionIdauthorGitEggTarget(ElementType。METHOD)Retention(RetentionPolicy。RUNTIME)DocumentedpublicinterfaceResubmitLock{防重复提交校验的时间间隔longinterval()default5;防重复提交校验的时间间隔的单位TimeUnittimeUnit()defaultTimeUnit。SECONDS;是否仅在当前session内进行防重复提交校验booleancurrentSession()defaultfalse;是否选用当前操作用户的信息作为防重复提交校验key的一部分booleancurrentUser()defaulttrue;keys和ignoreKeys不能同时使用参数SpringEL表达式例如{param。name},表达式的值作为防重复校验key的一部分String〔〕keys()default{};keys和ignoreKeys不能同时使用ignoreKeys不区分入参,所有入参拥有相同的字段时,都将过滤掉String〔〕ignoreKeys()default{};SpringEL表达式,决定是否进行重复提交校验,多个条件之间为且的关系,默认是进行校验String〔〕conditions()default{true};当未配置key时,设置哪几个参数作为防重对象,默认取所有参数returnint〔〕argsIndex()default{};}2、自定义AOP拦截防重请求的业务逻辑处理,详细逻辑处理请看代码注释。可以在Nacos中增加配置resubmitlock:enable:false使防重配置失效,默认不配置为生效状态。因为是ResubmitLockAspect是否初始化的ConditionalOnProperty配置,此配置修改需要重启服务生效。packagecom。gitegg。platform。boot。aspect;importcom。gitegg。platform。base。annotation。resubmit。ResubmitLock;importcom。gitegg。platform。base。enums。ResultCodeEnum;importcom。gitegg。platform。base。exception。SystemException;importcom。gitegg。platform。base。util。JsonUtils;importcom。gitegg。platform。boot。util。ExpressionUtils;importcom。gitegg。platform。boot。util。GitEggAuthUtils;importcom。gitegg。platform。boot。util。GitEggWebUtils;importcom。gitegg。platform。redis。lock。IDistributedLockService;importcom。google。common。collect。Maps;importlombok。RequiredArgsConstructor;importlombok。extern。log4j。Log4j2;importorg。apache。commons。lang3。ArrayUtils;importorg。aspectj。lang。JoinPoint;importorg。aspectj。lang。ProceedingJoinPoint;importorg。aspectj。lang。annotation。Aspect;importorg。aspectj。lang。annotation。Before;importorg。aspectj。lang。annotation。Pointcut;importorg。springframework。beans。factory。annotation。Autowired;importorg。springframework。boot。autoconfigure。condition。ConditionalOnProperty;importorg。springframework。lang。NonNull;importorg。springframework。stereotype。Component;importorg。springframework。util。DigestUtils;importjava。lang。reflect。Field;importjava。util。Arrays;importjava。util。Comparator;importjava。util。Map;importjava。util。TreeMap;authorGitEggdate2022410Log4j2ComponentAspectRequiredArgsConstructor(onConstructorAutowired)ConditionalOnProperty(nameenabled,prefixresubmitlock,havingValuetrue,matchIfMissingtrue)publicclassResubmitLockAspect{privatestaticfinalStringREDISSEPARATOR:;privatestaticfinalStringRESUBMITCHECKKEYPREFIXresubmitlockREDISSEPARATOR;privatefinalIDistributedLockServicedistributedLockService;Before切点Pointcut(annotation(com。gitegg。platform。base。annotation。resubmit。ResubmitLock))publicvoidresubmitLock(){}前置通知防止重复提交paramjoinPoint切点paramresubmitLock注解配置Before(annotation(resubmitLock))publicObjectresubmitCheck(JoinPointjoinPoint,ResubmitLockresubmitLock)throwsThrowable{finalObject〔〕argsjoinPoint。getArgs();finalString〔〕conditionsresubmitLock。conditions();根据条件判断是否需要进行防重复提交检查if(!ExpressionUtils。getConditionValue(args,conditions)ArrayUtils。isEmpty(args)){return((ProceedingJoinPoint)joinPoint)。proceed();}doCheck(resubmitLock,args);return((ProceedingJoinPoint)joinPoint)。proceed();}key的组成为:resubmitlock:userId:sessionId:uri:method:(根据springEL表达式对参数进行拼接)paramresubmitLock注解paramargs方法入参privatevoiddoCheck(NonNullResubmitLockresubmitLock,Object〔〕args){finalString〔〕keysresubmitLock。keys();finalbooleancurrentUserresubmitLock。currentUser();finalbooleancurrentSessionresubmitLock。currentSession();StringmethodGitEggWebUtils。getRequest()。getMethod();StringuriGitEggWebUtils。getRequest()。getRequestURI();StringBufferlockKeyBuffernewStringBuffer(RESUBMITCHECKKEYPREFIX);if(null!GitEggAuthUtils。getTenantId()){lockKeyBuffer。append(GitEggAuthUtils。getTenantId())。append(REDISSEPARATOR);}此判断暂时预留,适配后续无用户登录场景,因部分浏览器在跨域请求时,不允许request请求携带cookie,导致每次sessionId都是新的,所以这里默认使用用户id作为key的一部分,不使用sessionIdif(currentSession){lockKeyBuffer。append(GitEggWebUtils。getSessionId())。append(REDISSEPARATOR);}默认没有将user数据作为防重keyif(currentUsernull!GitEggAuthUtils。getCurrentUser()){lockKeyBuffer。append(GitEggAuthUtils。getCurrentUser()。getId())。append(REDISSEPARATOR);}lockKeyBuffer。append(uri)。append(REDISSEPARATOR)。append(method);StringBufferparametersBuffernewStringBuffer();优先判断是否设置防重字段,因keys试数组,取值时是按照顺序排列的,这里不需要重新排序if(ArrayUtils。isNotEmpty(keys)){Object〔〕argsForKeyExpressionUtils。getExpressionValue(args,keys);for(Objectobj:argsForKey){parametersBuffer。append(REDISSEPARATOR)。append(String。valueOf(obj));}}如果没有设置防重的字段,那么需要把所有的字段和值作为key,因通过反射获取字段时,顺序时不确定的,这里取出来之后需要进行排序else{只有当keys为空时,ignoreKeys和argsIndex生效finalString〔〕ignoreKeysresubmitLock。ignoreKeys();finalint〔〕argsIndexresubmitLock。argsIndex();if(ArrayUtils。isNotEmpty(argsIndex)){for(intindex:argsIndex){parametersBuffer。append(REDISSEPARATOR)。append(getKeyAndValueJsonStr(args〔index〕,ignoreKeys));}}else{for(Objectobj:args){parametersBuffer。append(REDISSEPARATOR)。append(getKeyAndValueJsonStr(obj,ignoreKeys));}}}将请求参数取md5值作为key的一部分,MD5理论上会重复,但是key中还包含session或者用户id,所以同用户在极端时间内请参数不同生成的相同md5值的概率极低StringparametersKeyDigestUtils。md5DigestAsHex(parametersBuffer。toString()。getBytes());lockKeyBuffer。append(parametersKey);try{booleanisLockdistributedLockService。tryLock(lockKeyBuffer。toString(),0,resubmitLock。interval(),resubmitLock。timeUnit());if(!isLock){thrownewSystemException(ResultCodeEnum。RESUBMITLOCK。code,ResultCodeEnum。RESUBMITLOCK。msg);}}catch(InterruptedExceptione){thrownewSystemException(ResultCodeEnum。RESUBMITLOCK。code,ResultCodeEnum。RESUBMITLOCK。msg);}}将字段转换为json字符串paramobjreturnpublicstaticStringgetKeyAndValueJsonStr(Objectobj,String〔〕ignoreKeys){MapString,ObjectmapMaps。newHashMap();得到类对象ClassobjCla(Class)obj。getClass();得到类中的所有属性集合Field〔〕fsobjCla。getDeclaredFields();for(inti0;ifs。length;i){Fieldffs〔i〕;设置些属性是可以访问的f。setAccessible(true);ObjectvalnewObject();try{StringfiledNamef。getName();如果字段在排除列表,那么不将字段放入mapif(null!ignoreKeysArrays。asList(ignoreKeys)。contains(filedName)){continue;}valf。get(obj);得到此属性的值设置键值map。put(filedName,val);}catch(IllegalArgumentExceptione){log。error(getKeyAndValueIllegalArgumentException,e);thrownewRuntimeException(您的操作太频繁,请稍后再试);}catch(IllegalAccessExceptione){log。error(getKeyAndValueIllegalAccessException,e);thrownewRuntimeException(您的操作太频繁,请稍后再试);}}MapString,ObjectsortMapsortMapByKey(map);StringmapStrJsonUtils。mapToJson(sortMap);returnmapStr;}privatestaticMapString,ObjectsortMapByKey(MapString,Objectmap){if(mapnullmap。isEmpty()){returnnull;}MapString,ObjectsortMapnewTreeMapString,Object(newComparatorString(){Overridepublicintcompare(Stringo1,Stringo2){return((String)o1)。compareTo((String)o2);}});sortMap。putAll(map);returnsortMap;}}3、Redisson分布式锁自定义接口packagecom。gitegg。platform。redis。lock;importjava。util。concurrent。TimeUnit;分布式锁接口authorGitEggdate2022410publicinterfaceIDistributedLockService{加锁paramlockKeykeyvoidlock(StringlockKey);释放锁paramlockKeykeyvoidunlock(StringlockKey);加锁并设置有效期paramlockKeykeyparamtimeout有效时间,默认时间单位在实现类传入voidlock(StringlockKey,inttimeout);加锁并设置有效期指定时间单位paramlockKeykeyparamtimeout有效时间paramunit时间单位voidlock(StringlockKey,inttimeout,TimeUnitunit);尝试获取锁,获取到则持有该锁返回true,未获取到立即返回falseparamlockKeyreturntrue获取锁成功false获取锁失败booleantryLock(StringlockKey);尝试获取锁,获取到则持有该锁leaseTime时间。若未获取到,在waitTime时间内一直尝试获取,超过watiTime还未获取到则返回falseparamlockKeykeyparamwaitTime尝试获取时间paramleaseTime锁持有时间paramunit时间单位returntrue获取锁成功false获取锁失败throwsInterruptedExceptionbooleantryLock(StringlockKey,longwaitTime,longleaseTime,TimeUnitunit)throwsInterruptedException;锁是否被任意一个线程锁持有paramlockKeyreturntrue被锁false未被锁booleanisLocked(StringlockKey);}4、Redisson分布式锁自定义接口实现类packagecom。gitegg。platform。redis。lock。impl;importcom。gitegg。platform。redis。lock。IDistributedLockService;importlombok。RequiredArgsConstructor;importorg。redisson。api。RLock;importorg。redisson。api。RedissonClient;importorg。springframework。beans。factory。annotation。Autowired;importorg。springframework。stereotype。Service;importjava。util。concurrent。TimeUnit;分布式锁的Redisson接口实现authorGitEggdate2022410ServiceRequiredArgsConstructor(onConstructorAutowired)publicclassDistributedLockServiceImplimplementsIDistributedLockService{privatefinalRedissonClientredissonClient;Overridepublicvoidlock(StringlockKey){RLocklockredissonClient。getLock(lockKey);lock。lock();}Overridepublicvoidunlock(StringlockKey){RLocklockredissonClient。getLock(lockKey);lock。unlock();}Overridepublicvoidlock(StringlockKey,inttimeout){RLocklockredissonClient。getLock(lockKey);lock。lock(timeout,TimeUnit。MILLISECONDS);}Overridepublicvoidlock(StringlockKey,inttimeout,TimeUnitunit){RLocklockredissonClient。getLock(lockKey);lock。lock(timeout,unit);}OverridepublicbooleantryLock(StringlockKey){RLocklockredissonClient。getLock(lockKey);returnlock。tryLock();}OverridepublicbooleantryLock(StringlockKey,longwaitTime,longleaseTime,TimeUnitunit)throwsInterruptedException{RLocklockredissonClient。getLock(lockKey);returnlock。tryLock(waitTime,leaseTime,unit);}OverridepublicbooleanisLocked(StringlockKey){RLocklockredissonClient。getLock(lockKey);returnlock。isLocked();}}5、SpringExpression自定义工具类,通过此工具类获取注解上的Expression表达式,以获取相应请求对象的值,如果请求对象有多个,可以通过Expression表达式精准获取。packagecom。gitegg。platform。boot。util;importorg。apache。commons。lang3。ArrayUtils;importorg。apache。commons。lang3。StringUtils;importorg。springframework。expression。Expression;importorg。springframework。expression。spel。standard。SpelExpressionParser;importorg。springframework。lang。NonNull;importorg。springframework。lang。Nullable;importjava。util。Map;importjava。util。concurrent。ConcurrentHashMap;SpringExpression工具类authorGitEggdate2022411publicclassExpressionUtils{privatestaticfinalMapString,ExpressionEXPRESSIONCACHEnewConcurrentHashMap(64);获取Expression对象paramexpressionStringSpringEL表达式字符串例如{param。id}returnExpressionNullablepublicstaticExpressiongetExpression(NullableStringexpressionString){if(StringUtils。isBlank(expressionString)){returnnull;}if(EXPRESSIONCACHE。containsKey(expressionString)){returnEXPRESSIONCACHE。get(expressionString);}ExpressionexpressionnewSpelExpressionParser()。parseExpression(expressionString);EXPRESSIONCACHE。put(expressionString,expression);returnexpression;}根据SpringEL表达式字符串从根对象中求值paramroot根对象paramexpressionStringSpringEL表达式paramclazz值得类型paramT泛型return值NullablepublicstaticTTgetExpressionValue(NullableObjectroot,NullableStringexpressionString,NonNullClasslt;?extendsTclazz){if(rootnull){returnnull;}ExpressionexpressiongetExpression(expressionString);if(expressionnull){returnnull;}returnexpression。getValue(root,clazz);}NullablepublicstaticTTgetExpressionValue(NullableObjectroot,NullableStringexpressionString){if(rootnull){returnnull;}ExpressionexpressiongetExpression(expressionString);if(expressionnull){returnnull;}noinspectionuncheckedreturn(T)expression。getValue(root);}求值paramroot根对象paramexpressionStringsSpringEL表达式paramT泛型这里的泛型要慎用,大多数情况下要使用Object接收避免出现转换异常return结果集publicstaticTT〔〕getExpressionValue(NullableObjectroot,NullableString。。。expressionStrings){if(rootnull){returnnull;}if(ArrayUtils。isEmpty(expressionStrings)){returnnull;}noinspectionConstantConditionsObject〔〕valuesnewObject〔expressionStrings。length〕;for(inti0;iexpressionStrings。length;i){noinspectionuncheckedvalues〔i〕(T)getExpressionValue(root,expressionStrings〔i〕);}noinspectionuncheckedreturn(T〔〕)values;}表达式条件求值如果为值为null则返回false,如果为布尔类型直接返回,如果为数字类型则判断是否大于0paramroot根对象paramexpressionStringSpringEL表达式return值NullablepublicstaticbooleangetConditionValue(NullableObjectroot,NullableStringexpressionString){ObjectvaluegetExpressionValue(root,expressionString);if(valuenull){returnfalse;}if(valueinstanceofBoolean){return(boolean)value;}if(valueinstanceofNumber){return((Number)value)。longValue()0;}returntrue;}表达式条件求值paramroot根对象paramexpressionStringsSpringEL表达式数组return值NullablepublicstaticbooleangetConditionValue(NullableObjectroot,NullableString。。。expressionStrings){if(rootnull){returnfalse;}if(ArrayUtils。isEmpty(expressionStrings)){returnfalse;}noinspectionConstantConditionsfor(StringexpressionString:expressionStrings){if(!getConditionValue(root,expressionString)){returnfalse;}}returntrue;}}5、防重测试,我们在系统的用户接口(GitEggCloud工程的UserController类)上进行测试,通过多参数接口以及配置keys,不配置keys等各种场景进行测试,在测试时为了达到效果,可以将interval时间设置为30秒。设置user参数的realName,mobile和page参数的size为key进行防重测试ResubmitLock(interval30,keys{〔0〕。realName,〔0〕。mobile,〔1〕。size})publicPageResultUserInfolist(ApiIgnoreQueryUserDTOuser,ApiIgnorePageUserInfopage){PageUserInfopageUseruserService。selectUserList(page,user);PageResultUserInfopageResultnewPageResult(pageUser。getTotal(),pageUser。getRecords());returnpageResult;}不设置防重参数的key,只取第一个参数user,配置排除的参数,不参与放重key的生成ResubmitLock(interval30,argsIndex{0},ignoreKeys{email,status})publicPageResultUserInfolist(ApiIgnoreQueryUserDTOuser,ApiIgnorePageUserInfopage){PageUserInfopageUseruserService。selectUserList(page,user);PageResultUserInfopageResultnewPageResult(pageUser。getTotal(),pageUser。getRecords());returnpageResult;}测试结果 相关引用: 1、防重配置项及通过SpringExpression获取相应参数:https:www。jianshu。comp77895a822237 2、Redisson分布式锁及相关工具类:https:blog。csdn。netwshningjingarticledetails115326052源码地址: GitEgg:GitEgg是一款开源免费的企业级微服务应用开发框架,旨在整合目前主流稳定的开源技术框架,集成常用的最佳项目解决方案,实现可直接使用的微服务快速开发框架。 GitHubwmz1930GitEgg:GitEgg是一款开源免费的企业级微服务应用开发框架,旨在整合目前主流稳定的开源技术框架,集成常用的最佳项目解决方案,实现可直接使用的微服务快速开发框架。