Merge branch 'feat/idempotent' into 0.3.0

This commit is contained in:
b2baccline
2021-07-29 22:41:48 +08:00
18 changed files with 552 additions and 87 deletions

View File

@@ -0,0 +1,66 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>ballcat-common</artifactId>
<groupId>com.hccake</groupId>
<version>${revision}</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>ballcat-common-idempotent</artifactId>
<dependencies>
<dependency>
<groupId>com.hccake</groupId>
<artifactId>ballcat-common-core</artifactId>
</dependency>
<dependency>
<groupId>com.hccake</groupId>
<artifactId>ballcat-common-util</artifactId>
</dependency>
<!-- slf4j日志 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<optional>true</optional>
</dependency>
<!--hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,93 @@
package com.hccake.ballcat.common.idempotent;
import cn.hutool.core.lang.Assert;
import com.hccake.ballcat.common.idempotent.annotation.Idempotent;
import com.hccake.ballcat.common.idempotent.exception.IdempotentException;
import com.hccake.ballcat.common.idempotent.key.IdempotentKeyStore;
import com.hccake.ballcat.common.model.result.BaseResultCode;
import com.hccake.ballcat.common.util.SpelUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.lang.reflect.Method;
/**
* @author hccake
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class IdempotentAspect {
private final IdempotentKeyStore idempotentKeyStore;
@Around("@annotation(idempotentAnnotation)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotentAnnotation) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
// 获取幂等标识
String idempotentKey = buildIdempotentKey(joinPoint, idempotentAnnotation, method, args);
// 校验当前请求是否重复请求
Assert.isTrue(idempotentKeyStore.saveIfAbsent(idempotentKey, idempotentAnnotation.duration()), () -> {
String errorMessage = String.format("拒绝重复执行方法[%s], 幂等key:[%s]", method.getName(), idempotentKey);
throw new IdempotentException(BaseResultCode.REPEATED_EXECUTE.getCode(), errorMessage);
});
try {
Object result = joinPoint.proceed();
if (idempotentAnnotation.removeKeyWhenFinished()) {
idempotentKeyStore.remove(idempotentKey);
}
return result;
}
catch (Throwable e) {
// 异常时必须删除,方便重试处理
idempotentKeyStore.remove(idempotentKey);
throw e;
}
}
/**
* 构建幂等标识 key
* @param joinPoint 切点
* @param idempotentAnnotation 幂等注解
* @param method 当前方法
* @param args 方法参数
* @return String 幂等标识
*/
private String buildIdempotentKey(ProceedingJoinPoint joinPoint, Idempotent idempotentAnnotation, Method method,
Object[] args) {
String uniqueExpression = idempotentAnnotation.uniqueExpression();
// 如果没有填写表达式,直接返回 prefix
if ("".equals(uniqueExpression)) {
return idempotentAnnotation.prefix();
}
// 根据当前切点,获取到 spEL 上下文
StandardEvaluationContext spelContext = SpelUtils.getSpelContext(joinPoint.getTarget(), method, args);
// 如果在 sevlet 环境下,则将 request 信息放入上下文,便于获取请求参数
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
if (requestAttributes != null) {
spelContext.setVariable("request", requestAttributes.getRequest());
}
// 解析出唯一标识
String uniqueStr = SpelUtils.parseValueToString(spelContext, uniqueExpression);
// 和 prefix 拼接获得完整的 key
return idempotentAnnotation.prefix() + ":" + uniqueStr;
}
}

View File

@@ -0,0 +1,46 @@
package com.hccake.ballcat.common.idempotent.annotation;
import java.lang.annotation.*;
/**
* 幂等控制注解
* @author hccake
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
String KEY_PREFIX = "idem";
/**
* <p>
* 幂等标识的前缀,可用于区分服务和业务,防止 key 冲突
* </p>
* ps: 完整的幂等标识 = {prefix}:{uniqueExpression.value}
* @return 业务标识
*/
String prefix() default KEY_PREFIX;
/**
* 值为 SpEL 表达式,从上下文中提取幂等的唯一性标识。
* @return Spring-EL expression
*/
String uniqueExpression() default "";
/**
* <p>
* 幂等的控制时长(秒),必须大于业务的处理耗时
* </p>
* 其值为幂等 key 的标记时长,超过标记时间,则幂等 key 可再次使用。
* @return 标记时长(秒),默认 10 min
*/
long duration() default 10 * 60;
/**
* 否在业务完成后立刻清除,幂等 key
* @return boolean true: 立刻清除 false: 不处理
*/
boolean removeKeyWhenFinished() default false;
}

View File

@@ -0,0 +1,16 @@
package com.hccake.ballcat.common.idempotent.exception;
import com.hccake.ballcat.common.core.exception.BusinessException;
import lombok.EqualsAndHashCode;
/**
* @author hccake
*/
@EqualsAndHashCode(callSuper = true)
public class IdempotentException extends BusinessException {
public IdempotentException(int code, String message) {
super(code, message);
}
}

View File

@@ -0,0 +1,22 @@
package com.hccake.ballcat.common.idempotent.key;
/**
* @author hccake
*/
public interface IdempotentKeyStore {
/**
* 当不存在有效 key 时将其存储下来
* @param key idempotentKey
* @param duration key的有效时长
* @return boolean true: 存储成功 false: 存储失败
*/
boolean saveIfAbsent(String key, long duration);
/**
* 删除 key
* @param key idempotentKey
*/
void remove(String key);
}

View File

@@ -0,0 +1,33 @@
package com.hccake.ballcat.common.idempotent.key;
import cn.hutool.cache.CacheUtil;
import cn.hutool.cache.impl.TimedCache;
/**
* @author hccake
*/
public class InMemoryIdempotentKeyStore implements IdempotentKeyStore {
private final TimedCache<String, Long> cache;
public InMemoryIdempotentKeyStore() {
this.cache = CacheUtil.newTimedCache(Integer.MAX_VALUE);
cache.schedulePrune(1);
}
@Override
public synchronized boolean saveIfAbsent(String key, long duration) {
Long value = cache.get(key, false);
if (value == null) {
cache.put(key, System.currentTimeMillis(), duration * 1000);
return true;
}
return false;
}
@Override
public void remove(String key) {
cache.remove(key);
}
}

View File

@@ -0,0 +1,28 @@
package com.hccake.ballcat.common.idempotent.key;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.concurrent.TimeUnit;
/**
* @author hccake
*/
public class RedisIdempotentKeyStore implements IdempotentKeyStore {
StringRedisTemplate stringRedisTemplate;
@Override
public boolean saveIfAbsent(String key, long duration) {
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
Boolean saveSuccess = opsForValue.setIfAbsent(key, String.valueOf(System.currentTimeMillis()), duration,
TimeUnit.SECONDS);
return saveSuccess != null && saveSuccess;
}
@Override
public void remove(String key) {
stringRedisTemplate.delete(key);
}
}

View File

@@ -0,0 +1,19 @@
package com.hccake.ballcat.common.idempotent.test;
import com.hccake.ballcat.common.idempotent.annotation.Idempotent;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author hccake
*/
@RestController
public class IdempotentController {
@RequestMapping("/")
@Idempotent(uniqueExpression = "#request.getHeader('formId')", duration = 1)
public String greeting() {
return "hello word";
}
}

View File

@@ -0,0 +1,18 @@
package com.hccake.ballcat.common.idempotent.test;
import com.hccake.ballcat.common.idempotent.annotation.Idempotent;
import lombok.extern.slf4j.Slf4j;
/**
* @author hccake
*/
@Slf4j
public class IdempotentMethods {
@Idempotent(uniqueExpression = "#key", duration = 1)
public String method1(String key) {
log.info("===执行方法1成功===");
return key;
}
}

View File

@@ -0,0 +1,28 @@
package com.hccake.ballcat.common.idempotent.test;
import com.hccake.ballcat.common.idempotent.IdempotentAspect;
import com.hccake.ballcat.common.idempotent.key.IdempotentKeyStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
/**
* @author hccake
*/
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
@ComponentScan
public class IdempotentTestConfiguration {
@Bean
public IdempotentAspect idempotentAspect(IdempotentKeyStore idempotentKeyStore) {
return new IdempotentAspect(idempotentKeyStore);
}
@Bean
public IdempotentMethods idempotentMethods() {
return new IdempotentMethods();
}
}

View File

@@ -0,0 +1,41 @@
package com.hccake.ballcat.common.idempotent.test;
import cn.hutool.core.lang.Assert;
import com.hccake.ballcat.common.idempotent.exception.IdempotentException;
import com.hccake.ballcat.common.idempotent.key.InMemoryIdempotentKeyStore;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
/**
* @author hccake
*/
@Slf4j
@SpringJUnitConfig({ IdempotentTestConfiguration.class, InMemoryIdempotentKeyStore.class })
class InMemoryIdempotentTest {
@Autowired
private IdempotentMethods idempotentMethods;
@Test
void testIdempotent() throws InterruptedException {
tryExecute("aaa");
Assert.isTrue(tryExecute("bbb"));
Assert.isFalse(tryExecute("bbb"));
Thread.sleep(1000);
Assert.isTrue(tryExecute("bbb"));
}
private boolean tryExecute(String key) {
try {
idempotentMethods.method1(key);
}
catch (IdempotentException e) {
System.out.println(e.getMessage());
return false;
}
return true;
}
}

View File

@@ -0,0 +1,64 @@
package com.hccake.ballcat.common.idempotent.test;
import cn.hutool.core.lang.Assert;
import com.hccake.ballcat.common.idempotent.exception.IdempotentException;
import com.hccake.ballcat.common.idempotent.key.InMemoryIdempotentKeyStore;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* @author hccake
*/
@Slf4j
@WebMvcTest(IdempotentController.class)
@AutoConfigureMockMvc
@SpringJUnitConfig({ IdempotentTestConfiguration.class, InMemoryIdempotentKeyStore.class })
class WebIdempotentTest {
@Autowired
private IdempotentMethods idempotentMethods;
@Autowired
private MockMvc mockMvc;
@Test
void shouldReturnDefaultMessage() throws Exception {
this.mockMvc.perform(get("/").header("formId", "formId1")).andDo(print()).andExpect(status().isOk())
.andExpect(content().string(containsString("hello word")));
this.mockMvc.perform(get("/").header("formId", "formId1")).andDo(print()).andExpect(status().isOk())
.andExpect(content().string(containsString("hello word")));
}
@Test
void testIdempotent() throws InterruptedException {
tryExecute("aaa");
Assert.isTrue(tryExecute("bbb"));
Assert.isFalse(tryExecute("bbb"));
Thread.sleep(1000);
Assert.isTrue(tryExecute("bbb"));
}
private boolean tryExecute(String key) {
try {
idempotentMethods.method1(key);
}
catch (IdempotentException e) {
System.out.println(e.getMessage());
return false;
}
return true;
}
}

View File

@@ -32,6 +32,11 @@ public enum BaseResultCode implements ResultCode {
*/
FILE_UPLOAD_ERROR(90006, "File Upload Error"),
/**
* 重复执行
*/
REPEATED_EXECUTE(90007, "Repeated execute"),
/**
* 未知异常
*/

View File

@@ -60,5 +60,11 @@
<artifactId>jsoup</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -1,4 +1,4 @@
package com.hccake.ballcat.common.redis.core;
package com.hccake.ballcat.common.util;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
@@ -14,7 +14,11 @@ import java.lang.reflect.Method;
* @version 1.0
* @date 2019/9/3 10:29
*/
public class SpELUtil {
@SuppressWarnings("SpellCheckingInspection")
public final class SpelUtils {
private SpelUtils() {
}
/**
* SpEL 解析器
@@ -22,48 +26,53 @@ public class SpELUtil {
public static final ExpressionParser PARSER = new SpelExpressionParser();
/**
*
*
* 方法参数获取
*/
public static final ParameterNameDiscoverer PARAMETER_NAME_DISCOVERER = new LocalVariableTableParameterNameDiscoverer();
/**
* 支持 #p0 参数索引的表达式解析
* @param rootObject 根对象,method 所在的对象
* @param spEL 表达式
* @param rootObject 根对象, method 所在的对象实例
* @param spelExpression spel 表达式
* @param method 目标方法
* @param args 方法入参
* @return 解析后的字符串
*/
public static String parseValueToString(Object rootObject, Method method, Object[] args, String spEL) {
StandardEvaluationContext context = getSpElContext(rootObject, method, args);
return parseValueToString(context, spEL);
public static String parseValueToString(Object rootObject, Method method, Object[] args, String spelExpression) {
StandardEvaluationContext context = getSpelContext(rootObject, method, args);
return parseValueToString(context, spelExpression);
}
/**
* 支持 #p0 参数索引的表达式解析
* @param rootObject 根对象,method 所在的对象
* @param method 目标方法
* @param args 方法入参
* @return 解析后的字符串
* @param rootObject 根对象, method 所在的对象
* @param method 目标方法
* @param args 方法实际入参
* @return StandardEvaluationContext spel 上下文
*/
public static StandardEvaluationContext getSpElContext(Object rootObject, Method method, Object[] args) {
String[] paraNameArr = PARAMETER_NAME_DISCOVERER.getParameterNames(method);
// SPEL 上下文
public static StandardEvaluationContext getSpelContext(Object rootObject, Method method, Object[] args) {
// spel 上下文
StandardEvaluationContext context = new MethodBasedEvaluationContext(rootObject, method, args,
PARAMETER_NAME_DISCOVERER);
// 方法参数放入 SPEL 上下文中
for (int i = 0; i < paraNameArr.length; i++) {
context.setVariable(paraNameArr[i], args[i]);
// 方法参数名数组
String[] parameterNames = PARAMETER_NAME_DISCOVERER.getParameterNames(method);
// 把方法参数放入 spel 上下文中
if (parameterNames != null && parameterNames.length > 0) {
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
}
return context;
}
public static String parseValueToString(StandardEvaluationContext context, String spEL) {
return PARSER.parseExpression(spEL).getValue(context, String.class);
/**
* 解析 spel 表达式
* @param context spel 上下文
* @param spelExpression spel 表达式
* @return String 解析后的字符串
*/
public static String parseValueToString(StandardEvaluationContext context, String spelExpression) {
return PARSER.parseExpression(spelExpression).getValue(context, String.class);
}
}

View File

@@ -20,6 +20,7 @@
<module>ballcat-common-model</module>
<module>ballcat-common-websocket</module>
<module>ballcat-common-security</module>
<module>ballcat-common-idempotent</module>
</modules>

View File

@@ -129,6 +129,11 @@
<artifactId>ballcat-common-desensitize</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.hccake</groupId>
<artifactId>ballcat-common-idempotent</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>com.hccake</groupId>
<artifactId>ballcat-common-model</artifactId>
@@ -461,6 +466,11 @@
<artifactId>hutool-extra</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-cache</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-json</artifactId>

View File

@@ -1,14 +1,13 @@
package com.hccake.ballcat.common.redis.core;
import com.hccake.ballcat.common.redis.config.CachePropertiesHolder;
import com.hccake.ballcat.common.util.SpelUtils;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
/**
@@ -21,89 +20,50 @@ public class KeyGenerator {
/**
* SpEL 上下文
*/
StandardEvaluationContext spElContext;
StandardEvaluationContext spelContext;
public KeyGenerator(Object target, Method method, Object[] arguments) {
this.spElContext = SpELUtil.getSpElContext(target, method, arguments);
this.spelContext = SpelUtils.getSpelContext(target, method, arguments);
}
public String getKey(String key, String spELExpressions) {
// 根据keyJoint 判断是否需要拼接
if (spELExpressions == null || spELExpressions.length() == 0) {
return key;
/**
* 根据 keyPrefix 和 keyJoint 获取完整的 key 信息
* @param keyPrefix key 前缀
* @param keyJoint key 拼接元素,值为 spel 表达式,可为空
* @return 拼接完成的 key
*/
public String getKey(String keyPrefix, String keyJoint) {
// 根据 keyJoint 判断是否需要拼接
if (keyJoint == null || keyJoint.length() == 0) {
return keyPrefix;
}
// 获取所有需要拼接的元素, 组装进集合中
String joint = SpELUtil.parseValueToString(spElContext, spELExpressions);
String joint = SpelUtils.parseValueToString(spelContext, keyJoint);
Assert.notNull(joint, "Key joint cannot be null!");
if (!StringUtils.hasText(key)) {
if (!StringUtils.hasText(keyPrefix)) {
return joint;
}
// 拼接后返回
return jointKey(key, joint);
}
public List<String> getKeys(String key, String keyJoint, Collection<String> multiByItem) {
String keyPrefix = getKey(key, keyJoint);
List<String> list = new ArrayList<>();
for (String item : multiByItem) {
list.add(jointKey(keyPrefix, item));
}
return list;
}
/**
* @param key
* @param spELExpressions
* @return
*/
public String getKeys(String key, String[] spELExpressions) {
// 根据keyJoint 判断是否需要拼接
if (spELExpressions == null || spELExpressions.length == 0) {
return key;
}
// 获取所有需要拼接的元素, 组装进集合中
List<String> list = new ArrayList<>(spELExpressions.length + 1);
list.add(key);
for (String joint : spELExpressions) {
String s = parseSpEL(joint);
Assert.notNull(s, "Key joint cannot be null!");
list.add(s);
}
// 拼接后返回
return jointKey(list);
}
/**
* 解析SPEL
* @param field
* @return
*/
public String parseSpEL(String field) {
return SpELUtil.parseValueToString(spElContext, field);
return jointKey(keyPrefix, joint);
}
/**
* 拼接key, 默认使用 :作为分隔符
* @param list
* @return
* @param keyItems 用于拼接 key 的元素列表
* @return 拼接完成的 key
*/
public String jointKey(List<String> list) {
return String.join(CachePropertiesHolder.delimiter(), list);
public String jointKey(List<String> keyItems) {
return String.join(CachePropertiesHolder.delimiter(), keyItems);
}
/**
* 拼接key, 默认使用 :作为分隔符
* @param items
* @return
* @param keyItems 用于拼接 key 的元素列表
* @return 拼接完成的 key
*/
public String jointKey(String... items) {
return jointKey(Arrays.asList(items));
public String jointKey(String... keyItems) {
return jointKey(Arrays.asList(keyItems));
}
}