场景一:控制表单重复提交
防重提交有很多方案,从前端的按钮置灰,到后端synchronize锁、lock锁、借助redis语法实现简单锁、redis+lua分布式锁、redisson分布式锁,再到db的悲观锁、乐观锁、借助表唯一索引等等都可以实现防重提交,以保证数据的安全性。
这篇文章我们介绍其中一种方案–借助redis语法实现简单锁,最终实现防重提交。
背景
我们项目中,为了控制表单重复提交问题,会在点击页面按钮(向后端发起业务请求)后就会置灰按钮,直到后端响应后解除按钮置灰。通过按钮置灰来防止重启提交问题。但postman、jmeter和其他服务调用(绕过前端页面)呢?所以后端接口也要根据控制表单重复提交的问题。
后端代码可以在2个位置做控制:
一是放在gateway网关做:
- 好处是只在一个地方加上控制代码,就可以控制所有接口的重复提交问题。
- 坏处是控制的范围太广(比如查询接口无需控制,控制了反而多余)、定义重复提交的时间段不能灵活调整。
二是放在aop切面做:
- 好处是只有需要的地方才会被控制(哪里需要引用一下自定义注解即可),另外也能灵活调整定义重复提交的时间段(自定义注解里定义时间字段开放给使用者填写)。
- 坏处是每个需要控制的地方都要加注解,会有侵入性和一定的工作量。
实现代码
1、添加自定义注解
package com.xxx.annotations; import java.lang.annotation.*; /** * 自定义注解防止表单重复提交 * * @author wanglingqiang * @date 2023/9/6 10:11 */ @target(elementtype.method) @retention(retentionpolicy.runtime) @documented public @interface repeatsubmit { /** * 过期时间,单位毫秒 */ long expiretime() default 500l; }
2、添加aop切面
package com.xxx.aop; import com.xxx.annotations.repeatsubmit; import com.xxx.exception.serviceexception; import lombok.extern.slf4j.slf4j; import org.aspectj.lang.proceedingjoinpoint; import org.aspectj.lang.annotation.around; import org.aspectj.lang.annotation.aspect; import org.aspectj.lang.annotation.pointcut; import org.aspectj.lang.reflect.methodsignature; 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 javax.annotation.resource; import javax.servlet.http.httpservletrequest; import java.lang.reflect.method; import java.util.concurrent.timeunit; /** * 防止表单重复提交切面 * * @author wanglingqiang * @date 2023/9/6 10:13 */ @slf4j @aspect @component public class repeatsubmitaspect { private static final string key_prefix = "repeat_submit:"; @resource private redistemplate redistemplate; @pointcut("@annotation(com.xxx.annotations.repeatsubmit)") public void repeatsubmit() {} @around("repeatsubmit()") public object around(proceedingjoinpoint joinpoint) throws throwable { //joinpoint获取方法对象 method method = ((methodsignature) joinpoint.getsignature()).getmethod(); //获取方法上的@repeatsubmit注解 repeatsubmit annotation = method.getannotation(repeatsubmit.class); //获取httpservletrequest对象,以获取请求uri servletrequestattributes requestattributes = (servletrequestattributes) requestcontextholder.getrequestattributes(); httpservletrequest request = requestattributes.getrequest(); string uri = request.getrequesturi(); //拼接redis的key,这里只是简单根据uri来判断是否重复提交。可以根据自己业务调整,比如根据用户id或者请求token等 string cachekey = key_prefix.concat(uri); boolean flag = null; try { //借助setifabsent(),key不存在才能设值成功 flag = redistemplate.opsforvalue().setifabsent(cachekey, "", annotation.expiretime(), timeunit.milliseconds); } catch (exception e) { //如果redis不可用,则打印日志记录,但依然对请求放行 log.error("", e); return joinpoint.proceed(); } //redis可用的情况,如果flag=true说明单位时间内这是第一次请求,放行 if (flag) { return joinpoint.proceed(); } else { //进入else说明单位时间内进行了多次请求,则拦截请求并提示稍后重试 throw new serviceexception("系统繁忙,请稍后重试"); } } }
这里利用redistemplate的setifabsent()实现的,如果存在就不能set成功,set的同时设置过期时间,可以是用使用默认,也可以自己根据业务调整。
另外,cachekey的定义,也可以根据自己的需要去调整,比如根据当前登录用户的userid、当前登录的token等。
3、使用
@slf4j @restcontroller @requestmapping("/user") public class usercontroller { @repeatsubmit @postmapping public ajaxresult add(@validated @requestbody sysuser user) { //.... }
场景二:控制接口调用频率
背景
忘记密码后通过发送手机验证码找回密码的场景。因为每发一条短信都需要收费,所以要控制发短信的频率。
比如,同一个手机号在3分钟内只能发送3次短信,超过3次后则提示用户“短信发送过于频繁,请10分钟后再试”。
实现代码
@slf4j @restcontroller @requestmapping("/sms") public class smscontroller { @resource private ismsservice smsservice; @resource public redistemplate redistemplate; @postmapping("/sendvalidcode") public result sendvalidcode(@requestbody @valid smsdto smsdto) { //验证手机号格式 checkphonenumber(smsdto.getphonenumber()); //...其他验证 //拼接redis的key(key为手机号,以控制一个手机号有限时间内容发送的次数) string cachekey = "sms:code:resetpwd:"+smsdto.getphonenumber(); //验证发送短信次数,超过则拦截(阈值是3次,超时时间是3分钟,重试时间是10分钟) checksendcount(cachekey, threshold, timeout, retry_time); return smsservice.sendmsg(smsdto); } /** * 验证发送短信次数,超过则拦截 * 该方法用lua脚本替换实现更好 */ private void checksendcount(string cachekey, long threshold, long timeout, string retrytime) { //首先进方法就先+1 long count = redistemplate.opsforvalue().increment(cachekey); //然后比较次数,是否超过阈值 if (count > threshold) { //超过则设置过期时间为10分钟,并提示10分钟后重试 redistemplate.expire(cachekey, 10l, timeunit.minutes); throw new serviceexception("短信发送过于频繁,请" + retrytime + "分钟后再试"); } else { //没超过3次,则累加上这一次 redistemplate.expire(cachekey, timeout, timeunit.minutes); } } }
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持代码网。
发表评论