Spring Boot接口幂等性的处理

Spring Boot接口幂等性的处理

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

实现方案

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

引用Redis依赖

 		 <!--引入Redis支持-->
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.4</version>
        </dependency>
    </dependencies>

代码实现

1.自定义幂等注解

package com.nscw.freshkeruyun.config.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();
}

2.生成key值工具类

package com.nscw.freshkeruyun.utils;

import com.alibaba.fastjson.JSON;

import java.lang.reflect.Method;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
 * Copyright (C), 2019-2020
 * FileName: IdempotentKeyUtil
 * Author:   SixJR.
 * Date:     2020年11月17日 10:09
 * Description: 生成key值工具类
 * History:
 * <author>          <time>          <version>          <desc>
 */

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 (args[i] instanceof ServletRequest || args[i] instanceof ServletResponse || args[i] instanceof MultipartFile) {
    
    
                /* ServletRequest不能序列化,从入参里排除,否则报异常:java.lang.IllegalStateException: It is illegal to call this method if the current request is not in asynchronous mode (i.e. isAsyncStarted() returns false)
                ServletResponse不能序列化 从入参里排除,否则报异常:java.lang.IllegalStateException: getOutputStream() has already been called for this response */
                continue;
            }

            if (isIncludeArgIndex(custArgsIndex, i)) {
    
    
//                System.out.println("arg---------->"+arg);
                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();
    }
}

3.创建一个自定义异常,跳过执行接口的方法体业务代码,抛出此异常。

package com.nscw.freshkeruyun.config.error;

/**
 * Copyright (C), 2019-2020
 * FileName: IdempotentException
 * Author:   SixJR.
 * Date:     2020年11月16日 16:07
 * Description: 幂等自定义异常处理代码
 * History:
 * <author>          <time>          <version>          <desc>
 */

public class IdempotentException extends RuntimeException {
    
    
    public IdempotentException(String message) {
    
    
        super(message);
    }

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

4.AOP对我们自定义注解进行拦截处理

package com.nscw.freshkeruyun.config;
import com.nscw.freshkeruyun.config.annotation.Idempotent;
import com.nscw.freshkeruyun.config.error.IdempotentException;
import com.nscw.freshkeruyun.utils.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;
/**
 * Copyright (C), 2019-2020
 * FileName: IdempotentAspect
 * Author:   SixJR.
 * Date:     2020年11月17日 10:11
 * Description: 自定义幂等aop切点
 * History:
 * <author>          <time>          <version>          <desc>
 */
@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.nscw.freshkeruyun.config.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。

5.在Controller的方法使用改注解

    @PostMapping("orderStatePush2")
    @Idempotent(key = "orderStatePush2", expirMillis = 100)
    public KeruyunResultDto orderStatePush2(@RequestParam("name") String name,@RequestParam("age") int name) {
    
    
        KeruyunResultDto keruyunResultDto = new KeruyunResultDto();
        keruyunResultDto.setCode(CodeConfig.getCode0());
        keruyunResultDto.setMessage("OK");
        keruyunResultDto.setMessageUuid(UUID.randomUUID().toString());
        System.out.println("订单状态推送:" + JSONObject.toJSONString(name));
        return keruyunResultDto;
    }

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

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

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

接下来是测试结果

第一次请求:幂等校验通过
在这里插入图片描述
第二次请求:幂等校验不通过,说明已经被请求过了
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/m0_43413873/article/details/109742532