背景
表单是我们实际中经常遇到的场景,在web环境下重复提交是不可避免的,一个完善的系统对表单重复提交是需要做一定处理的,包括前端和后端都需要做处理。
写这篇文章的实际情况就是在小程序中遇到了表单重复提交的问题,导致数据库对同一用户生成了多条相同记录。
当然实现方式有多种,本文只是其中的一种,基于AOP+本地缓存的一种实现。
作为记录,也希望能帮到需要的朋友。
实现思路
- 编写注解用于标识哪些controller或者哪些接口需要做防重处理。
- 编写AOP用于对指定注解标识的接口进行切面处理
- AOP中主要做的就是先根据当前请求组装唯一标识作为key,判断该key是否存在于缓存中
- 如果不存在则保存在缓存中,同时设置过期时间,继续完成请求
- 如果存在则说明是重复请求,进而可以抛出异常或者放弃该请求
实现过程
说明:
1. 上面实现思路中提到的比较重要的一个环节就是根据请求组装出唯一标识,为便于测试本文我们使用请求参数中携带的用户id和请求的url组装,过期时间我们设置为3s,也就是3s内同一用户对于的多次请求会被视为一次请求
2. 当然也可以根据实际情况调整,只需要保证唯一即可,主要就是为了表达这是同一个用户同一个客户端对同一个url的短时间内的多次请求。
3. 本地缓存我们使用号称本地缓存之王的Caffeine
新建springboot项目
包结构
- com.zjtx.tech
- cache 缓存相关类及配置
- CacheConfig.java
- CaffeineCaches.java
- CaffeineCache.java
- controller 提供外部访问接口
- TestController.java
- aop 切面注解及实现
- annotation
- HandleRepeatSubmit.java
- RepeatSubmitAspect.java
- annotation
- cache 缓存相关类及配置
pom文件中添加相关jar包
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.13</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.zjtx.tech.demo</groupId>
<artifactId>repeat-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>repeat-demo</name>
<description>repeat-demo</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.3</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.1</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
</dependencies>
</project>
说明如下:
-
需要注意jdk版本和caffeine版本的对应关系,我这里用的是java8,选择的caffeine版本是2.9.3
-
需要引入aspectj相关jar包
定义缓存bean
package com.zjtx.tech.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.concurrent.TimeUnit;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
Collection<CaffeineCache> cacheList = new ArrayList<>();
for (CaffeineCaches ca : CaffeineCaches.values()) {
Cache<Object, Object> cache = Caffeine.newBuilder().recordStats()
.expireAfterWrite(ca.getTtl(), TimeUnit.SECONDS)
.maximumSize(ca.getMaxSize())
.build();
cacheList.add(new CaffeineCache(ca.name(), cache));
}
manager.setCaches(cacheList);
return manager;
}
}
涉及到的相关类
CaffeineCaches.java
package com.zjtx.tech.cache;
public enum CaffeineCaches {
baseUsers,
userRequestUrls(3L, 1000);
private int maxSize = 1000; //默认最大缓存数量
private Long ttl = 3600L; //默认过期时间(单位:秒)
CaffeineCaches() {
}
CaffeineCaches(Long ttl, int maxSize) {
this.ttl = ttl;
this.maxSize = maxSize;
}
public int getMaxSize() {
return maxSize;
}
public Long getTtl() {
return ttl;
}
}
CaffeineCache.java, 继承自AbstractValueAdaptingCache, 主要是为了实现Cache接口
package com.zjtx.tech.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.core.serializer.support.SerializationDelegate;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Function;
public class CaffeineCache extends AbstractValueAdaptingCache {
private final String name;
private final Cache<Object, Object> store;
@Nullable
private final SerializationDelegate serialization;
public CaffeineCache(String name, Cache<Object, Object> store) {
this(name, store, true, null);
}
public CaffeineCache(String name, Cache<Object, Object> store, boolean allowNullValues) {
this(name, store, allowNullValues, null);
}
protected CaffeineCache(String name, Cache<Object, Object> store,
boolean allowNullValues, @Nullable SerializationDelegate serialization) {
super(allowNullValues);
Assert.notNull(name, "Name must not be null");
Assert.notNull(store, "Store must not be null");
this.name = name;
this.store = store;
this.serialization = serialization;
}
@Override
public final String getName() {
return this.name;
}
@Override
public final Cache<Object, Object> getNativeCache() {
return this.store;
}
@Override
@Nullable
protected Object lookup(Object key) {
return this.store.getIfPresent(key);
}
@SuppressWarnings("unchecked")
@Override
@Nullable
public <T> T get(Object key, Callable<T> valueLoader) {
return (T) this.store.get(key, o -> {
try {
return toStoreValue(valueLoader.call());
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
@Override
public void put(Object key, @Nullable Object value) {
this.store.put(key, toStoreValue(value));
}
@Override
@Nullable
public ValueWrapper putIfAbsent(Object key, @Nullable Object value) {
this.store.put(key, toStoreValue(value));
return toValueWrapper(this.store.getIfPresent(key));
}
@Override
public void evict(Object key) {
this.store.invalidate(key);
}
@Override
public boolean evictIfPresent(Object key) {
if (this.store.getIfPresent(key) != null) {
this.store.invalidate(key);
return true;
}
return false;
}
@Override
public void clear() {
this.store.invalidateAll();
}
@Override
public boolean invalidate() {
this.store.cleanUp();
return true;
}
@Override
protected Object toStoreValue(@Nullable Object userValue) {
Object storeValue = super.toStoreValue(userValue);
if (this.serialization != null) {
try {
return this.serialization.serializeToByteArray(storeValue);
} catch (Throwable ex) {
throw new IllegalArgumentException("Failed to serialize cache value '" + userValue +
"'. Does it implement Serializable?", ex);
}
} else {
return storeValue;
}
}
@Override
protected Object fromStoreValue(@Nullable Object storeValue) {
if (storeValue != null && this.serialization != null) {
try {
return super.fromStoreValue(this.serialization.deserializeFromByteArray((byte[]) storeValue));
} catch (Throwable ex) {
throw new IllegalArgumentException("Failed to deserialize cache value '" + storeValue + "'", ex);
}
} else {
return super.fromStoreValue(storeValue);
}
}
}
自定义注解
package com.zjtx.tech.aop.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface HandleRepeatSubmit {
}
实现AOP
package com.zjtx.tech.aop;
import com.zjtx.tech.cache.CaffeineCaches;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import java.util.Date;
@Component
@Aspect
public class HandleRepeatSubmitAspect {
@Autowired
private CacheManager caffeineCacheManager;
@Autowired
private HttpServletRequest request;
@Pointcut("@annotation(com.zjtx.tech.aop.annotation.HandleRepeatSubmit)")
public void logPointCut() {
}
@Around("logPointCut()")
public Object around(ProceedingJoinPoint point) throws Throwable {
Cache cache = caffeineCacheManager.getCache(CaffeineCaches.userRequestUrls.name());
String url = request.getRequestURI();
String userId = request.getParameter("userId");
Object result = null;
if (!StringUtils.isEmpty(userId)) {
String key = userId + "_" + url;
if (cache.get(key) == null) {
//这里的key是userId_url,value是当前时间
cache.put(key, new Date().getTime());
result = point.proceed();
} else {
System.out.println("检测到重复提交数据,将忽略请求....");
}
} else { // 匿名
result = point.proceed();
}
return result;
}
}
添加测试接口
package com.zjtx.tech.controller;
import com.zjtx.tech.aop.annotation.HandleRepeatSubmit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
@RestController
@RequestMapping("repeat")
public class TestController {
@GetMapping("test")
@HandleRepeatSubmit
public String test(String userId, String aa){
return userId + "_" + aa + "_" + new Date().getTime();
}
}
测试
启动springboot项目,默认端口为8080
浏览器访问 http://localhost:8080/repeat/test?userId=123
短时间内多次访问,可以看到后台打印重复请求日志,如此便实现了防止重复提交的目标。
小结
本文记录了基于AOP+本地缓存实现防止表单重复提交的过程,主要思路就是根据当前请求生成一个唯一key保存到本地缓存中,请求再次来的时候先判断生成的key是否存在于缓存中,存在则认为是重复请求。
作为记录也希望能帮助到需要的朋友,针对以上内容有任何问题或者建议欢迎留言评论~~~
创作不易,欢迎一键三连~~~