自定义注解防止接口重复提交

Lou.Chen2021年5月10日大约 9 分钟

幂等性

什么是接口幂等性

幂等性是指用户对一个接口发起一次或者多次请求的结果是一致的,不会因为多次请求而对系统数据产生其他的副作用

请求方式对幂等性的影响

  • GET一般只是做查询资源,多次查询的结果会一致,所以一般不会出现幂等性的问题
    • select * from where table 幂等
  • PUT一般用作修改资源,多次执行会出现幂等性的情况
    • update table set money=money+10 where id=1 不幂等
    • update table set money=10 where id=1 幂等
  • POST 一般做创建资源,多次执行会出现幂等性问题
    • insert into table values(1,'zhangsan',1000) 不幂等
  • DELETE一般做删除资源操作,在不考虑返回结果的情况下,认为此操作也是幂等性的
    • delete from table where id=1 幂等

出现幂等性问题的几种情况

  • 前端重复提交:
    • 用户下单一次,因为其网络导致,导致没有反应,再次点击下单,导致生成了多个订单的情况。
    • 用户提交表单,因为网络问题,页面没有及时响应,用户多次点击,导致重复提交。

实现幂等性的几种解决方案

Token机制

实现思路:

  • 首先客户端再请求业务接口之前先去服务端获取一个token,服务端生成token后返回给客户的。该token具有唯一性,token指定超时时间,并存入redis中。
  • 客户端拿到token后再去服务端执行业务请求
  • 服务端收到请求有以下几种情况:
    • 如果token存在,代表在时间周期内第一次请求,执行删除token,然后再处理业务
    • 如果token不存在,则表明token已被删除(第一次处理业务请求之前会删除token)或者过期(网络问题导致获取token和执行业务间隔过长),则表示业务已经执行过了,所以此时不会执行业务

优缺点:

  • 优点:实现简单
  • 缺点:因为有一次获取token的额外过程,所以性能较低。

锁机制

乐观锁

实现思路:

  • 给要操作的数据表加版本号字段version,当第一次要操作的时候以此版本号作为条件进行更新,并对对版本号加1操作

    UPDATE my_table SET price = price+50 ,version = version + 1 WHERE id = 1 AND version = 5

  • 第二次为重复提交时,还是会以版本号version=5作为条件去操作表,但是第一次提交操作已经将version改为5了,所以该次找不到此条记录,操作失败。

**缺点:**多一次数据库交互,效率较低

悲观锁

实现思路:

  • 给要操作的业务等sql直接加x锁

    start;
    select * from table where id=1 for update
    #其他业务操作
    #.....
    #.....
    commit;
    

**缺点:**会产生行锁,高并发情况下效率非常低

Token+请求参数⭐

这里采用 token+请求参数 的方式接口幂等的处理。

即某个用户在在指定的周期内,多次请求的相同的接口并且请求参数是相同的,那么第二次以及后续请求是不允许的

实现思路:

  • 首先通过拦截器拿到客户端请求头的Authroization的token和请求参数,将客户端请求的「固定前缀+请求地址+token」作为key,「请求的当前时间」和「当前的请求参数」作为value存入redis,并指定超时时间
  • 当第二次为重复提交请求时,再将当前「请求地址+token」作为key去redis中获取
    • 若key不存在(超时),则直接放行,允许执行,然后存入redis
    • 若key存在,则拿到此value解析后,若「当前的请求参数和value中的请求参数相等」,并且「当前时间-value中的时间<设置的周期时间(redis超时时间)」则认为重复提交,则不允许进行后续操作,否则覆盖此key即可

**优点:**效率较高,避免多一次的请求token请求操作。前提是前端需要携带唯一token将各个用户进行区分

Token+请求参数 案例

pom依赖

 <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>
        </dependency>

自定义重复提交注解

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RepeatSubmit {
    /**
     * 默认请求周期间隔时间为5s
     * @return
     */
    int interval() default 5000;

    /**
     * 提示消息
     * @return
     */
    String message() default "请勿重复提交,请稍后再试";
}

Request再封装保证多次读流

当拦截器通过request.getReader().readLine()读取请求体Body中的内容后,那么后面控制器方法就无法读取Request流中的内容,导致报错: getReader() has already been called for this request

所以就需要对Request进行包装,将流放入包装中的字节数组中,后面每次取的时候都是从该包装Request中读取。

public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {

    /**
     * 将请求的流放入该数组中保存
     */
    private final byte[] bytes;

    public RepeatableReadRequestWrapper(HttpServletRequest request, HttpServletResponse response) throws IOException {
        super(request);
        request.setCharacterEncoding("UTF-8");
        response.setCharacterEncoding("UTF-8");
        bytes = request.getReader().readLine().getBytes();
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

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

            @Override
            public void setReadListener(ReadListener readListener) {

            }

            @Override
            public int read() throws IOException {
                return bais.read();
            }
        };
    }
}

过滤器

拦截application/json类型的请求,并对请求进行包装

public class RepeatableRequestFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request= (HttpServletRequest) servletRequest;
        //如果请求头为 application/json 形式的那么,那么对请求进行包装
        if (StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
            request = new RepeatableReadRequestWrapper(request, (HttpServletResponse) servletResponse);
            filterChain.doFilter(request, servletResponse);
            return;
        }
        filterChain.doFilter(servletRequest,servletResponse);
    }
}

Redis序列化配置

@Configuration
public class RedisConfig {

    /**
     * 重写RedisTemplate 对默认的存储值进行序列化
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object,Object> redisTemplate=new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
        redisTemplate.setKeySerializer(serializer);
        redisTemplate.setValueSerializer(serializer);
        redisTemplate.setHashKeySerializer(serializer);
        redisTemplate.setHashValueSerializer(serializer);
        return redisTemplate;
    }
}

Redis工具类配置

@Component
public class RedisCache {

    @Autowired
    private RedisTemplate redisTemplate;

    /**
     * 缓存基本的对象,Integer、String、实体类等
     * @param key      缓存的键值
     * @param value    缓存的值
     * @param timeout  时间
     * @param timeUnit 时间颗粒度
     */
    public <T> void setCacheObject(final String key, final T value, long timeout, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
    }

    /**
     * 获得缓存的基本对象。
     * @param key 缓存键值
     * @return 缓存键值对应的数据
     */
    public <T> T getCacheObject(final String key) {
        ValueOperations<String, T> operation = redisTemplate.opsForValue();
        return operation.get(key);
    }

}

拦截器

@Component
public abstract class RepeatSubmitInterceptor implements HandlerInterceptor {
    @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 (repeatSubmit != null) {
                //是否为重复提交  是:直接返回提示消息,不允许访问
                if (isRepeatSubmit(request, repeatSubmit)) {
                    Map<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;
                }
            }
        }
        //因为流只能读取一次,若在拦截器中对request进行读取后,那么后续接口方法将不能获取到request中的流,所以需要对流进行封装,每次从封装过滤器中读取流数据
        // System.out.println("RepeatSubmitInterceptor preHandle:"+request.getReader().readLine());
        return true;
    }

    /**
     * 验证是否重复提交由子类实现具体的防重复提交的规则
     * @param request
     * @param repeatSubmit
     * @return
     */
    public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit);
}

@Component
public class SameUrlAndParamsInterceptorImpl extends RepeatSubmitInterceptor {

    //value 参数key
    private static final String REPEAT_PARAMS = "repeat_params";
    //value 时间key
    private static final String REPEAT_TIME = "repeat_time";
    //redis中的key固定前缀
    private static final String REPEAT_REPEAT_KEY = "repeat_submit_key";
    //请求头的认证的key
    private static final String HEADER = "Authorization";

    @Autowired
    private RedisCache redisCache;

    @Override
    public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit) {
        //获取请求参数
        //请求参数
        String nowParams = "";
        //如果为包装后的request对象,代表该请求为json格式的
        if (request instanceof RepeatableReadRequestWrapper) {
            try {
                //直接从包装的request中读取到流数据
                nowParams = request.getReader().readLine();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        //如果为空,则代表为 key-value 格式的请求
        if (StringUtils.isEmpty(nowParams)) {
            try {
                //直接获取key-value参数
                nowParams = new ObjectMapper().writeValueAsString(request.getParameterMap());
            } catch (JsonProcessingException e) {
                e.printStackTrace();
            }
        }
        //构建value
        Map<String, Object> nowDataMap = new HashMap<>();
        //设置获取的请求参数
        nowDataMap.put(REPEAT_PARAMS, nowParams);
        //设置当前时间
        nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
        String requestURI = request.getRequestURI();
        //获取请求头中的Authorization的唯一token (不可缺少,因为这样才能区分是不同客户端提交的)
        String header = request.getHeader(HEADER);
        //构建key 【固定前缀repeat_submit_key + 请求uri + 请求唯一token】 即可表明当前请求的唯一性。这里如果为jwt则可以去掉Bearer
        String cacheKey = REPEAT_REPEAT_KEY + requestURI + header.replace("Bearer ", "");
        Object cacheObject = redisCache.getCacheObject(cacheKey);
        //如果从redis中拿到key不为空
        if (cacheObject != null) {
            //将redis中拿到的数据转为map
            Map<String, Object> objectMap = (Map<String, Object>) cacheObject;
            //判断value参数是否相同并且在指定的时间范围内是否有重复提交
            if (compareParams(nowDataMap, objectMap) && compareTime(nowDataMap, objectMap, repeatSubmit.interval())) {
                return true;
            }
        }
        //表明第一次提交,直接设置到redis中
        redisCache.setCacheObject(cacheKey, nowDataMap, repeatSubmit.interval(), TimeUnit.MILLISECONDS);
        return false;
    }

    /**
     * 判断周期内的时间是否重复提交
     *
     * @param nowDataMap
     * @param objectMap
     * @param interval
     * @return
     */
    private boolean compareTime(Map<String, Object> nowDataMap, Map<String, Object> objectMap, int interval) {
        Long nowTime = (Long) nowDataMap.get(REPEAT_TIME);
        Long objectTime = (Long) objectMap.get(REPEAT_TIME);
        //如果 【当前时间】-【redis中的value时间】小于指定的周期直接,那么代表周期时间内为第二次请求, 则返回true,代表为重复提交
        return nowTime - objectTime < interval;
    }

    /**
     * 判断请求参数是否相同
     *
     * @param nowDataMap
     * @param objectMap
     * @return
     */
    private boolean compareParams(Map<String, Object> nowDataMap, Map<String, Object> objectMap) {
        String nowDataParams = (String) nowDataMap.get(REPEAT_PARAMS);
        String objectParams = (String) objectMap.get(REPEAT_PARAMS);
        return nowDataParams.equals(objectParams);
    }
}

过滤器拦截器注册

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private SameUrlAndParamsInterceptorImpl sameUrlAndParamsInterceptor;

    /**
     * 注册拦截器
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(sameUrlAndParamsInterceptor).addPathPatterns("/**");
    }

    /**
     * 注册拦截器
     * @return
     */
    @Bean
    public FilterRegistrationBean<RepeatableRequestFilter> repeatableRequestFilterFilterRegistrationBean() {
        FilterRegistrationBean<RepeatableRequestFilter> bean=new FilterRegistrationBean<>();
        bean.setFilter(new RepeatableRequestFilter());
        bean.addUrlPatterns("/*");
        return bean;
    }
}

控制器接口

10s内不允许重复提交

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

properties配置

# 应用名称
spring.application.name=repeat_submit
# 应用服务 WEB 访问端口
server.port=8080
spring.redis.host=xxx
spring.redis.password=xxx

最终测试:

http://localhost:8080/helloopen in new window

第一次提交:

  • 请求体:hello

  • 请求头Authorization: dh21312j3h1j3123

  • 结果:hello

第二次提交:

  • 请求体:hello

  • 请求头Authorization: dh21312j3h1j3123

  • 结果:

    {
        "message": "请勿重复提交,请稍后再试",
        "status": 500
    }
    

Redis中数据:

  • key: repeat_submit_key/hellodh21312j3h1j3123

  • value :

    {
      "repeat_params": "hello",
      "repeat_time": 1668608150712
    }