基于 Redis 实现幂等性操作

This commit is contained in:
YunaiV
2021-02-22 20:44:24 +08:00
parent 3bf4588631
commit 8fa9ba8ec6
22 changed files with 273 additions and 191 deletions

View File

@ -28,6 +28,9 @@ public interface GlobalErrorCodeConstants {
ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");
// ========== 自定义错误段 ==========
ErrorCode REPEATED_REQUESTS = new ErrorCode(900, "重复请求");
ErrorCode UNKNOWN = new ErrorCode(999, "未知错误");
static boolean isMatch(Integer code) {

View File

@ -1,5 +1,5 @@
/**
* 字典数据模块
* 字典数据模块,提供 {@link cn.iocoder.dashboard.framework.dict.core.util.DictUtils} 工具类
*
* 通过将字典缓存在内存中,保证性能
*/

View File

@ -0,0 +1,42 @@
package cn.iocoder.dashboard.framework.idempotent.config;
import cn.iocoder.dashboard.framework.idempotent.core.aop.IdempotentAspect;
import cn.iocoder.dashboard.framework.idempotent.core.keyresolver.DefaultIdempotentKeyResolver;
import cn.iocoder.dashboard.framework.idempotent.core.keyresolver.ExpressionIdempotentKeyResolver;
import cn.iocoder.dashboard.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import cn.iocoder.dashboard.framework.idempotent.core.redis.IdempotentRedisDAO;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.List;
@Configuration(proxyBeanMethods = false)
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class IdempotentConfiguration {
@Bean
public IdempotentAspect idempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
return new IdempotentAspect(keyResolvers, idempotentRedisDAO);
}
@Bean
public IdempotentRedisDAO idempotentRedisDAO(StringRedisTemplate stringRedisTemplate) {
return new IdempotentRedisDAO(stringRedisTemplate);
}
// ========== 各种 IdempotentKeyResolver Bean ==========
@Bean
public DefaultIdempotentKeyResolver defaultIdempotentKeyResolver() {
return new DefaultIdempotentKeyResolver();
}
@Bean
public ExpressionIdempotentKeyResolver expressionIdempotentKeyResolver() {
return new ExpressionIdempotentKeyResolver();
}
}

View File

@ -0,0 +1,46 @@
package cn.iocoder.dashboard.framework.idempotent.core.annotation;
import cn.iocoder.dashboard.framework.idempotent.core.keyresolver.DefaultIdempotentKeyResolver;
import cn.iocoder.dashboard.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.concurrent.TimeUnit;
/**
* 幂等注解
*
* @author 芋道源码
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等的超时时间,默认为 1 秒
*
* 注意,如果执行时间超过它,请求还是会进来
*/
int timeout() default 1;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 提示信息,正在执行中的提示
*/
String message() default "重复请求,请稍后重试";
/**
* 使用的 Key 解析器
*/
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
/**
* 使用的 Key 参数
*/
String keyArg() default "";
}

View File

@ -0,0 +1,56 @@
package cn.iocoder.dashboard.framework.idempotent.core.aop;
import cn.iocoder.dashboard.common.exception.ServiceException;
import cn.iocoder.dashboard.common.exception.enums.GlobalErrorCodeConstants;
import cn.iocoder.dashboard.framework.idempotent.core.annotation.Idempotent;
import cn.iocoder.dashboard.framework.idempotent.core.keyresolver.IdempotentKeyResolver;
import cn.iocoder.dashboard.framework.idempotent.core.redis.IdempotentRedisDAO;
import cn.iocoder.dashboard.util.collection.CollectionUtils;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.util.Assert;
import java.util.List;
import java.util.Map;
/**
* 拦截声明了 {@link Idempotent} 注解的方法,实现幂等操作
*
* @author 芋道源码
*/
@Aspect
@Slf4j
public class IdempotentAspect {
/**
* IdempotentKeyResolver 集合
*/
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
private final IdempotentRedisDAO idempotentRedisDAO;
public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, IdempotentRedisDAO idempotentRedisDAO) {
this.keyResolvers = CollectionUtils.convertMap(keyResolvers, IdempotentKeyResolver::getClass);
this.idempotentRedisDAO = idempotentRedisDAO;
}
@Before("@annotation(idempotent)")
public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) {
// 获得 IdempotentKeyResolver
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
// 解析 Key
String key = keyResolver.resolver(joinPoint, idempotent);
// 锁定 Key。
boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
// 锁定失败,抛出异常
if (!success) {
log.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs());
throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), idempotent.message());
}
}
}

View File

@ -0,0 +1,24 @@
package cn.iocoder.dashboard.framework.idempotent.core.keyresolver;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import cn.iocoder.dashboard.framework.idempotent.core.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;
/**
* 默认幂等 Key 解析器,使用方法名 + 方法参数,组装成一个 Key
*
* 为了避免 Key 过长,使用 MD5 进行“压缩”
*
* @author 芋道源码
*/
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
String methodName = joinPoint.getSignature().toString();
String argsStr = StrUtil.join(",", joinPoint.getArgs());
return SecureUtil.md5(methodName + argsStr);
}
}

View File

@ -0,0 +1,19 @@
package cn.iocoder.dashboard.framework.idempotent.core.keyresolver;
import cn.iocoder.dashboard.framework.idempotent.core.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;
/**
* 基于 Spring EL 表达式,
*
* @author 芋道源码
*/
public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
@Override
public String resolver(JoinPoint joinPoint, Idempotent idempotent) {
// TODO 稍后实现
return null;
}
}

View File

@ -0,0 +1,22 @@
package cn.iocoder.dashboard.framework.idempotent.core.keyresolver;
import cn.iocoder.dashboard.framework.idempotent.core.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;
/**
* 幂等 Key 解析器接口
*
* @author 芋道源码
*/
public interface IdempotentKeyResolver {
/**
* 解析一个 Key
*
* @param idempotent 幂等注解
* @param joinPoint AOP 切面
* @return Key
*/
String resolver(JoinPoint joinPoint, Idempotent idempotent);
}

View File

@ -0,0 +1,34 @@
package cn.iocoder.dashboard.framework.idempotent.core.redis;
import cn.iocoder.dashboard.framework.redis.core.RedisKeyDefine;
import lombok.AllArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
import static cn.iocoder.dashboard.framework.redis.core.RedisKeyDefine.KeyTypeEnum.STRING;
/**
* 幂等 Redis DAO
*
* @author 芋道源码
*/
@AllArgsConstructor
public class IdempotentRedisDAO {
private static final RedisKeyDefine IDEMPOTENT = new RedisKeyDefine("幂等操作",
"idempotent:%s", // 参数为 uuid
STRING, String.class, RedisKeyDefine.TimeoutTypeEnum.DYNAMIC);
private final StringRedisTemplate redisTemplate;
public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
String redisKey = formatKey(key);
return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
}
private static String formatKey(String key) {
return String.format(IDEMPOTENT.getKeyTemplate(), key);
}
}

View File

@ -0,0 +1,4 @@
/**
* 幂等组件,参考 https://github.com/it4alla/idempotent 项目实现
*/
package cn.iocoder.dashboard.framework.idempotent;

View File

@ -9,6 +9,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 操作日志注解
*
* @author 芋道源码
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OperateLog {

View File

@ -4,6 +4,7 @@ import cn.iocoder.dashboard.common.exception.util.ServiceExceptionUtil;
import cn.iocoder.dashboard.common.pojo.CommonResult;
import cn.iocoder.dashboard.common.pojo.PageResult;
import cn.iocoder.dashboard.framework.excel.core.util.ExcelUtils;
import cn.iocoder.dashboard.framework.idempotent.core.annotation.Idempotent;
import cn.iocoder.dashboard.modules.infra.controller.config.vo.*;
import cn.iocoder.dashboard.modules.infra.convert.config.InfConfigConvert;
import cn.iocoder.dashboard.modules.infra.dal.dataobject.config.InfConfigDO;
@ -91,7 +92,7 @@ public class InfConfigController {
@PostMapping("/create")
// @PreAuthorize("@ss.hasPermi('infra:config:add')")
// @Log(title = "参数管理", businessType = BusinessType.INSERT)
// @RepeatSubmit
@Idempotent(timeout = 10)
public CommonResult<Long> createConfig(@Validated @RequestBody InfConfigCreateReqVO reqVO) {
return success(configService.createConfig(reqVO));
}
@ -100,7 +101,8 @@ public class InfConfigController {
@PutMapping("/update")
// @PreAuthorize("@ss.hasPermi('infra:config:edit')")
// @Log(title = "参数管理", businessType = BusinessType.UPDATE)
public CommonResult<Boolean> edit(@Validated @RequestBody InfConfigUpdateReqVO reqVO) {
@Idempotent(timeout = 60)
public CommonResult<Boolean> updateConfig(@Validated @RequestBody InfConfigUpdateReqVO reqVO) {
configService.updateConfig(reqVO);
return success(true);
}

View File

@ -4,12 +4,14 @@ import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import javax.validation.constraints.NotNull;
@ApiModel("参数配置创建 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class InfConfigUpdateReqVO extends InfConfigBaseVO {
@ApiModelProperty(value = "参数配置序号", required = true, example = "1024")

View File

@ -12,7 +12,7 @@ import static cn.iocoder.dashboard.framework.redis.core.RedisKeyDefine.KeyTypeEn
*
* @author 芋道源码
*/
public interface RedisKeyConstants {
public interface SysRedisKeyConstants {
RedisKeyDefine LOGIN_USER = new RedisKeyDefine("登陆用户的缓存",
"login_user:%s", // 参数为 sessionId

View File

@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository;
import javax.annotation.Resource;
import static cn.iocoder.dashboard.modules.system.dal.redis.RedisKeyConstants.LOGIN_USER;
import static cn.iocoder.dashboard.modules.system.dal.redis.SysRedisKeyConstants.LOGIN_USER;
/**
* {@link LoginUser} 的 RedisDAO

View File

@ -6,7 +6,7 @@ import org.springframework.stereotype.Repository;
import javax.annotation.Resource;
import java.time.Duration;
import static cn.iocoder.dashboard.modules.system.dal.redis.RedisKeyConstants.CAPTCHA_CODE;
import static cn.iocoder.dashboard.modules.system.dal.redis.SysRedisKeyConstants.CAPTCHA_CODE;
/**
* 验证码的 Redis DAO

View File

@ -98,6 +98,8 @@ spring:
# Spring Boot Admin Client 客户端的相关配置
client:
url: http://127.0.0.1:${server.port}/${spring.boot.admin.context-path} # 设置 Spring Boot Admin Server 地址
instance:
prefer-ip: true # 注册实例时,优先使用 IP
# Spring Boot Admin Server 服务端的相关配置
context-path: /admin # 配置 Spring

View File

@ -99,7 +99,7 @@ spring:
client:
url: http://127.0.0.1:${server.port}/${spring.boot.admin.context-path} # 设置 Spring Boot Admin Server 地址
instance:
prefer-ip: true
prefer-ip: true # 注册实例时,优先使用 IP
# Spring Boot Admin Server 服务端的相关配置
context-path: /admin # 配置 Spring