Springboot + redis + 注解 + 拦截器来实现接口幂等性校验

来源:jianshu.com/p/6189275403ed

一,概念

幂等性,通俗得说就是一个接口,多次发起同一个请求,必须保证操作只能执行一次
比如:

  • 订单接口,不能多次创建订单
  • 支付接口,重复支付同一笔订单只能扣一次钱
  • 支付宝回调接口,可能会多次回调,必须处理重复回调
  • 普通表单提交接口,因为网络超时等原因多次点击提交,只能成功一次等等

二,常见解决方案

  • 唯一索引 – 防止新增脏数据
  • token机制 – 防止页面重复提交
  • 悲观锁 – 获取数据的时候加锁(锁表或锁行)
  • 乐观锁 – 基于版本号version实现,在更新数据那一刻校验数据
  • 分布式锁 – redis(jedis,redisson)或zookeeper实现
  • 状态机 – 状态变更,更新数据时判断状态

三,本文实现

本文采用第二种实现方式,即通过 redis + token机制实现接口幂等性校验

四,实现思路

为需要保证幂等性的每一次请求创建一个唯一标识token,先获取token,并将此token存入redis,请求接口时,将此token放到header或者作为请求参数请求接口,后端接口判断redis中是否存在此token:

  • 如果存在,正常处理业务逻辑,并从redis中删除此token,那么,如果是重复请求,由于token已被删除,则不能通过检验,返回请勿重复操作提示
  • 如果不存在,说明参数不合法或者是重复请求,返回提示即可。

五,项目简介

  • spring boot
  • redis
  • @Apildempotent注解 + 拦截器对请求进行拦截
  • @ControllerAdvice全局异常处理
  • 压测工具:jmeter

说明:
本文重点介绍幂等性核心实现,关于spring boot如何集成redis,ServerResponse,ResponseCode等细枝末节不在本文讨论范围之内。

六,代码实现

pom

<!-- Redis-Jedis -->
		<dependency>
			<groupId>redis.clients</groupId>
			<artifactId>jedis</artifactId>
			<version>2.9.0</version>
		</dependency>

		<!--lombok 本文用到@Slf4j注解, 也可不引用, 自定义log即可-->
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.16.10</version>
		</dependency>

JedisUtil

package com.hrh.interfacecheck.util;

import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;


/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
@Component
public class JedisUtil {
    @Autowired
    private JedisPool jedisPool;

    private Jedis getJedis(){
        return this.jedisPool.getResource();
    }

    /**
     * 设值
     * @param key
     * @param value
     * @return
     */
    public String set(String key,String value){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.set(key,value);
        } catch(Exception e){
            //logger.error("set key:{} value:{} error",key,value,e);
            System.out.println("set key:{"+key+"} value:{"+value+"} error");
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设值
     * @param key
     * @param value
     * @param expireTime
     * @return
     */
    public String set(String key,String value,int expireTime){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.setex(key,expireTime,value);
        } catch (Exception e){
            //logger.error("set key:{} value:{} expireTime:{} error",key,value,expireTime,e);
            System.out.println("set key:{"+key+"} value:{"+value+"} expireTime:{"+expireTime+" error ");
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 取值
     * @param key
     * @return
     */
    public String get(String key){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.get(key);
        } catch (Exception e){
            //logger.error("get key:{} error",key,e);
            System.out.println("get key:{"+key+"} ");
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 删除key
     * @param key
     * @return
     */
    public Long del(String key){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.del(key.getBytes());
        } catch (Exception e){
            //logger.error("del key:{} error",key,e);
            System.out.println("del key:{"+key+"} ");
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 判断key是否存在
     * @param key
     * @return
     */
    public Boolean exists(String key){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.exists(key.getBytes());
        } catch (Exception e){
            //logger.error("exists key:{} error",key,e);
            System.out.println("exists key:{"+key+"} ");
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 设置key过期时间
     * @param key
     * @param expireTime
     * @return
     */
    public Long expire(String key,int expireTime){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.expire(key.getBytes(),expireTime);
        } catch (Exception e){
            //logger.error("expire key:{} error",key,e);
            System.out.println("expire key:{"+key+"} ");
            return null;
        } finally {
            close(jedis);
        }
    }

    /**
     * 获取剩余时间
     * @param key
     * @return
     */
    public Long ttl(String key){
        Jedis jedis = null;
        try {
            jedis = getJedis();
            return jedis.ttl(key);
        } catch (Exception e){
            //logger.error("ttl key:{} error",key,e);
            System.out.println("ttl key:{"+key+"} ");
            return null;
        } finally {
            close(jedis);
        }
    }

    public void close(Jedis jedis){
        if(null != jedis){
            jedis.close();
        }
    }

}

自定义注解@ApiIdempotent

package com.hrh.interfacecheck.annotation;

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

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述 在需要保证 接口幂等性 的Controller的方法上使用此注解
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}

ApiIdempotentInterceptor拦截器

package com.hrh.interfacecheck.Interceptor;

import com.hrh.interfacecheck.annotation.ApiIdempotent;
import com.hrh.interfacecheck.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述 接口幂等性拦截器
 */
public class ApiIdempotentInterceptor implements HandlerInterceptor{

    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if(!(handler instanceof HandlerMethod)){
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();

        ApiIdempotent methodAnnotation = method.getAnnotation(ApiIdempotent.class);
        if(methodAnnotation != null){
            /** 幂等性校验,校验通过则放行,校验失败则抛出异常,并通过统一异常处理返回友好提示 */
            check(request);
        }
        return true;
    }

    private void check(HttpServletRequest request){
        tokenService.checkToken(request);
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

    }
}

TokenService

package com.hrh.interfacecheck.service;


import com.hrh.interfacecheck.common.ServerResponse;

import javax.servlet.http.HttpServletRequest;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
public interface TokenService {

    ServerResponse createToken();

    void checkToken(HttpServletRequest request);
}

TokenServiceImpl

package com.hrh.interfacecheck.service.impl;

import com.hrh.interfacecheck.common.Constant;
import com.hrh.interfacecheck.common.ServerResponse;
import com.hrh.interfacecheck.service.TokenService;
import com.hrh.interfacecheck.util.JedisUtil;
import com.hrh.interfacecheck.util.KeyUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import javax.servlet.http.HttpServletRequest;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
@Service
public class TokenServiceImpl  implements TokenService {

    private static final String TOKEN_NAME = "token";

    @Autowired
    private JedisUtil jedisUtil;

    @Override
    public ServerResponse createToken() {
        String str = KeyUtils.genUniqueKey();
        StringBuilder token = new StringBuilder();
        token.append(Constant.Redis.TOKEN_PREFIX).append(str);
        jedisUtil.set(token.toString(),token.toString(),Constant.Redis.EXPIRE_TIME_MINUTE);

        return ServerResponse.success(token.toString());
    }

    @Override
    public void checkToken(HttpServletRequest request) {
        String token = request.getHeader(TOKEN_NAME);
        if(StringUtils.isEmpty(token)){//head中不存在token
            token = request.getParameter(TOKEN_NAME);
            if(StringUtils.isEmpty(token)){//parameter中也不存在token
                try {
                    throw new Exception("");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
        if(!jedisUtil.exists(token)){
            try {
                throw new Exception("");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        Long del = jedisUtil.del(token);
        if(del<=0){
            try {
                throw new Exception("");
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}

TestService

**package com.hrh.interfacecheck.service;

import com.hrh.interfacecheck.common.ServerResponse;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
public interface TestService {

    ServerResponse testIdempotence();

    ServerResponse accessLimit();

}
**

TestServiceImpl

package com.hrh.interfacecheck.service.impl;

import com.hrh.interfacecheck.common.ServerResponse;
import com.hrh.interfacecheck.service.TestService;
import org.springframework.stereotype.Service;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
@Service
public class TestServiceImpl implements TestService {
    @Override
    public ServerResponse testIdempotence() {
        return ServerResponse.success("testIdempotence: success");
    }

    @Override
    public ServerResponse accessLimit() {
        return ServerResponse.success("accessLimit: success");
    }
}

InterfacecheckApplication

package com.hrh.interfacecheck;

import com.hrh.interfacecheck.Interceptor.ApiIdempotentInterceptor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

@SpringBootApplication
public class InterfacecheckApplication extends WebMvcConfigurerAdapter{

	public static void main(String[] args) {
		SpringApplication.run(InterfacecheckApplication.class, args);
	}

	/**
	 * 跨域
	 * @return
	 */
	@Bean
	public CorsFilter corsFilter(){
		final UrlBasedCorsConfigurationSource urlBasedCorsConfigurationSource = new UrlBasedCorsConfigurationSource();
		final CorsConfiguration corsConfiguration = new CorsConfiguration();
		corsConfiguration.setAllowCredentials(true);
		corsConfiguration.addAllowedOrigin("*");
		corsConfiguration.addAllowedHeader("*");
		corsConfiguration.addAllowedMethod("*");
		urlBasedCorsConfigurationSource.registerCorsConfiguration("/**",corsConfiguration);
		return new CorsFilter(urlBasedCorsConfigurationSource);
	}

	@Override
	public void addInterceptors(InterceptorRegistry registry) {
		/** 接口幂等性拦截器 */
		registry.addInterceptor(apiIdempotentInterceptor());
		super.addInterceptors(registry);
	}
	@Bean
	public ApiIdempotentInterceptor apiIdempotentInterceptor(){
		return new ApiIdempotentInterceptor();
	}
}

KeyUtils

package com.hrh.interfacecheck.util;

import java.util.Random;

/**
 * @创建人 hrh
 * @创建时间 2020/1/4 0004
 * @描述
 */
public class KeyUtils {
    /**
     * 生成唯一主键
     * 格式:时间+随机数
     */

    public static synchronized  String genUniqueKey(){
        Random random = new Random();
        Integer number = random.nextInt(900000) + 100000;
        return System.currentTimeMillis() + String.valueOf(number);
    }
}

Constant

package com.hrh.interfacecheck.common;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
public class Constant {

    public interface Redis {
        String OK = "OK";
        Integer EXPIRE_TIME_MINUTE = 60;// 过期时间, 60s, 一分钟
        Integer EXPIRE_TIME_HOUR = 60 * 60;// 过期时间, 一小时
        Integer EXPIRE_TIME_DAY = 60 * 60 * 24;// 过期时间, 一天
        String TOKEN_PREFIX = "token:";
        String MSG_CONSUMER_PREFIX = "consumer:";
        String ACCESS_LIMIT_PREFIX = "accessLimit:";
    }

    public interface LogType {
        Integer LOGIN = 1;// 登录
        Integer LOGOUT = 2;// 登出
    }

    public interface MsgLogStatus {
        Integer DELIVERING = 0;// 消息投递中
        Integer DELIVER_SUCCESS = 1;// 投递成功
        Integer DELIVER_FAIL = 2;// 投递失败
        Integer CONSUMED_SUCCESS = 3;// 已消费
    }

}

ResponseCode

package com.hrh.interfacecheck.common;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
/**
 * 响应状态码
 */
public enum ResponseCode {

    // 系统模块
    SUCCESS(0, "操作成功"),
    ERROR(1, "操作失败"),
    SERVER_ERROR(500, "服务器异常"),

    // 通用模块 1xxxx
    ILLEGAL_ARGUMENT(10000, "参数不合法"),
    REPETITIVE_OPERATION(10001, "请勿重复操作"),
    ACCESS_LIMIT(10002, "请求太频繁, 请稍后再试"),
    MAIL_SEND_SUCCESS(10003, "邮件发送成功"),

    // 用户模块 2xxxx
    NEED_LOGIN(20001, "登录失效"),
    USERNAME_OR_PASSWORD_EMPTY(20002, "用户名或密码不能为空"),
    USERNAME_OR_PASSWORD_WRONG(20003, "用户名或密码错误"),
    USER_NOT_EXISTS(20004, "用户不存在"),
    WRONG_PASSWORD(20005, "密码错误"),

    // 订单模块 4xxxx

    ;

    ResponseCode(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    private Integer code;

    private String msg;

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }
}

ServerResponse

package com.hrh.interfacecheck.common;

import com.fasterxml.jackson.annotation.JsonIgnore;

import java.io.Serializable;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
public class ServerResponse implements Serializable {

    private static final long serialVersionUID = 7498483649536881777L;

    private Integer status;

    private String msg;

    private Object data;

    public ServerResponse() {
    }

    public ServerResponse(Integer status, String msg, Object data) {
        this.status = status;
        this.msg = msg;
        this.data = data;
    }

    @JsonIgnore
    public boolean isSuccess() {
        return this.status == ResponseCode.SUCCESS.getCode();
    }

    public static ServerResponse success() {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), null, null);
    }

    public static ServerResponse success(String msg) {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), msg, null);
    }

    public static ServerResponse success(Object data) {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), null, data);
    }

    public static ServerResponse success(String msg, Object data) {
        return new ServerResponse(ResponseCode.SUCCESS.getCode(), msg, data);
    }

    public static ServerResponse error(String msg) {
        return new ServerResponse(ResponseCode.ERROR.getCode(), msg, null);
    }

    public static ServerResponse error(Object data) {
        return new ServerResponse(ResponseCode.ERROR.getCode(), null, data);
    }

    public static ServerResponse error(String msg, Object data) {
        return new ServerResponse(ResponseCode.ERROR.getCode(), msg, data);
    }

    public Integer getStatus() {
        return status;
    }

    public void setStatus(Integer status) {
        this.status = status;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }
}

JedisConfig

package com.hrh.interfacecheck.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
 * @创建人 hrh
 * @创建时间 2020/2/14 0014
 * @描述
 */
@Configuration
public class JedisConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Value("${spring.redis.password}")
    private String password;

    @Value("${spring.redis.jedis.pool.max-idle}")
    private int maxIdle;

    @Value("${spring.redis.jedis.pool.max-wait}")
    private long maxWait;

    @Value("${spring.redis.jedis.pool.min-idle}")
    private int minIdle;

    @Value("${spring.redis.timeout}")
    private int timeout;

    @Bean
    public JedisPool redisPoolFactory() {
        JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
        jedisPoolConfig.setMaxIdle(maxIdle);
        jedisPoolConfig.setMaxWaitMillis(maxWait);
        jedisPoolConfig.setMinIdle(minIdle);

        JedisPool jedisPool = new JedisPool(jedisPoolConfig, host, port, timeout, password);

        return jedisPool;
    }

}

配置文件

server.port=8080

# redis
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=
spring.redis.jedis.pool.max-idle=8
spring.redis.jedis.pool.max-wait=-1
spring.redis.jedis.pool.min-idle=0
spring.redis.timeout=0

OK,目前为止,校验代码准备完毕,接下来测试验证

七,测试验证

1.获取token的控制器TokenController

package com.hrh.interfacecheck.controller;

import com.hrh.interfacecheck.common.ServerResponse;
import com.hrh.interfacecheck.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
@RestController
@RequestMapping("/token")
public class TokenController {

    @Autowired
    private TokenService tokenService;

    @GetMapping
    public ServerResponse token(){
        return tokenService.createToken();
    }
}

2、TestController, 注意@ApiIdempotent注解, 在需要幂等性校验的方法上声明此注解即可, 不需要校验的无影响

package com.hrh.interfacecheck.controller;

import com.hrh.interfacecheck.annotation.ApiIdempotent;
import com.hrh.interfacecheck.common.ServerResponse;
import com.hrh.interfacecheck.service.TestService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @创建人 hrh
 * @创建时间 2020/2/13 0013
 * @描述
 */
@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private TestService testService;
    @ApiIdempotent
    @PostMapping("testIdempotence")
    public ServerResponse testIdempotence(){
        return testService.testIdempotence();
    }
}

3、获取token
在这里插入图片描述
4、测试接口安全性: 利用jmeter测试工具模拟50个并发请求, 将上一步获取到的token作为参数

5、header或参数均不传token, 或者token值为空, 或者token值乱填, 均无法通过校验, 如token值为"abcd"

八、注意点(非常重要)

上图中, 不能单纯的直接删除token而不校验是否删除成功, 会出现并发安全性问题, 因为, 有可能多个线程同时走到第46行, 此时token还未被删除, 所以继续往下执行, 如果不校验jedisUtil.del(token)的删除结果而直接放行, 那么还是会出现重复提交问题, 即使实际上只有一次真正的删除操作, 下面重现一下

稍微修改一下代码:
在这里插入图片描述

再次请求

再看看控制台

虽然只有一个真正删除掉token, 但由于没有对删除结果进行校验, 所以还是有并发问题, 因此, 必须校验

九、总结

其实思路很简单, 就是每次请求保证唯一性, 从而保证幂等性, 通过拦截器+注解, 就不用每次请求都写重复代码, 其实也可以利用spring aop实现, 无所谓。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值