背景
最近在小组同学卷的受不了的情况下,我决定换一个方向卷去,在算法上还是认命吧,跟他们差距太大了, 在最近一段时间偶然看到网上关于接口幂等性校验的文章,在我一番思索下,发现他们的实现原理各有不同而且每种实现原理各有不同,加之最近恰好在学设计模式,我就在想怎样利用设计模式让我们可以随意选择不同的实现方式。在此声明一下,笔者仅仅是一个学生,对于正式的业务流程开发并不太懂,只是利用自己现有的知识储备,打造一个让自己使用起来更方便的小demo, 如果有大佬觉得哪儿有问题,欢迎指出。
什么是幂等性
在数学领域中对于幂等性的解释是 f(f(x)) = f(x) 即幂等元素x在函数f的多次作用下,其效果和在f的一次作用下相同。在编程上可以理解为,如果某个函数(方法)或接口被调用多次其行为结果和被调用一次相同,则这种函数或接口就具有幂等性。简单举个例子,==天然幂等性==:
假设对象Person中有个name属性,有个
setName(Stringname){ 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 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
如何实现接口的幂等性校验
网上流传最多的应该是四种方式去实现接口的幂等性校验,接下来我们来一个个盘点。
数据库唯一主键
「方案描述」 数据库唯一主键的实现主要是利用数据库中主键唯一约束的特性,一般来说唯一主键比较适用于“插入”时的幂等性,其能保证一张表中只能存在一条带该唯一主键的记录。使用数据库唯一主键完成幂等性时需要注意的是,该主键一般来说并不是使用数据库中自增主键,而是使用分布式 ID 充当主键(或者使用其他算法生成的全局唯一的id),这样才能能保证在分布式环境下 ID 的全局唯一性。
「适用操作:」 插入操作 删除操作
「使用限制:」 需要生成全局唯一主键 ID;
「主要流程:」 ① 客户端执行创建请求,调用服务端接口。② 服务端执行业务逻辑,生成一个分布式 ID,将该 ID 充当待插入数据的主键,然后执数据插入操作,运行对应的 SQL 语句。③ 服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。
数据库乐观锁
「方案描述:」 数据库乐观锁方案一般只能适用于执行“更新操作”的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。这样每次对该数据库该表的这条数据执行更新时,都会将该版本标识作为一个条件,值为上次待更新数据中的版本标识的值。「适用操作:」 更新操作
「使用限制:」 需要数据库对应业务表中添加额外字段;
防重 Token 令牌
「方案描述:」 针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中),后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。
「适用操作:」 插入操作 更新操作 删除操作
「使用限制:」 需要生成全局唯一 Token 串;需要使用第三方组件 Redis 进行数据效验;
redis
「方案描述:」
第四种是我觉着用着挺方便的,但是实用性应该不大,而且和第三种类似,我们可以把接口名加请求参数通过算法生成一个全局唯一的id,然后 存到redis中,如果在一定时间请求多次,我们就直接拒绝。
「适用操作:」 插入操作 更新操作 删除操作
「使用限制:」 需要使用第三方组件 Redis 进行数据效验;
如何将这几种方式都组装到一起
我使用了Java自带的注解以及设计模式中的策略模式,我们可以在注解中直接指定幂等性校验的方式,当然也可以在配置文件中指定,但是直接在注解中指定更加灵活。
但是,由于最近时间比较忙,天天被某些人卷,很少有时间去完善,目前只是实现了redis和防重 Token 令牌两种方式的。以下是部分代码
「自定义注解」
packageorg.example.annotation; importjava.lang.annotation.*; /** *@authorzrq */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public@interfaceRequestMany{ /** *策略 *@return */ Stringvalue()default""; /** *过期时间 *@return */ longexpireTime()default0; }
「定义切面」
packageorg.example.aop; importorg.aspectj.lang.ProceedingJoinPoint; importorg.aspectj.lang.annotation.Around; importorg.aspectj.lang.annotation.Aspect; importorg.aspectj.lang.reflect.MethodSignature; importorg.example.annotation.RequestMany; importorg.example.factory.RequestManyStrategy; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.stereotype.Component; importorg.springframework.util.DigestUtils; importorg.springframework.web.context.request.RequestContextHolder; importorg.springframework.web.context.request.ServletRequestAttributes; importjavax.servlet.http.HttpServletRequest; importjava.lang.reflect.Method; importjava.util.Arrays; importjava.util.Map; importjava.util.stream.Collectors; /** *@authorzrq *@ClassNameRequestManyValidationAspect *@date2023/11/229:14 *@DescriptionTODO */ @Aspect @Component publicclassRequestManyValidationAspect{ @Autowired privateMapidempotentStrategies; @Around("@annotation(org.example.annotation.RequestMany)") publicObjectvalidateIdempotent(ProceedingJoinPointjoinPoint)throwsThrowable{ MethodSignaturemethodSignature=(MethodSignature)joinPoint.getSignature(); Methodmethod=methodSignature.getMethod(); RequestManyrequestMany=method.getAnnotation(RequestMany.class); Stringstrategy=requestMany.value();//获取注解中配置的策略名称 Integertime=(int)requestMany.expireTime();//获取注解中配置的策略名称 if(!idempotentStrategies.containsKey(strategy)){ thrownewIllegalArgumentException("Invalididempotentstrategy:"+strategy); } Stringkey=generateKey(joinPoint);//根据方法参数等生成唯一的key RequestManyStrategyidempotentStrategy=idempotentStrategies.get(strategy); idempotentStrategy.validate(key,time); returnjoinPoint.proceed(); } privateStringgenerateKey(ProceedingJoinPointjoinPoint){ //获取类名 StringclassName=joinPoint.getTarget().getClass().getSimpleName(); //获取方法名 MethodSignaturemethodSignature=(MethodSignature)joinPoint.getSignature(); StringmethodName=methodSignature.getMethod().getName(); //获取方法参数 Object[]args=joinPoint.getArgs(); StringargString=Arrays.stream(args) .map(Object::toString) .collect(Collectors.joining(",")); //获取请求携带的Token HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); Stringtoken=request.getHeader("token"); //生成唯一的key Stringkey=className+":"+methodName+":"+argString+":"+token; Stringmd5Password=DigestUtils.md5DigestAsHex(key.getBytes()); returnmd5Password; } }
「处理异常」
packageorg.example.exception; /** *运行时异常 *@authorbinbin.hou *@since0.0.1 */ publicclassRequestManyValidationExceptionextendsRuntimeException{ publicRequestManyValidationException(){ } publicRequestManyValidationException(Stringmessage){ super(message); } publicRequestManyValidationException(Stringmessage,Throwablecause){ super(message,cause); } publicRequestManyValidationException(Throwablecause){ super(cause); } publicRequestManyValidationException(Stringmessage,Throwablecause,booleanenableSuppression,booleanwritableStackTrace){ super(message,cause,enableSuppression,writableStackTrace); } }
「模式工厂」
packageorg.example.factory; importorg.example.exception.RequestManyValidationException; /** *@authorzrq *@ClassNameRequestManyStrategy *@date2023/11/229:04 *@DescriptionTODO */ publicinterfaceRequestManyStrategy{ voidvalidate(Stringkey,Integertime)throwsRequestManyValidationException; }
「模式实现01」
packageorg.example.factory.impl; importorg.example.exception.RequestManyValidationException; importorg.example.factory.RequestManyStrategy; importorg.example.utils.RedisCache; importorg.springframework.stereotype.Component; importjavax.annotation.Resource; importjava.util.concurrent.TimeUnit; /** *@authorzrq *@ClassNameRedisIdempotentStrategy *@date2023/11/229:07 *@DescriptionTODO */ @Component publicclassRedisIdempotentStrategyimplementsRequestManyStrategy{ @Resource privateRedisCacheredisCache; @Override publicvoidvalidate(Stringkey,Integertime)throwsRequestManyValidationException{ if(redisCache.hasKey(key)){ thrownewRequestManyValidationException("请求次数过多"); }else{ redisCache.setCacheObject(key,"1",time,TimeUnit.MINUTES); } } }
「模式实现02」
packageorg.example.factory.impl; importorg.example.exception.RequestManyValidationException; importorg.example.factory.RequestManyStrategy; importorg.example.utils.RedisCache; importorg.springframework.data.redis.connection.RedisConnectionFactory; importorg.springframework.data.redis.connection.jedis.JedisConnectionFactory; importorg.springframework.data.redis.core.RedisTemplate; importorg.springframework.data.redis.serializer.StringRedisSerializer; importorg.springframework.stereotype.Component; importorg.springframework.web.context.request.RequestContextHolder; importorg.springframework.web.context.request.ServletRequestAttributes; importjavax.annotation.Resource; importjavax.servlet.http.HttpServletRequest; /** *@authorzrq *@ClassNameTokenIdempotentStrategy *@date2023/11/229:13 *@DescriptionTODO */ @Component publicclassTokenIdempotentStrategyimplementsRequestManyStrategy{ @Resource privateRedisCacheredisCache; @Override publicvoidvalidate(Stringkey,Integertime)throwsRequestManyValidationException{ HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); Stringtoken=request.getHeader("token"); if(token==null||token.isEmpty()){ thrownewRequestManyValidationException("未授权的token"); } //根据key和token执行幂等性校验 booleanisDuplicateRequest=performTokenValidation(key,token); if(!isDuplicateRequest){ thrownewRequestManyValidationException("多次请求"); } } privatebooleanperformTokenValidation(Stringkey,Stringtoken){ //执行根据Token进行幂等性校验的逻辑 //这里可以使用你选择的合适的方法,比如将Token存储到数据库或缓存中,然后检查是否已存在 StringstoredToken=redisCache.getCacheObject(key); //比较存储的Token和当前请求的Token是否一致 returntoken.equals(storedToken); } }
「redisutil类」
packageorg.example.utils; importlombok.extern.slf4j.Slf4j; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.data.redis.connection.BitFieldSubCommands; importorg.springframework.data.redis.core.*; importorg.springframework.stereotype.Component; importjava.util.*; importjava.util.concurrent.TimeUnit; @SuppressWarnings(value={"unchecked","rawtypes"}) @Component @Slf4j publicclassRedisCache { @Autowired publicRedisTemplateredisTemplate; @Autowired privateStringRedisTemplatestringRedisTemplate; /** *缓存基本的对象,Integer、String、实体类等 * *@paramkey缓存的键值 *@paramvalue缓存的值 */ publicvoidsetCacheObject(finalStringkey,finalTvalue) { redisTemplate.opsForValue().set(key,value); } /** *缓存基本的对象,Integer、String、实体类等 * *@paramkey缓存的键值 *@paramvalue缓存的值 *@paramtimeout时间 *@paramtimeUnit时间颗粒度 */ public voidsetCacheObject(finalStringkey,finalTvalue,finalIntegertimeout,finalTimeUnittimeUnit) { redisTemplate.opsForValue().set(key,value,timeout,timeUnit); } /** *设置有效时间 * *@paramkeyRedis键 *@paramtimeout超时时间 *@returntrue=设置成功;false=设置失败 */ publicbooleanexpire(finalStringkey,finallongtimeout) { returnexpire(key,timeout,TimeUnit.SECONDS); } publicbooleanhasKey(finalStringkey) { returnBoolean.TRUE.equals(redisTemplate.hasKey(key)); } /** *设置有效时间 * *@paramkeyRedis键 *@paramtimeout超时时间 *@paramunit时间单位 *@returntrue=设置成功;false=设置失败 */ publicbooleanexpire(finalStringkey,finallongtimeout,finalTimeUnitunit) { returnredisTemplate.expire(key,timeout,unit); } /** *获得缓存的基本对象。 * *@paramkey缓存键值 *@return缓存键值对应的数据 */ public TgetCacheObject(finalStringkey) { ValueOperations operation=redisTemplate.opsForValue(); returnoperation.get(key); } /** *删除单个对象 * *@paramkey */ publicbooleandeleteObject(finalStringkey) { returnredisTemplate.delete(key); } /** *删除集合对象 * *@paramcollection多个对象 *@return */ publiclongdeleteObject(finalCollectioncollection) { returnredisTemplate.delete(collection); } /** *缓存List数据 * *@paramkey缓存的键值 *@paramdataList待缓存的List数据 *@return缓存的对象 */ public longsetCacheList(finalStringkey,finalList dataList) { Longcount=redisTemplate.opsForList().rightPushAll(key,dataList); returncount==null?0:count; } /** *获得缓存的list对象 * *@paramkey缓存的键值 *@return缓存键值对应的数据 */ public List getCacheList(finalStringkey) { returnredisTemplate.opsForList().range(key,0,-1); } /** *缓存Set * *@paramkey缓存键值 *@paramdataSet缓存的数据 *@return缓存数据的对象 */ public BoundSetOperations setCacheSet(finalStringkey,finalSet dataSet) { BoundSetOperations setOperation=redisTemplate.boundSetOps(key); Iterator it=dataSet.iterator(); while(it.hasNext()) { setOperation.add(it.next()); } returnsetOperation; } /** *获得缓存的set * *@paramkey *@return */ public Set getCacheSet(finalStringkey) { returnredisTemplate.opsForSet().members(key); } /** *缓存Map * *@paramkey *@paramdataMap */ public voidsetCacheMap(finalStringkey,finalMap dataMap) { if(dataMap!=null){ redisTemplate.opsForHash().putAll(key,dataMap); } } /** *获得缓存的Map * *@paramkey *@return */ public Map getCacheMap(finalStringkey) { returnredisTemplate.opsForHash().entries(key); } /** *往Hash中存入数据 * *@paramkeyRedis键 *@paramhKeyHash键 *@paramvalue值 */ public voidsetCacheMapValue(finalStringkey,finalStringhKey,finalTvalue) { redisTemplate.opsForHash().put(key,hKey,value); } /** *获取Hash中的数据 * *@paramkeyRedis键 *@paramhKeyHash键 *@returnHash中的对象 */ public TgetCacheMapValue(finalStringkey,finalStringhKey) { HashOperations opsForHash=redisTemplate.opsForHash(); returnopsForHash.get(key,hKey); } /** *删除Hash中的数据 * *@paramkey *@paramhkey */ publicvoiddelCacheMapValue(finalStringkey,finalStringhkey) { HashOperationshashOperations=redisTemplate.opsForHash(); hashOperations.delete(key,hkey); } /** *获取多个Hash中的数据 * *@paramkeyRedis键 *@paramhKeysHash键集合 *@returnHash对象集合 */ public List getMultiCacheMapValue(finalStringkey,finalCollection
「配置文件」
如果要实现其他方式的话只需要实现下RequestManyStrategy模板方法,然后编写自己的校验逻辑就可以。
以上代码已经上传到github :https://github.com/Lumos-i/tools-and-frameworks
结语
大学过的可真快,转眼就大三了,自己的技术还是不行,跟别人的差距还有很大距离,希望自己能在有限的时间里学到更多有用的知识,同时也希望在明年的这个时候可以坐在办公室里敲代码。突然想到高中时中二的一句话“听闻少年二字,应与平庸相斥”,谁不希望这样呢,奈何身边大佬太多,现在只能追赶别人的脚步。。。
审核编辑:黄飞
-
接口
+关注
关注
33文章
8575浏览量
151015 -
数据库
+关注
关注
7文章
3794浏览量
64360 -
Redis
+关注
关注
0文章
374浏览量
10871
原文标题:策略模式实现接口的幂等性校验
文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
相关推荐
评论