接口幂等性(防止接口重复提交)

7 篇文章 0 订阅
3 篇文章 0 订阅

🍅 幂等性

在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。


在这里插入图片描述

🍅 实现幂等性方案


token 机制:

  1. 首先,客户端请求服务端,获取一个 token,每次请求都获取一个新的 token,将 token 设置过期时间存如 redis 中,然后返回给客户端。
  2. 客户端将返回的 token 存放在请求头中,去请求接口。
  3. 服务端收到请求后,从请求头中拿取 token,去 redis 中查找数据是否存在。
  4. 如果数据存在,则删除该 token 继续处理剩下的业务。如果不存在,则证明该 token 过期或者当前业务已经执行过了,此时就不在执行业务逻辑了。

注意:在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。


去重表:

该方式主要使用 mysql 中的唯一索引机制来实现。使用该方式应该注意的是 一般并不使用数据库的自增主键,使用分布式 ID 充当主键。

  1. 客户端请求服务端,服务端将这次请求(例如:地址,参数…)存入到一个 mysql 去重表中。这个去重表,要根据这次请求的某个特殊字段,建立唯一索引或者主键索引。

  2. 如果插入成功,继续完成余下的业务,如果插入失败,表示该业务已经执行过了,余下业务不在执行。

存在的问题:mysql 的容错性会影响业务,高并发的环境下可能效率降低。


redis 中的 setnx:

  1. 客户端请求服务端,服务端将能代表本次请求的唯一性的业务字段,通过 setnx 的方式存入 redis 中,并设置超时时间。

  2. 判断 setnx 是否成功,成功的话继续处理业务,否则就是已经执行过了。

redis 一定要设置过期时间。这样能保证在这个时间范围内,如果重复调用接口,则能够进行判断识别。如果不设置过期时间,很可能导致数据无限量的存入 redis,致使 redis不能正常工作。


锁机制:

  1. 乐观锁:数据库增加版本号字段,每次更新都都根据版本号来判断,更新之前先去查询更新记录的版本号,更新的时候将版本号作为条件。
 select version from xxx where id=xxx;
update xxx set xxx=xxx where xx=xx and version=xxx。

乐观锁一般只适用于执行 更新操作 的过程。

  1. 悲观锁:假设每一次拿数据都会被修改,所以直接上排他锁就行了。
-- 开启事务,提交事务
start;
select * from xxx where xxx for update;
update xxx
commit;

在这里插入图片描述

🍓 实现 JSON 格式参数多次读取


创建项目,添加相关依赖:

      <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
            <version>2.5.13</version>
        </dependency>

application.yml 配置

spring.redis.host=127.0.0.1
spring.redis.port=6379

在这里插入图片描述

自定义一个拦截器:

package org.javaboy.repeat_submit.interceptor;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.javaboy.repeat_submit.annotation.RepeatSubmit;
import org.javaboy.repeat_submit.redis.RedisCache;
import org.javaboy.repeat_submit.request.RepeatableReadRequestWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.AsyncHandlerInterceptor;
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;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * @author: yueLQ
 * @date: 2022-09-07 19:28
 * <p>
 * 自定义一个拦截器
 * <p>
 * 在接口中进行处理,拿到我们的请求路径和请求参数
 * 接着我们去 redis 中判断一下这个请求是否之前发送过
 */
@Component
public class RepeatSubmitInterceptor implements HandlerInterceptor {

    public static final String REPEAT_PARAMS = "repeat_params";

    public static final String REPEAT_TIME = "repeat_time";

    // key 前缀
    public static final String REPEAT_SUBMIT_KEY = "REPEAT_SUBMIT_KEY:";

    // 请求头的认证
    public static final String HEADER = "Authorization";

    @Autowired
    private RedisCache redisCache;


    /**
     * 如果我们的请求的参数是  request.getParameter() 这个方法是可以反复获取的
     * 如果我们是使用 io 流的方式获取的话 request.getReader().readLine(),例如请求参数是 json
     * <p>
     * 请求是一个 json,需要 io 流的方式读取参数
     *
     * @param request
     * @param response
     * @param handler
     * @return
     * @throws Exception
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // HandlerMethod 是我们定义的每一个接口方法,他们把接口方法定义封装成了一个对象
        if (handler instanceof HandlerMethod) {
            // 强转
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            // 获取方法
            Method method = handlerMethod.getMethod();
            // 获取方法上的 RepeatSubmit 注解
            RepeatSubmit repeatSubmit = method.getAnnotation(RepeatSubmit.class);
            // 并不是每个方法上都有该注解,所以我们需要进行判断
            if (!ObjectUtils.isEmpty(repeatSubmit)) {
                // 需要进行重复校验
                // 判断是否重复提交,如果是重复提交的,返回一段提示的内容
                if (isRepeatSubmit(request, repeatSubmit)) {
                    // 存在返回一段 json
                    HashMap<String, Object> map = new HashMap<>();
                    map.put("status", 500);
                    map.put("message", repeatSubmit.message());
                    response.setContentType("application/json;charset=utf-8");
                    response.getWriter().write(new ObjectMapper().writeValueAsString(map));
                    return false;
                }
            }
        }
        return true;
    }

    /**
     * 判断是否是重复提交,返回 true 是重复提交
     *
     * @param request
     * @param repeatSubmit
     * @return
     */
    private boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit) {
        // 请求参数的字符串
        String nowParams = "";
        // 判断请求类型,如果请求是 RepeatableReadRequestWrapper 参数则是 json 格式
        if (request instanceof RepeatableReadRequestWrapper) {
            try {
                // 按行读取
                nowParams = ((RepeatableReadRequestWrapper) request).getReader().readLine();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        // 否则的话,则说明参数是 key-value 的形式
        if (StringUtils.isEmpty(nowParams)) {
            try {
                nowParams = new ObjectMapper().writeValueAsString(request.getParameterMap());
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
        }
        // 存储数据
        Map<String, Object> nowDataMap = new HashMap<>();
        nowDataMap.put(REPEAT_PARAMS, nowParams);
        nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
        // 获取当前请求的 uri
        String uri = request.getRequestURI();
        // token 令牌,定位到某一个用户
        String header = request.getHeader(HEADER);
        // 处理请求 key
        String cacheKey = REPEAT_SUBMIT_KEY + uri + header.replace("Bearer", "");
        // 查询 redis 中是否存在数据,如果存在进行比较
        Object cacheObject = redisCache.getCacheObject(cacheKey);
        if (!ObjectUtils.isEmpty(cacheObject)) {
            Map<String, Object> map = (Map<String, Object>) cacheObject;
            // 取出存储的时间和区间段进行比较是否重复提交
            if (compareParams(map, nowDataMap) && compareTime(map, nowDataMap, repeatSubmit.interval())) {
                return true;
            }
        }
        // 否则我们将数据存储起来
        redisCache.setCacheObject(cacheKey, nowDataMap, repeatSubmit.interval(), TimeUnit.MILLISECONDS);
        return false;
    }

    /**
     * 比较参数是否一样
     *
     * @param map
     * @param nowDataMap
     * @return
     */
    private boolean compareParams(Map<String, Object> map, Map<String, Object> nowDataMap) {
        String nowParams = (String) nowDataMap.get(REPEAT_PARAMS);
        String dataParams = (String) map.get(REPEAT_PARAMS);
        return nowParams.equals(dataParams);
    }

    /**
     * 时间比较
     *
     * @param map
     * @param nowDataMap
     * @param interval
     * @return
     */
    private boolean compareTime(Map<String, Object> map, Map<String, Object> nowDataMap, int interval) {
        Long nowTime = (Long)nowDataMap.get(REPEAT_TIME);
        Long dataTime = (Long)map.get(REPEAT_TIME);
        return  nowTime-dataTime<interval ? true : false;
    }

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

    }
}


定义可重复度的请求类:

package org.javaboy.repeat_submit.request;

import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import java.io.*;

/**
 * @author: yueLQ
 * @date: 2022-09-07 20:00
 * <p>
 * 可重复度的请求
 * <p>
 * 装饰者模式将 httpServletRequest 处理一下
 */
public class RepeatableReadRequest extends HttpServletRequestWrapper {

    // 定义一个数组,将读出来的数据转换为 byte 数组
    private final byte[] bytes;


    public RepeatableReadRequest(HttpServletRequest request, HttpServletResponse response) throws IOException {
        super(request);
        // 设置编码格式
        request.setCharacterEncoding("UTF-8" );
        // 设置编码格式
        response.setCharacterEncoding("UTF-8" );
        // 按行读取字节数组
        bytes = request.getReader().readLine().getBytes();
    }

    /**
     *  获取 io 流的方法
     * @return
     * @throws IOException
     */
    @Override
    public BufferedReader getReader() throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(getInputStream()));
        return reader;
    }

    /**
     *  从 byte 数组中返回 inputStream
     * @return
     * @throws IOException
     */
    @Override
    public ServletInputStream getInputStream() throws IOException {
        ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
        return new ServletInputStream() {
            /**
             *  返回长度
             * @return
             * @throws IOException
             */
            @Override
            public int available() throws IOException {
                return bytes.length;
            }

            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return false;
            }

            @Override
            public void setReadListener(ReadListener readListener) {

            }

            /**
             *  读取
             * @return
             * @throws IOException
             */
            @Override
            public int read() throws IOException {
                return bis.read();
            }
        };
    }
}


定义过滤器,将请求参数设置为可以重复读取

package org.javaboy.repeat_submit.filter;

import org.javaboy.repeat_submit.request.RepeatableReadRequest;
import org.springframework.util.StringUtils;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * @author: yueLQ
 * @date: 2022-09-07 20:12
 *
 */
public class RepeatableRequestFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        // 获取请求头的信息, 请求头是 json 格式的
        if (StringUtils.startsWithIgnoreCase(request.getContentType(),"application/json")){
            // 将请求转换为装饰者模式处理过的格式
            RepeatableReadRequest readRequest = new RepeatableReadRequest(request, (HttpServletResponse) servletResponse);
            filterChain.doFilter(readRequest,servletResponse);
            return;
        }
        filterChain.doFilter(servletRequest,servletResponse);
    }
}


创建 web 配置类:

package org.javaboy.repeat_submit.config;

import org.javaboy.repeat_submit.filter.RepeatableRequestFilter;
import org.javaboy.repeat_submit.interceptor.RepeatSubmitInterceptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
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.WebMvcConfigurer;

/**
 * @author: yueLQ
 * @date: 2022-09-07 19:46
 *
 * 将我们定义好的拦截器注册进来,
 *
 * 过滤器先执行,拦截器后执行,拦截器是在 DispatchServlet
 * 确保过滤器先执行,将请求参数替换过来,然后我们在拦截器后面处理就可以了。
 * 如果两个都是在过滤器中处理,要设置优先级的问题
 */
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    RepeatSubmitInterceptor submitInterceptor;

    /**
     *  将自定义拦截器注册进来
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 添加拦截器,并且添加拦截规则,/** 拦截所有请求
        registry.addInterceptor(submitInterceptor).addPathPatterns("/**");
    }

    /**
     * 配置过滤器的 bean
     * @return
     */
    @Bean
    FilterRegistrationBean<RepeatableRequestFilter> repeatableRequestFilterFilterRegistrationBean(){
        FilterRegistrationBean<RepeatableRequestFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new RepeatableRequestFilter());
        // 拦截所有请求
        bean.addUrlPatterns("/*");
        return bean;
    }
}


定义注解 RepeatSubmit

package org.javaboy.repeat_submit.annotation;

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

/**
 * @author: yueLQ
 * @date: 2022-09-14 19:23
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {

    /**
     * 两个请求之间的间隔时间
     *
     * @return
     */
    int interval() default 5000;

    /**
     * 重复提交的提示文本
     *
     * @return
     */
    String message() default "不允许重复提交,请稍后再试!";
}


封装 redis 工具类:

package org.javaboy.repeat_submit.redis;

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.util.concurrent.TimeUnit;

/**
 * @author: yueLQ
 * @date: 2022-09-14 19:15
 * <p>
 * 封装 redis
 */
@Component
public class RedisCache {

    @Autowired
    RedisTemplate redisTemplate;

    /**
     * 存储数据
     *
     * @param key
     * @param val
     * @param timeOut  超时时间
     * @param timeUnit 时间单位
     * @param <T>
     */
    public <T> void setCacheObject(final String key, final T val, Integer timeOut, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, val, timeOut, timeUnit);
    }

    /**
     * 获取数据
     *
     * @param key
     * @param <T>
     * @return
     */
    public <T> T getCacheObject(final String key) {
        ValueOperations<String, T> ops = redisTemplate.opsForValue();
        return ops.get(key);
    }
}


测试:

package org.javaboy.repeat_submit.controller;

import org.javaboy.repeat_submit.annotation.RepeatSubmit;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

/**
 * @author: yueLQ
 * @date: 2022-09-07 19:42
 */
@RestController
public class HelloController {

    @PostMapping("/hello")
    @RepeatSubmit(interval = 10000)
    public String hello(@RequestBody String hello){
        return hello;
    }
}

在这里插入图片描述

  • 2
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

光头小小强007

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值