SpringBoot+Redis实现接口幂等校验

概念

在我们开发的项目中,对外暴露的接口往往面临多次请求。

我们来解释一下幂等的概念:任意多次请求产生的结果与一次执行的结果相同。

按照这个含义就是对数据库的影响只能是一次性的,不能重复处理。

那么我们如何保证幂等性呢,通常有以下手段:

1、数据库建议唯一性索引,可以保证最终插入数据库的只有一条数据

2、token机制,每次接口请求前先获取一个token,然后再下次请求的时候在请求的header体中加上这个token,后台进行验证,如果验证通过删除token,下次请求再次判断token,由于token被删除了,认定此次请求为重复请求。

3、悲观锁或者乐观锁,悲观锁可以保证每次for update的时候其他sql无法update数据(在数据库引擎是innodb的时候,select的条件必须是唯一索引,防止锁全表)

4、先查询后判断,首先通过查询数据库是否存在数据,如果存在证明已经请求过了,直接拒绝该请求,如果没有存在,就证明是第一次进来,直接放行。

实现

1、首先搭建Redis服务,直接启动Redis服务即可。

2、创建SpringBoot项目,Pom文件加入依赖:

<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

<!-- hutool -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.1</version>
</dependency>

3、封装Redis工具

package com.itjing.redis.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.io.Serializable;
import java.util.concurrent.TimeUnit;

/**
 * @author lijing
 * @date 2022年05月26日 10:19
 * @description redis工具类
 */

@Component
public class RedisService {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 写入缓存
     *
     * @param key
     * @param value
     * @return
     */

    public boolean set(final String key, Object value) {
        boolean result = false;
        try {
            ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
            operations.set(key, value);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }


    /**
     * 写入缓存设置时效时间
     *
     * @param key
     * @param value
     * @return
     */

    public boolean setEx(final String key, Object value, Long expireTime) {
        boolean result = false;
        try {
            ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
            operations.set(key, value);
            redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }


    /**
     * 判断缓存中是否有对应的value
     *
     * @param key
     * @return
     */

    public boolean exists(final String key) {
        return redisTemplate.hasKey(key);
    }

    /**
     * 读取缓存
     *
     * @param key
     * @return
     */

    public Object get(final String key) {
        Object result = null;
        ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue();
        result = operations.get(key);
        return result;
    }

    /**
     * 删除对应的value
     *
     * @param key
     */

    public boolean remove(final String key) {
        if (exists(key)) {
            Boolean delete = redisTemplate.delete(key);
            return delete;
        }
        return false;

    }

}

4、自定义注解

package com.itjing.redis.annotation;

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

/**
 * @author lijing
 * @date 2022年05月26日 10:16
 * @description 幂等注解
 * 定义此注解的主要目的是把它添加在需要实现幂等的方法上
 */

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

}

5、token创建和检验

package com.itjing.redis.service;

import javax.servlet.http.HttpServletRequest;

/**
 * @author lijing
 * @date 2022年05月26日 10:17
 * @description
 */

public interface TokenService {

    /**
     * 创建token
     *
     * @return
     */

    String createToken();

    /**
     * 检验token
     *
     * @param request
     * @return
     */

    boolean checkToken(HttpServletRequest request) throws Exception;

}
package com.itjing.redis.service.impl;

import cn.hutool.core.text.StrBuilder;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import com.itjing.redis.common.Constant;
import com.itjing.redis.common.ResponseCode;
import com.itjing.redis.exception.ServiceException;
import com.itjing.redis.service.TokenService;
import com.itjing.redis.utils.RedisService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import javax.servlet.http.HttpServletRequest;

/**
 * @author lijing
 * @date 2022年05月26日 10:18
 * @description
 */

@Service
public class TokenServiceImpl implements TokenService {

    @Autowired
    private RedisService redisService;

    /**
     * 创建token
     *
     * @return
     */

    @Override
    public String createToken() {

        String str = RandomUtil.randomString(16);
        StrBuilder token = new StrBuilder();
        try {
            token.append(Constant.Redis.TOKEN_PREFIX).append(str);
            redisService.setEx(token.toString(), token.toString(), 10000L);
            boolean notEmpty = StrUtil.isNotEmpty(token.toString());
            if (notEmpty) {
                return token.toString();
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return null;
    }

    /**
     * 检验token
     *
     * @param request
     * @return
     */

    @Override
    public boolean checkToken(HttpServletRequest request) throws Exception {

        String token = request.getHeader(Constant.TOKEN_NAME);
        if (StrUtil.isBlank(token)) {// header中不存在token
            token = request.getParameter(Constant.TOKEN_NAME);
            if (StrUtil.isBlank(token)) {// parameter中也不存在token
                throw new ServiceException(ResponseCode.ILLEGAL_ARGUMENT.getMsg(), ResponseCode.ILLEGAL_ARGUMENT.getCode());
            }
        }

        if (!redisService.exists(token)) {
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg(), ResponseCode.REPETITIVE_OPERATION.getCode());
        }

        boolean remove = redisService.remove(token);
        // 注意删除后一定要校验是否删除
        // 不能单纯的直接删除token而不校验是否删除成功,
        // 会出现并发安全性问题, 因为, 有可能多个线程同时走到此行,
        // 此时token还未被删除, 所以继续往下执行, 如果不校验删除结果而直接放行,
        // 那么还是会出现重复提交问题, 即使实际上只有一次真正的删除操作
        if (!remove) {
            throw new ServiceException(ResponseCode.REPETITIVE_OPERATION.getMsg(), ResponseCode.REPETITIVE_OPERATION.getCode());
        }

        return true;
    }
}

6、拦截器配置

package com.itjing.redis.interceptor;

import com.itjing.redis.annotation.AutoIdempotent;
import com.itjing.redis.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.io.IOException;
import java.io.PrintWriter;
import java.lang.reflect.Method;

/**
 * @author lijing
 * @date 2022年05月26日 10:52
 * @description 接口幂等性拦截器
 */

public class AutoIdempotentInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    /**
     * 预处理
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */

    @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 methodAnnotation = method.getAnnotation(AutoIdempotent.class);
        if (methodAnnotation != null) {
            // 幂等性校验, 校验通过则放行, 校验失败则抛出异常, 并通过统一异常处理返回友好提示
            return tokenService.checkToken(request);
        }
        // 必须返回true,否则会被拦截一切请求
        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 {

    }

    /**
     * 返回的json值
     *
     * @param response
     * @param json
     * @throws Exception
     */

    private void writeReturnJson(HttpServletResponse response, String json) throws Exception {
        PrintWriter writer = null;
        response.setCharacterEncoding("UTF-8");
        response.setContentType("text/html; charset=utf-8");
        try {
            writer = response.getWriter();
            writer.print(json);

        } catch (IOException e) {
        } finally {
            if (writer != null)
                writer.close();
        }
    }

}
package com.itjing.redis.config;

import com.itjing.redis.interceptor.AutoIdempotentInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;

/**
 * @author lijing
 * @date 2022年05月26日 10:57
 * @description
 */

@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    /**
     * 添加拦截器
     *
     * @param registry
     */

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 接口幂等性拦截器
        registry.addInterceptor(autoIdempotentInterceptor());
    }

    @Bean
    public AutoIdempotentInterceptor autoIdempotentInterceptor() {
        return new AutoIdempotentInterceptor();
    }
}

7、测试用例

package com.itjing.redis.controller;

import com.itjing.redis.annotation.AutoIdempotent;
import com.itjing.redis.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author lijing
 * @date 2022年05月26日 11:01
 * @description
 */

@RestController
@RequestMapping("/autoIdempotent")
public class AutoIdempotentController {

    @Autowired
    private TokenService tokenService;

    @GetMapping("/token")
    public String token() {
        return tokenService.createToken();
    }

    @AutoIdempotent
    @PostMapping("/dealSomething")
    public String dealSomething() {
        return "success";
    }
}

另附相关常量类与枚举类

package com.itjing.redis.common;

/**
 * @author lijing
 * @date 2022年05月26日 10:33
 * @description 常量类
 */

public class Constant {

    public static final String TOKEN_NAME = "Authorization";

    public interface Redis {
        String TOKEN_PREFIX = "token:";
    }
}
package com.itjing.redis.common;

/**
 * @author lijing
 * @date 2022年05月26日 10:35
 * @description 响应状态码
 */

public enum ResponseCode {

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

    NO_LOGIN_ERROR(1000"未登录"),

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

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

    // 订单模块

    ;

    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;
    }
}

测试

利用jmeter测试工具模拟50个并发请求, 将获取到的token作为参数。

header或参数均不传token,或者token值为空,或者token值乱填,均无法通过校验。

注意点:在checkToken方法最后,不能单纯的直接删除token而不校验是否删除成功,会出现并发安全性问题,因为,有可能多个线程同时走到那块,此时token还未被删除,所以继续往下执行,如果不校验删除结果而直接放行,那么还是会出现重复提交问题,即使实际上只有一次真正的删除操作。

总结

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


原文始发于微信公众号(程序员阿晶):SpringBoot+Redis实现接口幂等校验

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/19632.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!