最近自己在做一套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包就可以用了。後續再完善好了把腳手架發上來吧。