欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-07-02 23:46:20
...


原博客地址

一、概念

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

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

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

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

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

二、常见解决方案

唯一索引 – 防止新增脏数据

token机制 – 防止页面重复提交

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

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

分布式锁 – redis(jedis、redisson)或zookeeper实现

状态机 – 状态变更, 更新数据时判断状态

三、本文实现

本文采用第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 视图模板依赖

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

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、配置拦截器

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

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 成功页面。

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

7、测试

运行项目,配置的是8080 端口,访问:localhost:8080/ 显示首页,点击如下,便会跳转到订单详情-下单
Spring Boot + Token 实现接口幂等性 | 防止表单重复提交

如下,打开开发者模式(F12)查看隐藏域已经返回了一个token
Spring Boot + Token 实现接口幂等性 | 防止表单重复提交

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

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

后台控制台显示如下:
Spring Boot + Token 实现接口幂等性 | 防止表单重复提交

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

相关标签: redis