Spring Boot+Redis+拦截器+自定义Annotation实现接口幂等

前言

业务开发中,经常会遇到重复提交的情况,无论是由于网络问题无法收到请求结果而重新发起请求,或是前端的操作抖动而造成重复提交情况。 在交易系统,支付系统这种重复提交造成的问题有尤其明显,比如:

  1. C端用户在APP上连续点击了多次提交订单,后台应该只产生一个订单;(生成多个订单那岂不是乱了套了)
  2. 向支付宝发起支付请求,由于网络问题或系统BUG重发,支付宝应该只扣一次钱。(扣多次钱那岂不是也乱了套了) 很显然,声明幂等的服务认为,外部调用者会存在多次调用的情况,为了防止外部多次调用对系统数据状态的发生多次改变,将服务设计成幂等。

提示:以下是本篇文章正文内容,下面案例可供参考

一、什么是幂等性?

用户对于同一操作,无论是发起一次请求还是多次请求,最终的执行结果是一致的,不会因为多次点击而产生副作用。

二、常见的幂等操作

  • select查询天然幂等;
  • delete删除也是幂等,删除同一个数据多次其效果一样;
  • update直接更新某个值时,幂等;

  • update更新累加操作的的结果,非幂等;

  • insert操作会每次都新增一条,非幂等;

三、什么情况下会产生重复提交(非幂等性)

  • 连续点击提交两次按钮;

  • 点击刷新按钮;

  • 使用浏览器后退按钮重复之前的操作,导致重复提交表单;

  • 使用浏览器历史记录重复提交表单;

  • 浏览器重复地HTTP请求等。

四、解决方案

1.前端

  • 前端js提交禁止按钮可以用一些js组件
  • 使用Post/Redirect/Get模式:

    在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。

    这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。

2.后端     

  • 数据库唯一索引:借助数据库唯一索引,可以保证在业务场景中多次重复的提交只有一条数据可以插入成功;
  • 乐观锁:如果只是更新已有的数据,没有必要对业务进行加锁,设计表结构时使用乐观锁,一般通过version来做乐观锁,这样既能保证执行效率,又能保证幂等。例如: UPDATE tab1 SET col1=1,version=version+1 WHERE version=#version# 不过,乐观锁存在失效的情况,就是常说的ABA问题,不过如果version版本一直是自增的就不会出现ABA的情况。
  • token令牌:每次接口请求前先获取一个token,然后再下次请求的时候在请求的header体中加上这个token,后台进行验证,如果验证通过删除token,下次请求再次判断token;以支付场景为例,这种方式分成两个阶段:申请token阶段和支付阶段。 第一阶段,在进入到提交订单页面之前,需要订单系统根据用户信息向支付系统发起一次申请token的请求,支付系统将token保存到Redis缓存中,为第二阶段支付使用。 第二阶段,订单系统拿着申请到的token发起支付请求,支付系统会检查Redis中是否存在该token,如果存在,表示第一次发起支付请求,删除缓存中token后开始支付逻辑处理;如果缓存中不存在,表示非法请求。 实际上这里的token是一个信物,支付系统根据token确认,你是你妈的孩子。不足是需要系统间交互两次,流程较上述方法复杂。

五、Redis Token解决方案

1.原理图

Spring Boot+Redis+拦截器+自定义Annotation实现接口幂等_第1张图片

2.搭建RedisService

引入spring-boot-starter-data-redis依赖,通过RedisTemplate操作Redis缓存。

代码如下(示例):

@Slf4j
@Component
public class RedisService {
    @Resource
    private RedisTemplate redisTemplate;

    /**
     *  写入缓存
     * @param key
     * @param value
     */
    public void set(final String key, Object value){
        try {
            ValueOperations operations = redisTemplate.opsForValue();
            operations.set(key, value);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException("数据缓存至redis失败");
        }
    }

    /**
     * 写入缓存,设置超时时间
     * @param key
     * @param value
     * @param expireTime
     */
    public void setEx(final String key, Object value, Long expireTime){
        try{
            redisTemplate.opsForValue().set(key, value, expireTime, TimeUnit.SECONDS);
        }catch (Exception e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException("数据缓存至redis失败");
        }
    }

    /**
     * 判断缓存中是否存在key
     * @param key
     * @return
     */
    public boolean exists(final String key){
       return redisTemplate.hasKey(key);
    }

    /**
     * 读取缓存
     * @param key
     * @return
     */
    public Object get(final String key){
        return redisTemplate.opsForValue().get(key);
    }

    public boolean remove(final String key){
        return redisTemplate.delete(key);
    }
}

3.token创建和校验

3.1 token服务接口

创建token接口,用来创建和校验token。

代码如下(示例):

public interface TokenService {
    /**
     * 创建token
     * @return
     */
    public String createToken();

    /**
     * 校验token
     * @param request
     * @return
     * @throws Exception
     */
    public boolean checkToken(HttpServletRequest request) throws Exception;
}

3.2 token服务实现类

代码如下(示例):

@Service
public class TokenServiceImpl implements TokenService {

    private final RedisService redisService;

    public TokenServiceImpl(RedisService redisService){
        this.redisService = redisService;
    }

    @Override
    public String createToken() {
        try{
            String token = RedisConst.REDIS_AUTO_IDEMPOTENT_PREFIX+RandomUtil.randomBigDecimal().toString();
            redisService.setEx(token, token, 1000L);
            return token;
        }catch (Exception e){
            e.printStackTrace();
        }
        return null;
    }

    @Override
    public boolean checkToken(HttpServletRequest request) throws Exception {
        String token = request.getHeader(RedisConst.AUTO_IDEMPOTENT_TOKEN_NAME);
        if(StrUtil.isBlank(token)){
            token = request.getParameter(RedisConst.AUTO_IDEMPOTENT_TOKEN_NAME);
        }
        if(StrUtil.isNotBlank(token) && redisService.remove(token)){
            return true;
        }
        return false;
    }
}

4.自定义注解

自定义一个注解,定义此注解的主要目的是把它添加在需要实现幂等的方法上,凡是某个方法注解了它,都会实现自动幂等。

代码如下(示例):

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}

@Target——表示该注解用于什么地方。默认值为任何元素,表示该注解用于什么地方。可用的ElementType 参数包括(1:N,一个target可以包含多个ElementType):

  • ElementType.CONSTRUCTOR: 用于描述构造器
  • ElementType.FIELD: 成员变量、对象、属性(包括enum实例)
  • ElementType.LOCAL_VARIABLE: 用于描述局部变量
  • ElementType.METHOD: 用于描述方法
  • ElementType.PACKAGE: 用于描述包
  • ElementType.PARAMETER: 用于描述参数
  • ElementType.TYPE: 用于描述类、接口(包括注解类型) 或enum声明

@Retention——定义该注解的生命周期

  • RetentionPolicy.SOURCE : 在编译阶段丢弃。这些注解在编译结束之后就不再有任何意义,所以它们不会写入字节码。@Override@SuppressWarnings都属于这类注解。
  • RetentionPolicy.CLASS : 在类加载的时候丢弃。在字节码文件的处理中有用。注解默认使用这种方式
  • RetentionPolicy.RUNTIME : 始终不会丢弃,运行期也保留该注解,因此可以使用反射机制读取该注解的信息。我们自定义的注解通常使用这种方式。

5.拦截器实现

5.1 拦截处理器

主要的功能是拦截扫描AutoIdempotent注解到的方法,然后调用tokenService的checkToken()方法校验token是否正确,进行业务接口的放行及拦截。

代码如下(示例):

@Slf4j
@Component
public class AutoIdempotentInterceptor implements HandlerInterceptor {

    private final TokenService tokenService;

    public AutoIdempotentInterceptor(TokenService tokenService){
        this.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();
        //扫描包含AutoIdempotent注解的方法
        AutoIdempotent autoIdempotent = method.getAnnotation(AutoIdempotent.class);
        if(null != autoIdempotent){
            return tokenService.checkToken(request);
        }
        return true;
    }

    @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 {
    }
}

5.2 拦截器配置及加载

通过实现WebMvcConfigurer,实现对拦截器的加载及设置拦截器的过滤路径规则。

代码如下(示例):

@Configuration
public class WebConfiguration implements WebMvcConfigurer {
    @Resource
    private AutoIdempotentInterceptor autoIdempotentInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(autoIdempotentInterceptor).addPathPatterns("/doudou/**");
    }
}

六、测试用例

1.获取token

代码如下(示例):

@Slf4j
@RestController
@RequestMapping("doudou")
public class IdempotentController {

    private final TokenService tokenService;

    public IdempotentController(TokenService tokenService){
        this.tokenService = tokenService;
    }

    @ApiOperation(value = "获取Idempotent token", notes = "获取Idempotent token")
    @GetMapping("/getToken")
    public ResponseEntity>> getToken() {
        log.info("获取token IdempotentController getToken。");
        Map resultMap = Maps.newHashMap();
        resultMap.put("idempToken", tokenService.createToken());
        return ResponseEntity.ok(Response.ok(resultMap));
    }
}

2.幂等注解测试接口

代码如下(示例):

@Slf4j
@RestController
@RequestMapping("doudou")
public class TestController {


    private final RedisService redisService;

    public TestController(RedisService redisService){
        this.redisService = redisService;
    }

    @ApiOperation(value = "首个测试接口", notes = "首个测试接口")
    @AutoIdempotent
    @GetMapping("/test-1")
    public ResponseEntity>> graphSearch(@ApiParam(value = "节点类型") @RequestParam String label) {
        log.info("我被调用了:{}", label);
        Map resultMap = Maps.newHashMap();
        redisService.setEx(label, label, 100L);
        return ResponseEntity.ok(Response.ok(resultMap));
    }
}

3.postman请求测试

首先访问/getToken路径获取到具体到token:

Spring Boot+Redis+拦截器+自定义Annotation实现接口幂等_第2张图片 利用获取到到token,然后放到具体请求到header中,可以看到第一次请求成功:

Spring Boot+Redis+拦截器+自定义Annotation实现接口幂等_第3张图片

第二次请求,返回到是重复性操作,可见重复性验证通过,再多次请求到时候我们只让其第一次成功,第二次就是失败:

Spring Boot+Redis+拦截器+自定义Annotation实现接口幂等_第4张图片

暂未对异常进行捕获


 总结

本文对幂等性的概念以及常见的解决接口幂等性的方式进行了介绍,同时对通过spring boot、拦截器、自定义注解、Redis优雅的实现了Redis Token接口幂等方案。

你可能感兴趣的:(spring,boot,redis,java)