美文网首页简友广场
Spring boot幂等性约束的实现(初级版)

Spring boot幂等性约束的实现(初级版)

作者: 梅西爱骑车 | 来源:发表于2020-06-06 06:36 被阅读0次

    在分布式服务中,业务在高并发或者可能被多次调用的情况下,同一个请求会出现多次。这个时候如果执行插入的业务操作,则数据库中出现多条数据,产生了脏数据,同时也是对资源的浪费。
    此时我们需要阻止多余业务的处理操作。

    实现方案

    实现接口的幂等性,让请求只成功一次。这里需要保存一个唯一标识key,在下一个相同请求(类似表的唯一索引,请求的时间戳不同但几个核心参数相同即认为相同请求)执行时获取是否存在标识,如果重复提交则阻止执行。

    引入依赖

           <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/6 06:06
     * @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());
        }
    
        /**
         * 原生的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. 自定义幂等注解
    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();
    
        //自定义的传入参数序列作为key的后缀,默认的全部参数作为key的后缀拼接。参数定义示例:{0,1}
        int[] custKeysByParameterIndexArr() default {};
    
        //过期时间,单位秒。可以是毫秒,需要修改切点类的设置redis值的代码参数。
        long expirMillis();
    }
    
    1. 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.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Component;
    
    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;
    
        /**
         * 切点(自定义注解)
         */
        @Pointcut("@annotation(com.pay.common.annotation.Idempotent)")
        public void executeIdempotent() {
    
        }
    
        /**
         * 切点业务
         *
         * @throws Throwable
         */
        @Around("executeIdempotent()")
        public Object arountd(ProceedingJoinPoint jPoint) throws Throwable {
            //获取当前方法信息
            Method method = ((MethodSignature) jPoint.getSignature()).getMethod();
            //获取注解
            Idempotent idempotent = method.getAnnotation(Idempotent.class);
            //生成Key
            Object[] args = jPoint.getArgs();
            int[] custArgs = idempotent.custKeysByParameterIndexArr();
    
            String 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(过期时间)
    
            //低版本`Springboot`使用如下方法
    //        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));
            }
        }
    
    }
    

    说明:

    • 如果是Springboot 2.X 以上版本,其Redis使用lettuce,使用如上代码。低版本Springboot用已经注释的代码String redisRes = redisTemplate.execute((RedisCallback<String>) conn -> ((RedisAsyncCommands) conn).getStatefulConnection().sync().set(key, "NX", "EX", idempotent.expirMillis()));返回结果为字符串OK不是true
    • 由于我把幂等功能抽到了common模块,供其他业务模块使用,所以需要暴露这个aop,使依赖common的其项目能自动扫描到这个注解了@Component @Aspect的类,使切点生效。common项目需要手动添加如下文件夹及文件。spring.factories文件,则是用来记录项目包外需要注册的bean类名,帮助Spring boot项目包以外的bean(即在pom文件中添加依赖中的bean)注册到Spring boot项目的spring容器。
      META-INF\spring.factories
      文件内容(本文只需要最后一行):
    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
    com.pay.common.autoconfig.schedlock.ShedlockConfig,\
    com.pay.common.service.MailService,\
    com.pay.common.exception.handler.GlobalExceptionHandler,\
    com.pay.common.annotation.aop.IdempotentAspect
    
    1. Controller的方法使用该注解
    @RestController
    public class guoController {
    
        private String name;
    
        @GetMapping("/go")
        @Idempotent(key = "IndexRecordServiceImplKey", expirMillis = 100)
        public String go(@RequestParam String name, @RequestParam int age) {
            return "IDEA class by guo xiuzhi ok when running";
        }
    

    说明
    注解有个参数项——custKeysByParameterIndexArr,实现通过指定参数的序号定义使用哪几个参数作为key,0代表第一个参数,1代表第二个参数......,如果该参数项空,默认使用所有参数拼接为key

    @Idempotent(key = "IndexRecordServiceImplKey", expirMillis = 100)默认是把java方法go的所有参数name和age作为key来进行幂等。等同@Idempotent(key = "IndexRecordServiceImplKey", custKeysByParameterIndexArr = {0,1}, expirMillis = 100)

    如果只想用第一个参数作为key,写法@Idempotent(key = "IndexRecordServiceImplKey", custKeysByParameterIndexArr = {0}, expirMillis = 100)

    测试

    第一次访问该url——http://localhost:8085/go?name=guo&age=40,正常返回结果:

    GET http://localhost:8085/go?name=guo&age=40
    
    HTTP/1.1 200 
    Content-Type: text/plain;charset=ISO-8859-1
    Content-Length: 40
    Date: Fri, 05 Jun 2020 22:21:59 GMT
    Keep-Alive: timeout=60
    Connection: keep-alive
    
    IDEA class by guo xiuzhi ok when running
    
    Response code: 200; Time: 11002ms; Content length: 40 bytes
    

    第二次访问,被幂等异常拦截:

    GET http://localhost:8085/go?name=guo&age=40
    
    HTTP/1.1 500 
    Content-Type: application/xml;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Fri, 05 Jun 2020 22:23:11 GMT
    Connection: close
    
    <Map>
        <timestamp>1591395791847</timestamp>
        <status>500</status>
        <error>Internal Server Error</error>
        <trace>com.pay.common.exception.IdempotentException: 幂等校验失败。key值为:public java.lang.String com.pay.payee.controller.guoController.go(java.lang.String,int)"guo"40;&#xd;
            at com.pay.common.annotation.aop.IdempotentAspect.arountd(IdempotentAspect.java:73)&#xd;
            at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)&#xd;
            at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)&#xd;
            at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)&#xd;
            at java.lang.reflect.Method.invoke(Method.java:498)&#xd;
    ......
    

    这个异常展示不友好,使用异常全局处理,实现方法见我另一篇文章Spring boot全局异常处理

        /*
         * @Description 幂等验证
         * @Param [ex]
         * @return com.pay.common.message.JsonResult
         */
        @ExceptionHandler(value = IdempotentException.class)
        public JsonResult resolveIdempotentException(IdempotentException ex) {
            return JsonResult.of(ex.getMessage() + ";", false, ResultEnum.IDEMPOTENT_KEY_DUPLICATE.getCode(), ResultEnum.IDEMPOTENT_KEY_DUPLICATE.getMessage());
        }
    

    返回xml格式信息

    GET http://localhost:8085/go?name=guo&age=40
    
    HTTP/1.1 200 
    Content-Type: application/xml;charset=UTF-8
    Transfer-Encoding: chunked
    Date: Sat, 06 Jun 2020 01:52:18 GMT
    Keep-Alive: timeout=60
    Connection: keep-alive
    
    <JsonResult>
        <data>幂等校验失败。key值为:public java.lang.String com.pay.payee.controller.guoController.go(java.lang.String,int)"guo"40;
        </data>
        <flag>false</flag>
        <code>5012</code>
        <msg>IDEMPOTENT_KEY_DUPLICATE</msg>
    </JsonResult>
    
    Response code: 200; Time: 1062ms; Content length: 216 bytes
    

    通过SpEL实现

    参照Spring boot上支持@cacheable的缓存,其中的“key” 和“cacheNames”支持spel表达式,这样更符合自定义key的用法,而不是通过序号指定第几个参数。详见:Spring boot幂等性约束的实现(高级版)

    相关文章

      网友评论

        本文标题:Spring boot幂等性约束的实现(初级版)

        本文链接:https://www.haomeiwen.com/subject/ozkttktx.html