最近自己在做一套spring开发脚手架,期间做了一个幂等工具。今天分享一下吧。也请大家给提提意见。看看有哪些问题。
实现思路大概就是一个声明式的方式,通过注解进入切面,实现对目标方法的环切。利用redis的单线程特性。实现接口幂等。
不多说了,直接上代码,现阶段还不是很完善。后续如果整个项目完善了,到时候再发上来吧。
先看一下注解:
/**
* 幂等注解
* 用于controller层方法
*
* @author: 王锰
* @date: 2018/8/18
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* @return key超时时间;默认10秒超时
*/
long timeout() default 10;
/**
* @return 幂等策略
*/
IdempotentStrategy strategy() default IdempotentStrategy.BASE_IDEMPOTENT_DTO;
}
幂等注解,标记controller层方法。里面有两个属性,一个是设置的超时时间;一个是幂等策略。其实还不是很完善,比如还可以自定义时间单位,设定等待时间等等一些其他操作。这里因为也是一个初步方案,就简单实现了。
/**
* 幂等字段注解
* 用于标注dto中的字段
* 要配合@Idempotent使用
*
* @author: 王锰
* @date: 2018/8/18
*/
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IdempotentField {
}
幂等字段注解,只有当开启了幂等才能起作用。不然该注解无效。目前是一个空接口,后续有需要的地方可以加入相应的配置。
上面提到了幂等策略,这里我用了一个策略模式,根据声明的不同策略进行不同幂等key的操作。
/**
* 幂等策略枚举
*
* @author: 王锰
* @date: 2018/08/20
*/
@Getter
public enum IdempotentStrategy {
/**
* 使用继承与BaseDto的类,如果dto中有指定字段,使用指定字段;否则使用dto整体做幂等
*/
BASE_IDEMPOTENT_DTO,
/**
* 使用实现了IdempotentInterface接口的类做幂等;如果dto中有指定字段,使用指定字段;否则使用dto整体做幂等
*/
IDEMPOTENT_INTERFACE,
/**
* 使用整个参数列表做幂等,无关@idempotentfield
*/
LIST_PARAMETER,
;
}
目前我们现在都是使用http+json形式的rest风格接口,其中的get和delete天生具有幂等性,所以幂等主要用于post和put,而put一般不做计算也是幂等的,况且一些公司会规定只能用post请求。所以我就做了一个策略模式,上面提到3个策略,一个是使用继承BaseIdempotentDto;一个是实现IdempotentInterface接口;一个是使用方法的参数列表;
所以引申出一个类,一个接口;
@EqualsAndHashCode(callSuper = true)
@Data
@ApiModel(value = "BaseIdempotentDto对象", description = "需要使用messageId实现幂等的dto对象基类")
public class BaseIdempotentDto extends BaseDto {
@IdempotentField
@ApiModelProperty(value = "消息ID,用于实现幂等操作", name = "messageId", dataType = "String")
protected String messageId;
}
继承这个类,默认使用messageId进行幂等操作;为什么要做这个呢?是因为后面我有一个消息代理项目,做一个分布式消息可靠性投递,那时每个消息会有自己的一个唯一ID也就是messageId。所以通过broker发送的消息,会直接通过messageId做幂等。
/**
* 一个空接口,用于标注该类需要幂等
*
* @author: 王锰
* @date: 2018/8/20
*/
public interface IdempotentInterface {
}
一个空接口,主要用于标记此DTO是需要被用来做幂等的。其实也是后续可以提供很多默认方法,如果有必要的话。这里还是一个最初方案。所以一切以实现为目标。拓展性的东西,后续再补充吧。毕竟先做对,再做好。
好,基础的东西都说完了,后面该实现我们的幂等策略了。这里之前我写过一个spring下实现策略模式。有兴趣的可以去看看。
这里直接上代码
既然是策略模式,必须现有一个幂等策略功能接口:
/**
* 接口幂等keyStr实现策略
*
* @author: 王锰
* @date: 2018/8/20
*/
public interface IdempotentStrategyInterface {
/**
* 根据不同策略获取key值
*
* @return key值
*/
String process(ProceedingJoinPoint pjp) throws IllegalAccessException;
}
先做一个策略转换器模板类
/**
* 策略模板
*
* @author 王锰
* @date 20:02 2019/7/8
*/
@Data
public abstract class AbstractStrategyContext<R> {
@Autowired
Map<String, R> map;
/**
* 根据type获取对应的策略实例
*
* @param type 策略名称
* @return 策略实例
*/
R getStrategy(String type) {
return Optional.ofNullable(getMap().get(type)).orElseThrow(() -> new RuntimeException("类型:" + type + "未定义"));
}
}
然后实现我们当前幂等功能的策略转换器
/**
* 幂等策略转换器
*
* @author 王锰
* @date 20:03 2019/7/8
*/
@Component
public class IdempotentStrategyContext extends AbstractStrategyContext<IdempotentStrategyInterface> {
/**
* 策略转换方法
*/
public <T> String accept(IdempotentStrategy idempotentStrategy, ProceedingJoinPoint pjp) throws IllegalAccessException {
return getStrategy(idempotentStrategy.name()).process(pjp);
}
}
好了,基本的框架写完了。后面就剩实现AOP拦截和具体策略了。
我们先看一下AOP吧。很简单,
/**
* 接口幂等切面
*
* @author: 王锰
* @date: 2019/8/18
*/
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IdempotentStrategyContext idempotentStrategyContext;
/**
* 切点,标注了@Idempotent的controller方法
*/
@Pointcut(value = "@annotation(org.wmframework.idempotent.annotations.Idempotent)")
public void idempotent() {
}
@Around("idempotent()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
log.info("-----idempotent aspect start-----");
Object[] args = pjp.getArgs();
if (null == args || args.length == 0) {
log.error("args is null,skip idempotent,execute target class");
return pjp.proceed();
}
MethodSignature methodSignature = (MethodSignature) pjp.getSignature();
Idempotent idempotent = methodSignature.getMethod().getAnnotation(Idempotent.class);
long timeout = idempotent.timeout();
IdempotentStrategy strategy = idempotent.strategy();
String keyStr = idempotentStrategyContext.accept(strategy, pjp);
if (StringUtils.isEmpty(keyStr)) {
log.error("keyStr is null,skip idempotent,execute target class");
return pjp.proceed();
}
String key = new Md5Hash(JSON.toJSONString(keyStr)).toHex();
log.info("redis key:{}", key);
boolean setNxRes = setNx(key, "1", timeout);
if (setNxRes) {
log.info("try lock success,execute target class");
Object processResult = pjp.proceed();
String targetRes = JSONObject.toJSONString(processResult);
log.info("target result:{}", targetRes);
setEx(key, targetRes, timeout);
return processResult;
} else {
log.info("try lock failed");
String value = redisTemplate.opsForValue().get(key);
if ("1".equals(value)) {
log.error("same request executing");
throw new BizException("请求正在处理。。。。。。");
} else {
log.info("same request already be executed,return success result");
//第一次已经处理完成,但未过超时时间,所以后续同样请求使用同一个返回结果
return JSONObject.parseObject(value, Resp.class);
}
}
}
/**
* 使用StringRedisTemplate实现setnx
*
* @param key redis key
* @param value redis value 1
* @param expire 设置的超时时间
* @return true:setnx成功,false:失败或者执行结果为null
*/
private boolean setNx(String key, String value, Long expire) {
try {
RedisCallback<Boolean> callback = (connection) ->
connection.set(key.getBytes(), value.getBytes(), Expiration.seconds(expire), RedisStringCommands.SetOption.SET_IF_ABSENT);
Boolean result = redisTemplate.execute(callback);
return Optional.ofNullable(result).orElse(false);
} catch (Exception e) {
log.error("setNx redis occured an exception", e);
return false;
}
}
/**
* 使用StringRedisTemplate实现setex
*
* @param key redis key
* @param value redis value
* @param expire 设置的超时时间
* @return true:setex成功,false:失败或者执行结果为null
*/
private boolean setEx(String key, String value, Long expire) {
try {
RedisCallback<Boolean> callback = (connection) ->
connection.set(key.getBytes(), value.getBytes(), Expiration.seconds(expire), RedisStringCommands.SetOption.SET_IF_PRESENT);
Boolean result = redisTemplate.execute(callback);
return Optional.ofNullable(result).orElse(false);
} catch (Exception e) {
log.error("setEx redis occured an exception", e);
return false;
}
}
}
1,注解应该都比较清晰,大概思路就是定义切点为Idempotent。
2,进入环切
3,获取参数列表,如果为空,不使用幂等。一般也不会写这种方法。写也不会需要幂等。起码我没遇到过。
4,获取当前Idempotent注解。
5,注入策略转换器,根据不用策略返回keystr。
6,判断keyStr是否为空,为空不走幂等。
7,将keystr做MD5加密。进行redis的setnx操作。
8,setnx key值为1
8.1,成功,获取到锁,同样的消息将不可再次进入。执行目标方法,执行后,将结果setex到redis。
8.2,失败,没获取到锁。判断当前key对应的值。
8.2.1,value=1 请求正在处理
8.2.2,value!=1 请求已经处理成功过。直接获取到成功报文返回。
这里面的setnx和setex我是用的是stringredistemplate模板来做的,因为直接使用setif方法不能setnx时设置超时时间。所以使用rediscalllback来做。
我看了一下源码,rediscallback的入参是redisconnection,这个接口的父接口是rediscommods。而他又继承了很多接口,比如我们这里用到的redisStringCommands里的set方法。可以通过
Boolean set(byte[] key, byte[] value, Expiration expiration, SetOption option);
来进行和jedis同样的setnx设置超时时间的操作。
到这里,还剩下3个策略。
下面直接上代码吧。没什么难度。
/**
* BASE_IDEMPOTENT_DTO策略
* 不允许参数列表为空;
* DTO必须继承于BaseIdempotentDto
* DTO中如果存在IdempotentField表及字段;使用字段幂等;否则使用DTO幂等
*
* @author: 王锰
* @date: 2018/8/20
*/
@Component("BASE_IDEMPOTENT_DTO")
@Slf4j
public class BaseDtoIdempotentStrategy implements IdempotentStrategyInterface {
public static final String PREFIX = "base_idempotent_dto_";
@Override
public String process(ProceedingJoinPoint pjp) throws IllegalAccessException {
log.info("idempotent strategy:{}", IdempotentStrategy.BASE_IDEMPOTENT_DTO.name());
Object[] args = pjp.getArgs();
Object dto = null;
for (Object arg : args) {
boolean assignableFrom = BaseIdempotentDto.class.isAssignableFrom(arg.getClass());
if (assignableFrom) {
dto = arg;
break;
}
}
if (dto == null) {
log.info("not class extends of BaseIdempotentDto in list of parameter");
return null;
}
StringBuilder keyStr = new StringBuilder(PREFIX);
List<Field> fields = BeanUtils.recursive(new ArrayList<>(), dto.getClass());
for (Field field : fields) {
field.setAccessible(true);
IdempotentField idempotentField = field.getAnnotation(IdempotentField.class);
if (null == idempotentField) {
continue;
}
keyStr.append(field.get(dto)).append("_");
}
if (keyStr.toString().equals(PREFIX)) {
log.info("use dto");
keyStr.append(JSONObject.toJSONString(dto));
} else {
log.info("user idempotentField");
return keyStr.substring(0, keyStr.length() - 1);
}
return keyStr.toString();
}
}
/**
* IDEMPOTENT_INTERFACE策略
* 不允许参数列表为空;
* DTO必须实现IdempotentInterface接口;
* DTO中如果存在IdempotentField表及字段;使用字段幂等;否则使用DTO幂等
*
* @author: 王锰
* @date: 2018/8/20
*/
@Component("IDEMPOTENT_INTERFACE")
@Slf4j
public class InterfacesIdempotentStrategy implements IdempotentStrategyInterface {
public static final String PREFIX = "idempotent_interface_";
@Override
public String process(ProceedingJoinPoint pjp) throws IllegalAccessException {
log.info("idempotent strategy:{}", IdempotentStrategy.IDEMPOTENT_INTERFACE.name());
Object[] args = pjp.getArgs();
Object dto = null;
for (Object arg : args) {
boolean assignableFrom = IdempotentInterface.class.isAssignableFrom(arg.getClass());
if (assignableFrom) {
dto = arg;
break;
}
}
if (dto == null) {
log.info("no class implements of IdempotentStrategyInterface in list of parameter");
return null;
}
StringBuilder keyStr = new StringBuilder(PREFIX);
Field[] fields = dto.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
IdempotentField idempotentField = field.getAnnotation(IdempotentField.class);
if (null == idempotentField) {
continue;
}
keyStr.append(field.get(dto)).append("_");
}
if (keyStr.toString().equals(PREFIX)) {
log.info("use dto");
keyStr.append(JSONObject.toJSONString(dto));
} else {
log.info("use idempotentField");
keyStr.substring(0, keyStr.length() - 1);
}
return keyStr.toString();
}
}
/**
* LIST_PARAMETER策略
* 不允许参数列表为空
*
* @author: 王锰
* @date: 2018/8/20
*/
@Component("LIST_PARAMETER")
@Slf4j
public class ListParameterIdempotentStrategy implements IdempotentStrategyInterface {
public static final String PREFIX = "list_parameter_";
@Override
public String process(ProceedingJoinPoint pjp) {
log.info("idempotent strategy:{}", IdempotentStrategy.LIST_PARAMETER.name());
Object[] args = pjp.getArgs();
StringBuilder keyStr = new StringBuilder(PREFIX);
keyStr.append(JSONObject.toJSONString(Arrays.asList(args)));
return keyStr.toString();
}
}
做策略时有个默认约定就是有问题了返回null。所以在AOP中进行了null值判断。
好了,到这里已经完事了。
我又做了一个demo项目,写了一个controller
@PostMapping("/post1")
@Idempotent(timeout = 600,strategy = IdempotentStrategy.BASE_IDEMPOTENT_DTO)
public Resp<User> post1(@RequestBody User user) throws InterruptedException {
log.info("user:{}", JSONObject.toJSONString(user));
Thread.sleep(3000);
User user1 = new User();
user1.setId(1234);
user1.setName("wm");
return Resp.ok(user1);
}
设置超时时间600秒,策略,继承dto。测试结果不发了。是没什么问题的。第一次请求进来,会请求到方法,第二次进来,就直接返回了。不同的请求不会互相影响。
如果有什么问题或者性能优化的点。可以指出大家共同探讨。就到这吧。
哦,对了。我这里其实是做到了一个脚手架里。demo直接引入的jar包就可以用了。后续再完善好了把脚手架发上来吧。