文章目录
1.简介
在一些表单提交操作的时候会存在用户多次点击button触发提交事件的场景(针对异步请求场景)。
在客户端可以针对重复提交添加状态值判断,如下:
- 1.声明一个loading变量,当触发submit事件的时候判断loading的值是否为true,为true则不进行操作
- 2.如果判断的loading值为false,则发送请求提交数据到后台保存。
- 3.当ajax异步处理成功的时候,把loading改为false(类似于释放锁);
var loading=false;
function submit(){
if(loading){
return;
}
loading=true;
$ajax.post("/api/user",user,function((res) => {
loading=false;
}
});
}
众所周知,数据验证和表单防重复提交等逻辑在前端可以做判断,但是后台还是会再请求做一次校验。
2.后端防表单重复提交设计实现
2.1.引入依赖
pom.xml文件内容如下
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--redisson 分布式防表单提交用到的,单节点部署可以使用ecache基于内存的缓存-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.17.6</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.83</version>
</dependency>
</dependencies>
2.2.添加redis配置
application.yml文件配置如下
spring:
redis:
host: 127.0.0.1
port: 6379
jedis:
pool:
max-active: 8
2.3.添加需要使用的工具类
MD5Util 工具类拥有对请求数据加密成md5(节省存储空间)
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MD5Util {
public static String toMD5(String plainText) {
String value = "";
if (plainText == null){
plainText = "";
}
try {
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(plainText.getBytes());
byte b[] = md.digest();
int i;
StringBuffer buf = new StringBuffer("");
for (int offset = 0; offset < b.length; offset++) {
i = b[offset];
if (i < 0){
i += 256;
}
if (i < 16){
buf.append("0");
}
buf.append(Integer.toHexString(i));
}
value = buf.toString();
// 24));// 16位的加密
} catch (NoSuchAlgorithmException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
return value;
}
}
SpringUtil(普通类调用Spring bean对象使用的工具类)
@Component
public class SpringUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext = null;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
if (SpringUtil.applicationContext == null) {
SpringUtil.applicationContext = applicationContext;
}
}
/**
* 获取applicationContext
*/
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
/**
* 通过name获取 Bean.
*/
public static Object getBean(String name) {
return getApplicationContext().getBean(name);
}
/**
* 通过class获取Bean.
*/
public static <T> T getBean(Class<T> clazz) {
return getApplicationContext().getBean(clazz);
}
/**
*通过name,以及Clazz返回指定的Bean
*/
public static <T> T getBean(String name, Class<T> clazz) {
return getApplicationContext().getBean(name, clazz);
}
}
RedisUtils 操作redis缓存工具类
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RedisUtils {
public static NameMapper getNameMapper() {
Config config = getClient().getConfig();
if (config.isClusterConfig()) {
return config.useClusterServers().getNameMapper();
}
return config.useSingleServer().getNameMapper();
}
/**
* 缓存基本的对象,Integer、String、实体类等
*
* @param key 缓存的键值
* @param value 缓存的值
* @param duration 时间
*/
public static <T> void setCacheObject(final String key, final T value, final Duration duration) {
RBatch batch = getClient().createBatch();
RBucketAsync<T> bucket = batch.getBucket(key);
bucket.setAsync(value);
bucket.expireAsync(duration);
batch.execute();
}
/**
* 删除单个对象
* @param key 缓存的键值
*/
public static boolean deleteObject(final String key) {
return getClient().getBucket(key).delete();
}
/**
* 检查redis中是否存在key
* @param key 缓存的键值
*/
public static Boolean hasKey(String key) {
RKeys rKeys = getClient().getKeys();
return rKeys.countExists(getNameMapper().map(key)) > 0;
}
public static RedissonClient getClient() {
return Lazy.CLIENT;
}
/**
* 使用懒加载方式实例化RedissongetClient()客户端工具
*/
private static class Lazy {
private static final RedissonClient CLIENT = SpringUtil.getBean(RedissonClient.class);
}
}
2.4.添加防重复提交注解
/**
* @author Dominick Li
* @description 防止重复提交
**/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
/**
* 时间单位,默认为秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 间隔时间,默认为3秒
*/
int interval() default 3;
}
2.5.使用Aop实现限流逻辑
使用@Around环绕通知实现逻辑
- 1.根据限流注解配置的限流的时间获取缓存存活时间
- 2.把请求的接口地址路径+用户token+请求参数作为缓存的Key
- 3.判断缓存是否存在,如果存在则返回错误提示信息
- 4.如缓存不存在则设置当前key的缓存数据,然后执行业务逻辑代码
/**
* 防止重复提交AOP切面实现类
*/
@Slf4j
@Aspect
@Component
public class RepeatSubmitAspect {
@Pointcut("@annotation(com.ljm.boot.redisson.annotation.RepeatSubmit)")
public void repeatSubmitPointCut() {
}
@Around("repeatSubmitPointCut()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
Method method = currentMethod(proceedingJoinPoint);
//获取到方法的注解对象
RepeatSubmit repeatSubmit = method.getAnnotation(RepeatSubmit.class);
long interval = 1000;
if (repeatSubmit.interval() > 0) {
interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval());
}
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
String params = argsToString(proceedingJoinPoint.getArgs());
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 用户的唯一标识
String token = request.getHeader("token");
// 唯一标识(url + token + params)
String submitKey = MD5Util.toMD5(url + "_" + token + ":" + params);
boolean flag = false;
//判断缓存中是否有此key
if (RedisUtils.hasKey(submitKey)) {
log.info("key={},interval={},重复提交", submitKey, interval);
} else {
//如果没有表示不是重复提交并设置key存活的缓存时间
RedisUtils.setCacheObject(submitKey, "", Duration.ofMillis(interval));
flag = true;
System.out.println("非重复提交");
}
if (flag) {
Object result = null;
try {
result = proceedingJoinPoint.proceed();
} catch (Throwable e) {
/*异常通知方法*/
log.error("异常通知方法>目标方法名{},异常为:{}", method.getName(), e);
} finally {
RedisUtils.deleteObject(submitKey);
}
return result;
} else {
return "{'code':500,'msg':'重复提交'}";
}
}
/**
* 根据切入点获取执行的方法
*/
private Method currentMethod(JoinPoint joinPoint) {
String methodName = joinPoint.getSignature().getName();
//获取目标类的所有方法,找到当前要执行的方法
Method[] methods = joinPoint.getTarget().getClass().getMethods();
Method resultMethod = null;
for (Method method : methods) {
if (method.getName().equals(methodName)) {
resultMethod = method;
break;
}
}
return resultMethod;
}
/**
* 参数拼装
*/
private String argsToString(Object[] paramsArray) {
StringBuilder params = new StringBuilder();
if (paramsArray != null && paramsArray.length > 0) {
for (Object o : paramsArray) {
if (!ObjectUtils.isEmpty(o) && !isFilterObject(o)) {
try {
params.append(JSONObject.toJSONString(o)).append(" ");
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
return params.toString().trim();
}
/**
* 判断是否是需要过滤的对象
*/
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.entrySet()) {
Map.Entry entry = (Map.Entry) value;
return entry.getValue() instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
}
3.测试
3.1.添加需要限流的接口
@RestController
public class TestController {
/**
* 测试 间隔时间2秒
*/
@GetMapping("/test/{id}")
@RepeatSubmit(interval = 2)
public String test(@PathVariable Integer id) throws Exception {
Thread.sleep(1000L);
return "success";
}
/**
* 测试 间隔时间1500毫秒
*/
@GetMapping("/test")
@RepeatSubmit(interval = 1500, timeUnit = TimeUnit.MILLISECONDS)
public String test2(@PathVariable Integer id) throws Exception {
Thread.sleep(1000L);
return "success";
}
}
3.2.模拟表单重复提交操作
public class TestRepeatSubmit {
public static void main(String[] args) throws Exception {
///设置线程池最大执行20个线程并发执行任务
int threadSize = 20;
//AtomicInteger通过CAS操作能保证统计数量的原子性
AtomicInteger successCount = new AtomicInteger(0);
CountDownLatch downLatch = new CountDownLatch(20);
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(threadSize);
for (int i = 0; i < threadSize; i++) {
int finalI = i;
fixedThreadPool.submit(() -> {
RestTemplate restTemplate = new RestTemplate();
//String str = restTemplate.getForObject("http://localhost:8032/test/"+i, String.class);
String str = restTemplate.getForObject("http://localhost:8032/test/1", String.class);
if ("success".equals(str)) {
successCount.incrementAndGet();
}
System.out.println(str);
downLatch.countDown();
});
//模拟网络传输时间
Thread.sleep(100);
}
//等待所有线程都执行完任务
downLatch.await();
fixedThreadPool.shutdown();
System.out.println("总共有" + successCount.get() + "个线程请求成功!");
}
}
启动web服务后,
1.访问地址使用http://localhost:8032/test/1,运行main函数结果如下,注解配置的间隔时间内相同参数的请求会被拒绝。
2.访问地址使用http://localhost:8032/test/i,运行main函数结果如下:
由于i是不同的值,所以20个请求都能够正常请求。
4.项目配套代码
创作不易,要是觉得我写的对你有点帮助的话,麻烦在gitee上帮我点下 Star
【SpringBoot框架篇】其它文章如下,后续会继续更新。
- 1.搭建第一个springboot项目
- 2.Thymeleaf模板引擎实战
- 3.优化代码,让代码更简洁高效
- 4.集成jta-atomikos实现分布式事务
- 5.分布式锁的实现方式
- 6.docker部署,并挂载配置文件到宿主机上面
- 7.项目发布到生产环境
- 8.搭建自己的spring-boot-starter
- 9.dubbo入门实战
- 10.API接口限流实战
- 11.Spring Data Jpa实战
- 12.使用druid的monitor工具查看sql执行性能
- 13.使用springboot admin对springboot应用进行监控
- 14.mybatis-plus实战
- 15.使用shiro对web应用进行权限认证
- 16.security整合jwt实现对前后端分离的项目进行权限认证
- 17.使用swagger2生成RESTful风格的接口文档
- 18.使用Netty加websocket实现在线聊天功能
- 19.使用spring-session加redis来实现session共享
- 20.自定义@Configuration配置类启用开关
- 21.对springboot框架编译后的jar文件瘦身
- 22.集成RocketMQ实现消息发布和订阅
- 23.集成smart-doc插件零侵入自动生成RESTful格式API文档
- 24.集成FastDFS实现文件的分布式存储
- 25.集成Minio实现文件的私有化对象存储
- 26.集成spring-boot-starter-validation对接口参数校验
- 27.集成mail实现邮件推送带网页样式的消息
- 28.使用JdbcTemplate操作数据库
- 29.Jpa+vue实现单模型的低代码平台
- 30.使用sharding-jdbc实现读写分离和分库分表
- 31.基于分布式锁或xxx-job实现分布式任务调度
- 32.基于注解+redis实现表单防重复提交
- 33.优雅集成i18n实现国际化信息返回
- 34.使用Spring Retry完成任务的重试