Redis實現接口冪等

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章