Spring Boot + Token 实现接口幂等性 | 防止表单重复提交
原博客地址
一、概念
幂等性, 通俗的说就是一个接口, 多次发起同一个请求, 必须保证操作只能执行一次 比如:
订单接口, 不能多次创建订单
支付接口, 重复支付同一笔订单只能扣一次钱
支付宝回调接口, 可能会多次回调, 必须处理重复回调
普通表单提交接口, 因为网络超时等原因多次点击提交, 只能成功一次 等等
二、常见解决方案
唯一索引 – 防止新增脏数据
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、配置拦截器
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
点击上面的“提交”,成功
后台控制台显示如下:
当重复提交同一个表单时将会提示,不可以重复提交。
上一篇: React