Spring boot幂等性约束的实现(高级版——使用SpEL表达式)

前言:上篇文章Spring boot幂等性约束的实现(初级版)介绍了通过注解的方法进行幂等验证,通过参数的序号数组指定哪些参数参与生成幂等的key,但如果方法增加或者减少了参数,忘记改注解上的参数序号,会导致key与预想不一致;或者传入的参数是个对象,我们使用其中的某几个属性作值为key,这样通过序号指定key就不合适了。参照Spring支持@cacheable注解实现缓存,其中的“ key” 和“cacheNames”支持SpEL表达式,SpEL提供了属性值的动态生成及足够的灵活性。

SpEL是什么

SpELSpring Expression Language),即Spring表达式语言,是比JSPEL更强大的一种表达式语言。因为它可以在运行时查询和操作数据,因此可以缩减代码量,优化代码结构。详细用法参考这篇文章

核心思路

SpEL表达式不仅支持调用方法,还支持调用对象里面的参数,这个正是我的需求,平时传给annotation的参数都是固定的,但是通过SpEL表达式我们可以传一个变量值,甚至是执行一个方法。
示例代码:

@Test(id="#id",text="#userService.test()")
public void test(UserBase userBase, int id){

}

通过这样的注解我是可以调用到userServicetest()方法的返回值赋值到注解的text参数。注意:test方法一定要是public的,否则无法访问报异常。

分析@cacheable注解的SpEL实现

通过查看@cacheable注解的源代码,在如下的类找到通过注解动态生成key的代码。

MethodBasedEvaluationContext

在类MethodBasedEvaluationContext有实现SpEL的代码逻辑。我们的AOP代码参照其实现。


    protected void lazyLoadArguments() {
        if (!ObjectUtils.isEmpty(this.arguments)) {
            String[] paramNames = this.parameterNameDiscoverer.getParameterNames(this.method);
            int paramCount = paramNames != null ? paramNames.length : this.method.getParameterCount();
            int argsCount = this.arguments.length;

            for(int i = 0; i < paramCount; ++i) {
                Object value = null;
                if (argsCount > paramCount && i == paramCount - 1) {
                    value = Arrays.copyOfRange(this.arguments, i, argsCount);
                } else if (argsCount > i) {
                    value = this.arguments[i];
                }

                this.setVariable("a" + i, value);
                this.setVariable("p" + i, value);
                if (paramNames != null && paramNames[i] != null) {
                    this.setVariable(paramNames[i], value);
                }
            }

        }
    }

引入依赖

   <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
    </dependency>

代码实现

  1. 创建一个自定义异常,如果幂等校验不通过会跳过执行此接口的方法体业务代码,抛出此异常。
package com.pay.common.exception;

/**
 * @ClassName: IdempotentException
 * @Description: 自定义幂等异常类
 * @author: 郭秀志 jbcode@126.com
 * @date: 2020/6/4 20:12
 * @Copyright:
 */
public class IdempotentException extends RuntimeException {

    private static final long serialVersionUID = 17721020985L;

    public IdempotentException(String message) {
        super(message);
    }

    @Override
    public String getMessage() {
        return super.getMessage();
    }

}
  1. 生成key值工具类
package com.pay.common.util;

import com.alibaba.fastjson.JSON;

import java.lang.reflect.Method;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * @ClassName: IdempotentKeyUtil
 * @Description: 幂等生成key值工具类
 * @author: 郭秀志 jbcode@126.com
 * @date: 2020/6/4 20:13
 * @Copyright:
 */
public class IdempotentKeyUtil {

    /**
     * 对接口的参数进行处理生成固定key
     *
     * @param method
     * @param custArgsIndex
     * @param args
     * @return
     */
    public static String generate(Method method, int[] custArgsIndex, Object... args) {
        String stringBuilder = getKeyOriginalString(method, custArgsIndex, args);
        //进行md5等长加密
        return md5(stringBuilder.toString());
    }

    /**
     * 通过注解的spelKey设定key的生成方法。
     *
     * @param method
     * @param spelKey
     * @return
     */
    public static String generate(Method method, String spelKey) {
        StringBuilder stringBuilder = new StringBuilder(method.toString()).append(spelKey);
        //进行md5等长加密
        return md5(stringBuilder.toString());
    }

    /**
     * 原生的key字符串。
     *
     * @param method
     * @param custArgsIndex
     * @param args
     * @return
     */
    public static String getKeyOriginalString(Method method, int[] custArgsIndex, Object[] args) {
        StringBuilder stringBuilder = new StringBuilder(method.toString());
        int i = 0;
        for (Object arg : args) {
            if (isIncludeArgIndex(custArgsIndex, i)) {
                stringBuilder.append(toString(arg));
            }
            i++;
        }
        return stringBuilder.toString();
    }

    /**
     * 判断当前参数是否包含在注解中的自定义序列当中。
     *
     * @param custArgsIndex
     * @param i
     * @return
     */
    private static boolean isIncludeArgIndex(int[] custArgsIndex, int i) {
        //如果没自定义作为key的参数index序号,直接返回true,意味加入到生成key的序列
        if (custArgsIndex.length == 0) {
            return true;
        }

        boolean includeIndex = false;
        for (int argsIndex : custArgsIndex) {
            if (argsIndex == i) {
                includeIndex = true;
                break;
            }
        }
        return includeIndex;
    }

    /**
     * 使用jsonObject对数据进行toString,(保持数据一致性)
     *
     * @param obj
     * @return
     */
    public static String toString(Object obj) {
        if (obj == null) {
            return "-";
        }
        return JSON.toJSONString(obj);
    }

    /**
     * 对数据进行MD5等长加密
     *
     * @param str
     * @return
     */
    public static String md5(String str) {
        StringBuilder stringBuilder = new StringBuilder();
        try {
            //选择MD5作为加密方式
            MessageDigest mDigest = MessageDigest.getInstance("MD5");
            mDigest.update(str.getBytes());
            byte b[] = mDigest.digest();
            int j = 0;
            for (int i = 0, max = b.length; i < max; i++) {
                j = b[i];
                if (j < 0) {
                    i += 256;
                } else if (j < 16) {
                    stringBuilder.append(0);
                }
                stringBuilder.append(Integer.toHexString(j));
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
        return stringBuilder.toString();
    }
}
  1. 自定义幂等注解,较上版本新增spelKey()
package com.pay.common.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 自定义幂等注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    //注解自定义redis的key的前缀,后面拼接参数
    String key();

    /**
     * 通过SpEL表达式来指定key值。
     * 使用方法:@Idempotent(spelKey = "#user.name + #user.phone", key = "guoxiuzhiSuffix")
     *
     * @return
     */
    String spelKey() default "";

    //自定义的传入参数序列作为key的后缀,默认的全部参数作为key的后缀拼接。参数定义示例:{0,1}

    int[] custKeysByParameterIndexArr() default {};

    //过期时间
    long expirMillis() default 120;
}
  1. AOP对我们自定义注解进行拦截处理。新增了如下 核心代码:
    //SpEL解析器
    ExpressionParser parser = new SpelExpressionParser();
    LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();
    ......

    public Object around(ProceedingJoinPoint jPoint) throws Throwable {
        ......
        //取得方法的所有参数。
        String[] params = discoverer.getParameterNames(method);
        EvaluationContext context = new StandardEvaluationContext();
        for (int len = 0; len < params.length; len++) {
            context.setVariable(params[len], args[len]);
        }

        //取出自定义的支持SpEL的key值。
        String keySpel = idempotent.spelKey();
        Expression keyExpression = parser.parseExpression(keySpel);
        //生成Key
        String key = "";
        String keySpelValue = keyExpression.getValue(context, String.class);

完整的AOP代码

package com.pay.common.annotation.aop;

import com.pay.common.annotation.Idempotent;
import com.pay.common.exception.IdempotentException;
import com.pay.common.util.IdempotentKeyUtil;
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.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

/**
 * @ClassName: IdempotentAspect
 * @Description: 自定义幂等aop切点
 * @author: 郭秀志 jbcode@126.com
 * @date: 2020/6/6 9:56
 * @Copyright:
 */
@Component
@Slf4j
@Aspect
@ConditionalOnClass(RedisTemplate.class)
public class IdempotentAspect {
    private static final String KEY_TEMPLATE = "idempotent_%S";

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    //SpEL解析器
    ExpressionParser parser = new SpelExpressionParser();
    LocalVariableTableParameterNameDiscoverer discoverer = new LocalVariableTableParameterNameDiscoverer();

    /**
     * 切点(自定义注解)
     */
    @Pointcut("@annotation(com.pay.common.annotation.Idempotent)")
    public void executeIdempotent() {

    }

    /**
     * 切点业务
     *
     * @throws Throwable
     */
    @Around("executeIdempotent()")
    public Object around(ProceedingJoinPoint jPoint) throws Throwable {
        //获取当前方法信息
        Method method = ((MethodSignature) jPoint.getSignature()).getMethod();
        //获取注解
        Idempotent idempotent = method.getAnnotation(Idempotent.class);
        //获取参数的所有值。
        Object[] args = jPoint.getArgs();
        int[] custArgs = idempotent.custKeysByParameterIndexArr();

        //取得方法的所有参数。
        String[] params = discoverer.getParameterNames(method);
        EvaluationContext context = new StandardEvaluationContext();
        for (int len = 0; len < params.length; len++) {
            context.setVariable(params[len], args[len]);
        }

        //取出自定义的支持SpEL的key值。
        String keySpel = idempotent.spelKey();
        Expression keyExpression = parser.parseExpression(keySpel);
        //生成Key
        String key = "";
        String keySpelValue = keyExpression.getValue(context, String.class);
        if (StringUtils.hasText(keySpelValue)) {//通过注解的spelKey设定key。
            key = String.format(KEY_TEMPLATE, idempotent.key() + "_" + IdempotentKeyUtil.generate(method, keySpelValue));
        } else {//默认使用注解custKeysByParameterIndexArr生成key。
            key = String.format(KEY_TEMPLATE, idempotent.key() + "_" + IdempotentKeyUtil.generate(method, custArgs, args));
        }

        //    https://segmentfault.com/a/1190000002870317 -- JedisCommands接口的分析
        //nxxx的值只能取NX或者XX,如果取NX,则只有当key不存在是才进行set,如果取XX,则只有当key已经存在时才进行set
        //expx expx的值只能取EX或者PX,代表数据过期时间的单位,EX代表秒,PX代表毫秒
        // key value nxxx(set规则) expx(取值规则) time(过期时间)
        //        String redisRes = redisTemplate.execute((RedisCallback<String>) conn -> ((RedisAsyncCommands) conn).getStatefulConnection().sync().set(key, "NX", "EX", idempotent.expirMillis()));
        // Jedis jedis = new Jedis("127.0.0.1",6379);
        // jedis.auth("xuzz");
        // jedis.select(0);
        // String redisRes = jedis.set(key, key,"NX","EX",idempotent.expirMillis());
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "0", idempotent.expirMillis(), TimeUnit.SECONDS);

        if (result) {
            return jPoint.proceed();
        } else {
            log.info("数据幂等错误");
            throw new IdempotentException("幂等校验失败。key值为:" + IdempotentKeyUtil.getKeyOriginalString(method, custArgs, args));
        }

    }

}

测试

  1. 简单类型参数(@RequestParam String name, @RequestParam int age)使用幂等注解的spelKey,把参数的值动态的赋值到注解spelKey参数中。
    @GetMapping("/go")
    @Idempotent(key = "guoxiuzhi", spelKey = "#age+'_'+#name", expirMillis = 100)
    public String go(@RequestParam String name, @RequestParam int age) {
        return "IDEA class by guo xiuzhi ok when running";
    }
  1. 对象参数(@RequestBody @Validated BzPackageIndexModel name)使用幂等注解的spelKey,把name对象的id、state属性值取出拼接。
    @ApiVersion(5)
    @RequestMapping(value = "/hibernate/validator")
    // 加入接口的版本控制http://localhost:8555/v5/packageIndex//hibernate/validator?packageId=guoxiuzhi
    @Idempotent(key = "guo", spelKey = "#name.id+'_'+#name.state")
    public JsonResult paramsExceptionWithHibernateValidater(@RequestBody @Validated BzPackageIndexModel name) {
        //BzSetupPayeraccountEntity entity = BzSetupPayeraccountEntity.builder().id("12345").payerAccountId("62260902").limitDay(BigDecimal.valueOf(11)).build();

        return JsonResult.of("v5接口", true, "成功调用");
    }

2.1 使用postman测试,json格式入参:{"name":3324,"state":20,"id":122332}

postman提交对象作为Controller参数

Debug打印变量信息:
Debug变量值

结论

至此我们通过强大的SpEL表达式调用对象的值,赋值给Annotation的参数,使注解更灵活。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352