前言
项目中经常会出现重复提交的问题,而接口幂等性也一直以来是做任何项目都要关注的疑难点,网上可以查到非常多的方案,我归纳了几点如下:
1)、数据库层面,对责任字段设置唯一索引,这是最直接有效的方式,不好的地方就是一旦触发就会在服务端抛数据库相关异常;
2)、代码层面,增加业务逻辑判断,先查询一遍若没有才插入,这也是最容易想到的方式,反正写上就对了,不好的地方就是分布式场景下依然避免不了问题;
3)、前端层面,对于触发事件的操作比如按钮等,最好点击过后都设置几秒的置灰时间,能很大程度上解决恶意提交的问题。
以上几点经常在项目中结合使用,不过有一种更通用的方案,就是自定义注解,写一个专门处理这类问题的注解,之后在有需要用到的接口上直接加上这个注解即可,十分方便。
项目
1、介绍
本人所在公司是互联网行业,所以平常相当繁忙,但依然在这几天晚上得空之余咬牙爆肝做好了一个案例,网上有非常多相关的文章我也看过,但有些案例过于臃肿,我认为一个好的案例一定是轻巧、精简、清晰、一看就懂的迷你项目,我便是朝着这个方向来做的。
通过这个小项目或者叫小案例,你可以学到这些技术:
1)、SpringBoot2.6版本整合SpringDataRedis;
2)、SpringBoot2.6版本整合Redisson;
3)、SpringBoot2.6版本整合MybatisPlus最新版;
4)、学会自定义一个注解;
5)、学会防重复提交注解的核心实现并可灵活设置延迟时间。
2、获取源码方式
关注公众号:【Java分享客栈】,回复“防重”二字,即可获取源码链接。
若不想关注公众号,无需源码的辅助,可以直接观看下面图文并茂的实现步骤_。
实现步骤
1、引入依赖
完整依赖如下:
使用的SpringBoot版本3.0之前最新的发布版本2.6.3,3.0版本要求JDK17,所以短时间内不会在行业中普遍的,企业中成熟的版本依然是2.x。
<?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.6.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>resubmit</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>resubmit</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- spring web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring aop 实现自定义注解用到 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- spring processor 加载项目配置使用,也可以不用,但IDEA配置类顶端会有警告 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- spring jdbc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql 不填写版本号默认是8.0以上 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- springData redis 不填写版本号默认和springboot版本一致 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redission 注意版本号和springData-redis要对应 这里26对应springData-redis的2.6版本 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-data-26</artifactId>
<version>3.16.8</version>
</dependency>
<!-- fastjson 解析json用到,也可以换成自己喜欢用的 -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.79</version>
</dependency>
<!-- mybatis-plus 参考官网,目前是最新版 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<!-- 代码生成器 mybatisPlus自带的生成器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.1</version>
</dependency>
<!-- freemarker模板生成器 引入代码生成器需要 -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>
<!-- swagger 因为mybatisPlus代码生成器会自带swagger的注解 -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.7.0</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.7.0</version>
</dependency>
<!-- lombok 因为mybatisPlus代码生成器会自带lombok的注解 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2、配置application.yml
这里注意,生产环境项目是要区分application.yml、application-dev.yml、application-test.yml、application-prod.yml的,这里只是实现功能的小案例,就只写了这一个。
# 端口,改成自己的。
server:
port: 8888
# 数据源,使用hikari,现在一般项目都是默认的这个数据源了,更详细的配置可以百度。
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/resubmit_demo?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&useSSL=false
username: root # 换成自己数据库的账号
password: 123456 # 换成自己数据库的密码
# redis配置,换成自己的。
redis:
database: 11
host: 192.168.1.197
port: 6379
password: 123456
jedis:
pool:
max-active: 1000
max-wait: -1ms
max-idle: 50
min-idle: 1
# redission配置,这里直接读取的redis变量.
redisson:
singleserverconfig:
address: "redis://${spring.redis.host}:${spring.redis.port}"
password: ${spring.redis.password}
database: ${spring.redis.database}
3、编写配置类
1)、redis配置类
package com.example.resubmit.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.*;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* <p>
* redis配置类
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
@Configuration
public class RedisConfig {
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
StringRedisTemplate redisTemplate = new StringRedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// key采用String的序列化方式
template.setKeySerializer(stringRedisSerializer);
// hash的key也采用String的序列化方式
template.setHashKeySerializer(stringRedisSerializer);
// value序列化方式采用jackson
template.setValueSerializer(jackson2JsonRedisSerializer);
// hash的value序列化方式采用jackson
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
}
2)、Redisson配置类
package com.example.resubmit.config;
import com.fasterxml.jackson.core.JsonProcessingException;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.redisson.spring.data.connection.RedissonConnectionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
/**
* <p>
* redission配置类
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
@Configuration
@ConfigurationProperties(prefix = "redisson.singleserverconfig")
public class RedissonSpringDataConfig {
private static final Logger log = LoggerFactory.getLogger(RedissonSpringDataConfig.class);
private String address;
private int database;
private String password;
@Bean
public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redisson) {
return new RedissonConnectionFactory(redisson);
}
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws JsonProcessingException {
log.debug("[RedissonSpringDataConfig][redisson]>>>> address: {}, database: {}, password: {}", address, database, password);
Config config = new Config();
SingleServerConfig sconfig= config.useSingleServer()
.setAddress(address)
.setDatabase(database);
// 如果redis设置了密码,这里不设置密码就会报“org.redisson.client.RedisAuthRequiredException: NOAUTH Authentication required”错误。
if(StringUtils.hasText(password)){
sconfig.setPassword(password);
}
return Redisson.create(config);
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public int getDatabase() {
return database;
}
public void setDatabase(int database) {
this.database = database;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
4、MybatisPlus代码生成器
这个生成器我参考官网的做了优化和注释,一看就能明白。
package com.example.resubmit.generator;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;
import java.util.Collections;
/**
* @作者: 福隆苑居士,公众号:【Java分享客栈】
* @日期: 2022/2/8 20:51
* @描述: mybatis-plus代码生成器
*/
public class CodeGenerator {
private static final String url = "jdbc:mysql://localhost:3306/resubmit_demo?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf8&useSSL=false";
private static final String username = "root";
private static final String password = "123456";
private static final String outputDir = "D:\workspace\workspace_java\resubmit\src\main\java"; // entity、mapper、service、controller生成的目录地址,换成自己项目的。
private static final String xmlOutputDir = "D:\workspace\workspace_java\resubmit\src\main\resources\mapper"; // xxMapper.xml生成的目录地址,换成自己项目的。
public static void main(String[] args) {
FastAutoGenerator.create(url, username, password)
.globalConfig(builder -> {
builder.author("福隆苑居士,公众号:【9i分享客栈】") // 设置作者
.enableSwagger() // 开启 swagger 模式
.fileOverride() // 覆盖已生成文件
.outputDir(outputDir); // 指定输出目录
})
.packageConfig(builder -> {
builder.parent("com.example.resubmit") // 设置父包名,和自己项目的父包名一致即可。
.moduleName("") // 设置父包模块名,为空就会直接生成在父包名目录下。
.pathInfo(Collections.singletonMap(OutputFile.mapperXml, xmlOutputDir)); // 设置mapperXml生成路径
})
.strategyConfig(builder -> {
builder.addInclude("tb_user") // 设置需要生成的表名,多个用逗号隔开。
.addTablePrefix("t_", "tb_", "c_"); // 设置过滤表前缀,多个用逗号隔开。
})
.templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板
.execute();
}
}
5、定义响应实体
这个因人而定,可以自己定义,也可以直接用我的。
package com.example.resubmit.util;
import com.example.resubmit.enums.ResponseCodeEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
/**
* <p>
* 自定义响应结果
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
public class ResultEntity<T> {
private String code;
private String msg;
private T data;
public ResultEntity(){}
public ResultEntity(String code, String msg){
this.code = code;
this.msg = msg;
}
public ResultEntity(String code, String msg, T data){
this.code = code;
this.msg = msg;
this.data = data;
}
@JsonIgnore
public boolean isSuccess() {
return ResponseCodeEnum.SUCCESS.getCode().equals(this.getCode());
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public static ResultEntity fail(String code, String msg) {
return new ResultEntity(code, msg);
}
public static <T> ResultEntity fail(String code, String msg, T data) {
return new ResultEntity(code, msg, data);
}
public static ResultEntity ok(String code, String msg) {
return new ResultEntity(code, msg);
}
public static <T> ResultEntity ok(String code, String msg, T data) {
return new ResultEntity(code, msg, data);
}
public static ResultEntity ok(String msg) {
return new ResultEntity(ResponseCodeEnum.SUCCESS.getCode(), msg);
}
public static <T> ResultEntity ok(String msg, T data) {
return new ResultEntity(ResponseCodeEnum.SUCCESS.getCode(), msg, data);
}
public static ResultEntity fail(String msg) {
return new ResultEntity(ResponseCodeEnum.FAIL.getCode(), msg);
}
}
6、Redisson工具类
package com.example.resubmit.redission;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
/**
* <p>
* 加锁解锁工具类
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
@Component
public class RedisLock {
private static final Logger log = LoggerFactory.getLogger(RedisLock.class);
// todo 待优化,最好使用自定义的线程池,自定义工作队列和最大线程数。
private static final ScheduledExecutorService EXECUTOR_SERVICE = Executors.newScheduledThreadPool(4);
@Resource
private Redisson redisson;
/**
* Redission获取锁
*
* @param lockKey 锁名
* @param uuid 唯一标识
* @param delaySeconds 过期时间
* @param unit 单位
* @return 是否获取成功
*/
public boolean Rlock(String lockKey, final String uuid, long delaySeconds, final TimeUnit unit) {
RLock rLock = redisson.getLock(lockKey);
boolean success = false;
try {
// log.debug("===lock thread id is :{}", Thread.currentThread().getId());
success = rLock.tryLock(0, delaySeconds, unit);
} catch (InterruptedException e) {
log.error("[RedisLock][Rlock]>>>> 加锁异常: ", e);
}
return success;
}
/**
* Redission释放锁
*
* @param lockKey 锁名
*/
public void Runlock(String lockKey) {
RLock rLock = redisson.getLock(lockKey);
log.debug("[RedisLock][Rlock]>>>> {}, status: {} === unlock thread id is: {}", rLock.isHeldByCurrentThread(), rLock.isLocked(),
Thread.currentThread().getId());
rLock.unlock();
}
/**
* Redission延迟释放锁
*
* @param lockKey 锁名
* @param delayTime 延迟时间
* @param unit 单位
*/
public void delayUnlock(final String lockKey, long delayTime, TimeUnit unit) {
if (!StringUtils.hasText(lockKey)) {
return;
}
if (delayTime <= 0) {
Runlock(lockKey);
} else {
EXECUTOR_SERVICE.schedule(() -> Runlock(lockKey), delayTime, unit);
}
}
}
7、自定义注解
这里增加了延迟时间的属性,默认8秒。
package com.example.resubmit.redission;
import java.lang.annotation.*;
/**
* <p>
* 防重复提交注解
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface NotResubmit {
/**
* 延时时间 在延时多久后可以再次提交,默认8秒
* @return 秒
*/
int delaySeconds() default 8;
}
8、防重注解的实现
这里是实现防重注解的核心代码,这里append实体属性时用到了toString()方法,所以要求我们生成的实体对象一定要有toString()方法。
package com.example.resubmit.redission;
import com.example.resubmit.enums.ResponseCodeEnum;
import com.example.resubmit.util.ResultEntity;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import javax.annotation.Resource;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.util.concurrent.TimeUnit;
/**
* <p>
* 防重复提交注解的实现,使用AOP。
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
@Aspect
@Component
public class LockMethodAOP {
private static final Logger log = LoggerFactory.getLogger(LockMethodAOP.class);
@Resource
private RedisLock redisLock;
/**
* 这里注意,我的注解写在同一个包下所以没有包名,如果换自己的目录,要改为@annotation(com.xxx.NotResubmit)加上完整包名.
*/
@Around("execution(public * *(..)) && @annotation(NotResubmit)")
public Object interceptor(ProceedingJoinPoint pjp) throws Throwable {
// 获取到这个注解
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();
NotResubmit lock = method.getAnnotation(NotResubmit.class);
final String lockKey = generateKey(pjp);
// 上锁
final boolean success = redisLock.Rlock(lockKey, null, lock.delaySeconds(), TimeUnit.SECONDS);
if (!success) {
// 这里也可以改为自己项目自定义的异常抛出
return ResponseEntity.badRequest().body(ResultEntity.fail(ResponseCodeEnum.FAIL.getCode(), "操作太频繁"));
}
return pjp.proceed();
}
private String generateKey(ProceedingJoinPoint pjp) {
StringBuilder sb = new StringBuilder();
Signature signature = pjp.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method method = methodSignature.getMethod();
sb.append(pjp.getTarget().getClass().getName())//类名
.append(method.getName());//方法名
for (Object o : pjp.getArgs()) {
sb.append(o.toString());
}
return DigestUtils.md5DigestAsHex(sb.toString().getBytes(Charset.defaultCharset()));
}
}
9、编写控制器
这里在插入方法上加了@NotResubmit(delaySeconds = 10)注解,表示这个插入方法执行时,10秒内不允许重复提交,10秒后才可以插入成功,这个时间可以根据需要在不同的接口上自行修改。
package com.example.resubmit.controller;
import com.example.resubmit.entity.User;
import com.example.resubmit.redission.NotResubmit;
import com.example.resubmit.service.IUserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.stereotype.Controller;
import java.time.LocalDateTime;
import java.util.List;
/**
* <p>
* 控制器
* </p>
*
* @author 福隆苑居士,公众号:【Java分享客栈】
* @since 2022-02-08
*/
@Controller
@RequestMapping("/api/user")
public class UserController {
private final IUserService userService;
public UserController(IUserService userService) {
this.userService = userService;
}
/**
* 查询列表
* @return 结果
*/
@GetMapping("/list")
public ResponseEntity<List<User>> list() {
return ResponseEntity.ok().body(userService.list());
}
/**
* 插入记录
* @return 结果
*/
@NotResubmit(delaySeconds = 10)
@PostMapping("/insert")
public ResponseEntity<List<User>> insert(@RequestBody User user) {
// 插入
user.setCreatedAt(LocalDateTime.now());
user.setCreatedBy("冰敦敦");
user.setUpdatedAt(LocalDateTime.now());
user.setUpdatedBy("冰敦敦");
userService.save(user);
// 返回列表
return ResponseEntity.ok().body(userService.list());
}
}
10、效果
1)、执行接口插入一条记录
2)、连续点击看注解是否生效
可以发现,会返回操作太频繁的提示,并没有插入数据库。
3)、等待10秒过后再执行
发现又可以插入进去了
总结
通过最终效果可以发现,自定义的防重注解实现起来并没有那么难,核心思想如下:
把实体类的属性append并进行签名,作为redisson加锁的key,在aop拦截到使用这个注解的接口方法时,就会根据传入的对象和上一次提交时传入的对象进行属性签名的匹配,只要完全一致,代表是重复提交,只有在超过延迟时间后才能成功通过拦截,最终执行业务。
这也是选取整合mybatisPlus的原因,因为它的代码生成器会自动生成带有toString()方法的代码,如果不想使用,直接用lombok注解也可以。
最后,你其实可以发现,掌握了方法后,这个注解不仅可以拿来做防重,进行优化改造后还可以用来做接口限流,是不是很有意思呢。_