前言
專案中經常會出現重複提交的問題,而介面冪等性也一直以來是做任何專案都要關注的疑難點,網上可以查到非常多的方案,我歸納了幾點如下:
1)、資料庫層面,對責任欄位設定唯一索引,這是最直接有效的方式,不好的地方就是一旦觸發就會在服務端拋資料庫相關異常;
2)、程式碼層面,增加業務邏輯判斷,先查詢一遍若沒有才插入,這也是最容易想到的方式,反正寫上就對了,不好的地方就是分散式場景下依然避免不了問題;
3)、前端層面,對於觸發事件的操作比如按鈕等,最好點選過後都設定幾秒的置灰時間,能很大程度上解決惡意提交的問題。
以上幾點經常在專案中結合使用,不過有一種更通用的方案,就是自定義註解,寫一個專門處理這類問題的註解,之後在有需要用到的介面上直接加上這個註解即可,十分方便。
專案
1、介紹
本人所在公司是網際網路行業,所以平常相當繁忙,但依然在這幾天晚上得空之餘咬牙爆肝做好了一個案例,網上有非常多相關的文章我也看過,但有些案例過於臃腫,我認為一個好的案例一定是輕巧、精簡、清晰、一看就懂的迷你專案,我便是朝著這個方向來做的。
通過這個小專案或者叫小案例,你可以學到這些技術:
1)、SpringBoot2.6版本整合SpringDataRedis;
2)、SpringBoot2.6版本整合Redisson;
3)、SpringBoot2.6版本整合MybatisPlus最新版;
4)、學會自定義一個註解;
5)、學會防重複提交註解的核心實現並可靈活設定延遲時間。
2、原始碼
評論中分享
實現步驟
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 福隆苑居士,公眾號:【9i分享客棧】
* @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 福隆苑居士,公眾號:【9i分享客棧】
* @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;
/**
* @作者: 福隆苑居士,公眾號:【9i分享客棧】
* @日期: 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 福隆苑居士,公眾號:【9i分享客棧】
* @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 福隆苑居士,公眾號:【9i分享客棧】
* @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 福隆苑居士,公眾號:【9i分享客棧】
* @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 福隆苑居士,公眾號:【9i分享客棧】
* @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 福隆苑居士,公眾號:【9i分享客棧】
* @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註解也可以。
最後,你其實可以發現,掌握了方法後,這個註解不僅可以拿來做防重,進行優化改造後還可以用來做介面限流,是不是很有意思呢。_
以上內容是本人純手打,如果覺得有一滴滴幫助,就麻煩伸出您的芊芊玉手點一下推薦吧!_