【SpringBoot框架篇】32.基于注解+redis实现表单防重复提交

文章目录

  • 1.简介
  • 2.后端防表单重复提交设计实现
    • 2.1.引入依赖
    • 2.2.添加redis配置
    • 2.3.添加需要使用的工具类
    • 2.4.添加防重复提交注解
    • 2.5.使用Aop实现限流逻辑
  • 3.测试
    • 3.1.添加需要限流的接口
    • 3.2.模拟表单重复提交操作
  • 4.项目配套代码

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.bootgroupId>
            <artifactId>spring-boot-starter-webartifactId>
        dependency>

        
        <dependency>
            <groupId>org.redissongroupId>
            <artifactId>redisson-spring-boot-starterartifactId>
            <version>3.17.6version>
        dependency>

        <dependency>
            <groupId>org.aspectjgroupId>
            <artifactId>aspectjweaverartifactId>
            <version>1.9.4version>
        dependency>
        
        <dependency>
            <groupId>org.projectlombokgroupId>
            <artifactId>lombokartifactId>
        dependency>
        
        <dependency>
            <groupId>com.alibabagroupId>
            <artifactId>fastjsonartifactId>
            <version>1.2.83version>
        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函数结果如下,注解配置的间隔时间内相同参数的请求会被拒绝。
【SpringBoot框架篇】32.基于注解+redis实现表单防重复提交_第1张图片
2.访问地址使用http://localhost:8032/test/i,运行main函数结果如下:

【SpringBoot框架篇】32.基于注解+redis实现表单防重复提交_第2张图片
由于i是不同的值,所以20个请求都能够正常请求。

4.项目配套代码

gitee代码地址

创作不易,要是觉得我写的对你有点帮助的话,麻烦在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完成任务的重试

你可能感兴趣的:(springBoot,spring,boot,redis,java)