Spring Boot + Token 实现接口幂等性 | 防止表单重复提交

Spring Boot + Token 实现接口幂等性 | 防止表单重复提交

一、概念

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

  • 订单接口, 不能多次创建订单

  • 支付接口, 重复支付同一笔订单只能扣一次钱

  • 支付宝回调接口, 可能会多次回调, 必须处理重复回调

  • 普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次 等等

二、常见解决方案

  1. 唯一索引 -- 防止新增脏数据

  2. token机制 -- 防止页面重复提交

  3. 悲观锁 -- 获取数据的时候加锁(锁表或锁行)

  4. 乐观锁 -- 基于版本号version实现, 在更新数据那一刻校验数据

  5. 分布式锁 -- redis(jedis、redisson)或zookeeper实现

  6. 状态机 -- 状态变更, 更新数据时判断状态

三、本文实现

本文采用第2种方式实现, 即通过token机制实现接口幂等性校验。(假如是分布式环境,可以考虑将生成的token由JVM内存(session)转移到redis等,可参考:https://mp.weixin.qq.com/s/v_iyZVd5ldixnhaxkdSArA)

四、实现思路

为保证幂等性,每一次请求(创建订单)接口都生成一个新的唯一标识 token, 并将此 token存入session, 同时返回token给其前端,下次请求(下单)接口时, 将此 token放到header或者作为请求参数带过来, 后端(下单)接口判断当前session中的token与前端传递过来的token是否相等:

  • 当前session中是否存在此token

  • 前端请求参数中是否携带有token

  • 如果都存在, 并且相等,正常处理业务逻辑, 并从session中删除此 token, 那么, 如果是重复请求, 由于 token已被删除, 则不能通过校验, 返回 请勿重复操作提示

  • 如果不存在, 说明参数不合法或者是重复请求, 返回提示即可

集群环境:采用token加redis(redis单线程)

单JVM环境:采用token加redis或token加jvm内存

五、项目简介

  • SpringBoot

  • Thymeleaf (Spring Boot 推荐使用 Thymeleaf 来代替 JSP)

  • 自定义注解@Token注解 + 拦截器对请求进行拦截

  • 继承WebMvcConfigurationSupport ,在其中配置拦截器

六、项目实战

1、先创建一个SpringBoot工程,并引入Thymeleaf 视图模板依赖

        
            org.springframework.boot
            spring-boot-starter-thymeleaf
        

2、自定义注解@Token ,只需要在具体的请求接口方法添加即可。

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Token {

    /**
     * 是否创建新的token
     */
    boolean generate() default false;
    /**
     * 是否移除token
     */
    boolean remove() default false;
}

3、创建拦截器,并且继承 HandlerInterceptorAdapter ,或者实现 HandlerInterceptor 接口,建议使用HandlerInterceptorAdapter,因为可以按需进行方法的覆盖,不用实现所有方法。

/**
 * @description: 表单提交--token拦截器
 * @author: xianhao_gan
 * @date: 2019/08/16
 **/
@Slf4j
public class TokenInterceptor extends HandlerInterceptorAdapter {

    /** The Constant TOKEN. 放在session中的token */
    private static final String TOKEN = "token";

    /**
     * 拦截处理程序的执行。在HandlerMapping之后调用,确定适当的处理程序对象,但是在HandlerAdapter调用处理程序之前调用。
     * @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) {
            Method method = ((HandlerMethod) handler).getMethod();
            Token tokenAnnotation = method.getAnnotation(Token.class);
            if (tokenAnnotation != null) {
                HttpSession session = request.getSession();

                // 创建新的表单提交令牌token,防止表单重复提交
                boolean isGenerate = tokenAnnotation.generate();
                if (isGenerate) {
                    String formToken = UUID.randomUUID().toString();
                    session.setAttribute(TOKEN, formToken);
                    log.info("创建表单提交令牌成功,token:" + formToken);
                    return true;
                }

                // 删除token令牌
                boolean isRemove = tokenAnnotation.remove();
                if (isRemove) {
                    if (isRepeatSubmit(request)) {
                        log.warn("表单不能重复提交:" + request.getRequestURL());
                        return false;
                    }
                    session.removeAttribute(TOKEN);
                }
            }
        } else {
            return super.preHandle(request, response, handler);
        }
        return true;
    }
}

其中,我们只需要覆写preHandle方法即可。

说明:

  • preHandle 方法会在请求处理之前进行调用(Controller方法调用之前)
  • postHandle 请求处理之后进行调用,但是在视图被渲染之前(Controller方法调用之后)
  • afterCompletion 在整个请求结束之后被调用,也就是在DispatcherServlet 渲染了对应的视图之后执行(主要是用于进行资源清理工作)

isRepeatSubmit校验token方法如下:

    /**
     * 表单是否重复提交校验
     * @param request
     * @return
     */
    private boolean isRepeatSubmit(HttpServletRequest request) {
        //session中token
        String token = (String) request.getSession().getAttribute(TOKEN);
        if (StringUtils.isEmpty(token)) {
            return true;
        }
        //请求头中获取token
        String reqToken = request.getHeader(TOKEN);
        if (StringUtils.isEmpty(reqToken)) {
            //请求参数request中获取token
            reqToken = request.getParameter(TOKEN);
            if (StringUtils.isEmpty(reqToken)) {
                return true;
            }
        }
        //对比session与前端传递过来的token是否相等
        if (!token.equals(reqToken)) {
            return true;
        }
        return false;
    }

4、配置拦截器

5、新增OrderController 控制器,分别提供创建订单(跳转订单)、提交订单(下单)两个http接口。

并且在创建订单接口方法加上注解@Token(generate = true)

在提交订单接口方法加上注解@Token(remove = true)

package com.stwen.token.controller;

import com.stwen.token.annotation.Token;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * @description: 订单控制器
 * @author: xianhao_gan
 * @date: 2019/08/16
 **/
@Controller
@RequestMapping("/order")
@Slf4j
public class OrderController {

    @RequestMapping("/")
    public String index(){
        return "index";
    }

    /**
     * 跳转订单详情页面--下单
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/detail")
    @Token(generate = true)
    public String orderPage(HttpServletRequest request, HttpServletResponse response){

        //TODO 调用具体业务逻辑-生成订单

        log.info("打开订单详情...");
        return "order_detail";
    }

    /**
     * 提交订单
     * @param request
     * @param response
     * @return
     */
    @RequestMapping("/submit")
    @Token(remove = true)
    public String orderSubmit(HttpServletRequest request, HttpServletResponse response){

        //TODO 调用具体业务逻辑--提交订单

        log.info("hello,订单提交成功。");
        return "success";
    }

}

注意:在接口方法中可以具体调用自己的业务逻辑,但是需要考虑异常情况:在你处理具体业务逻辑时发生异常,比如创建订单-跳转订单接口业务逻辑发生异常,但是拦截器 preHandle 方法已经创建好了token放在session中,这时就需要手动删除session中的token,或者实现一个切面@Aspect,在@AfterThrowing 中捕获异常时,清除session中token等。

6、新增3个html测试页面:index.html 、order_detail.html、success.html

由 index.html 跳转到 order_detail.html 时,会被拦截创建一个token,返回放到input 隐藏域,当点击提交时,会把该token一并带过去。提交订单成功,将返回success 成功页面。

7、测试

运行项目,配置的是8080 端口,访问:localhost:8080/ 显示首页,点击如下,便会跳转到订单详情-下单

如下,打开开发者模式(F12)查看隐藏域已经返回了一个token

点击上面的“提交”,成功

后台控制台显示如下:

当重复提交同一个表单时将会提示,不可以重复提交。

你可能感兴趣的