介绍了使用 redirect 技术防止表单提交,但是 redirect 解决不了后退到表单页面时重复提交表单,为了解决这个问题,加入了 token 的机制。如果每个 form 相关的处理方法中都写一遍 token 的生成和校验代码,在实际项目中是不太能接受的,接下来介绍了使用拦截器的方式生成和校验 token。
GET
访问表单页面POST
提交表单Result: ${result!}
<!DOCTYPE html>
<html>
<head>
<title>Update User</title>
</head>
<body>
<form action="/user-form" method="post">
Username: <input type="text" name="username"><br>
Password: <input type="text" name="password"><br>
<button type="submit">Update User</button>
</form>
</body>
</html>
package controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class ParameterController {
// 显示表单
@RequestMapping(value = "/user-form", method= RequestMethod.GET)
public String showUserForm() {
return "user-form.htm";
}
// 更新 User,把操作结果保存到 redirectAttributes,
// redirect 到 result 页面显示操作结果
@RequestMapping(value = "/user-form", method= RequestMethod.POST)
public String handleUserForm(@RequestParam String username,
@RequestParam String password,
final RedirectAttributes redirectAttributes) {
// Update user in database...
System.out.println("Username: " + username + ", Password: " + password);
// 操作结果显示给用户
redirectAttributes.addFlashAttribute("result", "The user is already successfully updated");
return "redirect:/result"; // URI instead of viewName
}
// 显示表单处理结果
@RequestMapping("/result")
public String result() {
return "result.htm";
}
}
Update User
,表单成功提交后被重定向到 result 页面但是,如果在浏览器里点击后退按钮后退到表单页面,点击 Update User
,表单被再次提交了。可以使用 token 防止后退的情况下重复提交表单,访问表单页面的时候生成一个 token 在 form 里并且在 Server 端存储这个 token,提交表单的时候先检查 Server 端有没有这个 token,如果有则说明是第一次提交表单,然后把 token 从 Server 端删除,处理表单,redirect 到 result 页面,如果 Server 端没有这个 token,则说明是重复提交的表单,不处理表单的提交。
Result: ${result!}
在 form 里增加一个 input 域存放 token.
<!DOCTYPE html>
<html>
<head>
<title>Update User</title>
</head>
<body>
<form action="/user-form" method="post">
<input type="input" name="token" value="${token!}"><br>
Username: <input type="text" name="username"><br>
Password: <input type="text" name="password"><br>
<button type="submit">Update User</button>
</form>
</body>
</html>
package controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.ModelMap;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import javax.servlet.http.HttpSession;
import java.util.UUID;
@Controller
public class ParameterController {
// 显示表单
@RequestMapping(value = "/user-form", method= RequestMethod.GET)
public String showUserForm(ModelMap model, HttpSession session) {
String token = UUID.randomUUID().toString().toUpperCase().replaceAll("-", "");
model.addAttribute("token", token);
session.setAttribute(token, token);
return "user-form.htm";
}
// 更新 User,把操作结果保存到 redirectAttributes,
// redirect 到 result 页面显示操作结果
@RequestMapping(value = "/user-form", method= RequestMethod.POST)
public String handleUserForm(@RequestParam String username,
@RequestParam String password,
@RequestParam String token,
HttpSession session,
RedirectAttributes redirectAttributes) {
// 处理表单前,查看 token 是否有效
if (token == null || token.isEmpty() || !token.equals(session.getAttribute(token))) {
throw new RuntimeException("重复提交表单");
}
// 正常提交表单,删除 token
session.removeAttribute(token);
// Update user in database...
System.out.println("Username: " + username + ", Password: " + password);
// 操作结果显示给用户
redirectAttributes.addFlashAttribute("result", "The user is already successfully updated");
return "redirect:result";
}
// 显示表单处理结果
@RequestMapping("/result")
public String result() {
return "result.htm";
}
}
Update User
,表单成功提交后被重定向到 result 页面Update User
,因为 token 不存在了,程序抛出异常,防止了表单的重复提交(显示异常页面不是最好的办法,更友好的做法是显示一个表单重复提交提示页面)思考一下,为了给 user-form 增加 token,在处理 user-form 的方法里新加了很多代码,如果有 10 个 form, 100 form 都要使用 token 的机制呢?难道要去每个 form 处理的方法里都加上上面的那么多代码吗?上面 token 使用的是 UUID,如果要改成 static 类型的整数,每次生成时都加 1 呢? token 存储在 session 里,项目进行到一定的时候要决定存储在第三方缓存如 Redis 里呢?每次需求的变更都要修改所有 form 的处理方法? 工作量也太大了,谁遇到这样的问题都会抓狂,难怪招聘里着重强调:不许打项目经理!
什么是 token? 简单的说就是一次操作的标识,可以是数字,字符串,甚至对象等,只要能把不同的表单提交区别开来就可以了。申请表单的时候生成一个 token,表单提交后删除 token。
Result: ${result!}
在 form 里增加一个 input 域存放 token.
<!DOCTYPE html>
<html>
<head>
<title>Update User</title>
</head>
<body>
<form action="/user-form" method="post">
<input type="input" name="token" value="${token!}"><br>
Username: <input type="text" name="username"><br>
Password: <input type="text" name="password"><br>
<button type="submit">Update User</button>
</form>
</body>
</html>
和开始的 Controller 代码一样,没有 token 的相关代码。
package controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
@Controller
public class ParameterController {
// 显示表单
@RequestMapping(value = "/user-form", method= RequestMethod.GET)
public String showUserForm() {
return "user-form.htm";
}
// 更新 User,把操作结果保存到 redirectAttributes,
// redirect 到 result 页面显示操作结果
@RequestMapping(value = "/user-form", method= RequestMethod.POST)
public String handleUserForm(@RequestParam String username,
@RequestParam String password,
final RedirectAttributes redirectAttributes) {
// Update user in database...
System.out.println("Username: " + username + ", Password: " + password);
// 操作结果显示给用户
redirectAttributes.addFlashAttribute("result", "The user is already successfully updated");
return "redirect:result";
}
// 显示表单处理结果
@RequestMapping("/result")
public String result() {
return "result.htm";
}
}
拦截器 TokenValidator 用于生成和校验 token。
package interceptor;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;
public class TokenValidator implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// POST, PUT, DELETE 请求都有可能是表单提交
if (!"GET".equalsIgnoreCase(request.getMethod())) {
String clientToken = request.getParameter("token");
String serverToken = (String) request.getSession().getAttribute(clientToken);
if (clientToken == null || clientToken.isEmpty() || !clientToken.equals(serverToken)) {
throw new RuntimeException("重复提交表单");
}
// 正常提交表单,删除 token
request.getSession().removeAttribute(clientToken);
}
return true;
}
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
// GET 请求访问表单页面
if (!"GET".equalsIgnoreCase(request.getMethod())) {
return;
}
// 生成 token 存储到 session 里,并且保存到 form 的 input 域
String token = UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();
modelAndView.addObject("token", token);
request.getSession().setAttribute(token, token);
}
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
}
}
<beans>
...
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/user-form"/> <!--需要增加 token 校验的 form 的 URI-->
<bean class="interceptor.TokenValidator"></bean>
</mvc:interceptor>
</mvc:interceptors>
</beans>
Update User
,表单成功提交后被重定向到 result 页面Update User
,因为 token 不存在了,程序抛出异常,防止了表单的重复提交(显示异常页面不是最好的办法,更友好的做法是显示一个表单重复提交提示页面)Token 的存储需要考虑过期时间,否则访问 10 万次 user-form 页面,生成 10 万个 token 而不提交表单,token 一直不会被删除,会造成很大的资源浪费。
Token 应该写入到 form 的隐藏域,为了直观,上面我们写入了普通的 input 中:
<input type="hidden" name="token" value="${token!}">
这里使用的是 SpringMVC 的拦截器生成和校验 token,当然也可以使用 Servlet 的 Filter 等技术实现。
重复提交表单时不应该直接把异常显示给用户,可以使用 SpringMVC 的异常处理机制,不同的页面显示不同异常的友好信息。