热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

SpringBoot+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/  显示首页,点击如下,便会跳转到订单详情-下单


推荐阅读
author-avatar
mobiledu2502886217
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有