Spring Web FlashMap引发的血案
Spring Web 3.1之后引入了叫作Flash Attribute的功能,旨在解决类似POST/Redirect/GET这种请求模式中的属性传递问题,但由于从老系统到新系统的迁移过程中,用户Session都开始使用基于Redis的会话组件(HttpSession的Redis版本),在老系统的POST/Redirect/GET这种请求模式下出现了问题,因此记下一笔。
常见的用户场景
比如,用户在浏览器端填充完表单,然后提交,服务器端处理完,可以使用Forward的方式将用户转发到对应的页面,但Forward完成之前,用户有可能强制刷新页面,这样可能造成重复提交,因此可能会使用Redirect来响应用户:

这样的确可以减少服务端Redirect后的重复提交问题,但若在Redirect之前用户强制刷新,也会存在重复提交问题,其他防止重复提交的可以使用Token校验等方式,或者更好的方式是在浏览器端作一些前端优化,给予用户友好的等待提示。
Flash Attribute
但使用Redirect后,相当于用户上一次Request的请求参数被丢失了,我们希望能在Redirect后的那次Request获取上一次Request的某些参数或属性,比如提示用户某些字段非法等信息,于是Spring 3.1后,加入了Flash Attribute。Flash Attribute允许在前后两次Request之间传递一些参数或属性,原理在于Flash Attribute这种属性会临时放入Session中,当第二次Request后,又将这类属性从Session中移除,Spring Web中使用RedirectAttributes来放入Flash Attribute:
@RequestMapping(value="submitOrder", method=RequestMethod.POST)public String submitOrder(@ModelAttribute("Order") Order order, final RedirectAttributes redirectAttributes) { //... redirectAttributes.addFlashAttribute("message", "Submit successfully!"); //... return "redirect:submit_success";} Spring Web中使用FlashMapManager来管理Flash Attribute:
public interface FlashMapManager { /** * 获取之前请求中的FlashAttribute,并将一些过期的属性从Session中移除 */ FlashMap retrieveAndUpdate(HttpServletRequest request, HttpServletResponse response); /** * 保存FlashAttribute,并设置过期时间,该方法会在redirect这种视图类型中调用 */ void saveOutputFlashMap(FlashMap flashMap, HttpServletRequest request, HttpServletResponse response);} retrieveAndUpdate方法会在Spring Web核心组件DispatchServlet中被调用:
protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception { // .... FlashMap inputFlashMap = this.flashMapManager.retrieveAndUpdate(request, response); if (inputFlashMap != null) { // 将FlashAttribute设置在request对象中 request.setAttribute(INPUT_FLASH_MAP_ATTRIBUTE, Collections.unmodifiableMap(inputFlashMap)); } try { // 分发请求 doDispatch(request, response); } finally { // ... }} saveOutputFlashMap方法会在RedirectView视图中被调用:
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws IOException { String targetUrl = createTargetUrl(model, request); targetUrl = updateTargetUrl(targetUrl, model, request, response); // 获取FlashAttribute FlashMap flashMap = RequestContextUtils.getOutputFlashMap(request); if (!CollectionUtils.isEmpty(flashMap)) { UriComponents uriComponents = UriComponentsBuilder.fromUriString(targetUrl).build(); flashMap.setTargetRequestPath(uriComponents.getPath()); flashMap.addTargetRequestParams(uriComponents.getQueryParams()); FlashMapManager flashMapManager = RequestContextUtils.getFlashMapManager(request); if (flashMapManager == null) { throw new IllegalStateException("FlashMapManager not found despite output FlashMap having been set"); } // 保存FlashAttribute flashMapManager.saveOutputFlashMap(flashMap, request, response); } sendRedirect(request, response, targetUrl, this.http10Compatible);} Spring Web默认使用SessionFlashMapManager作为FlashMap管理器,内部将Flash Attribute保存在Session中:
public class SessionFlashMapManager extends AbstractFlashMapManager { private static final String FLASH_MAPS_SESSION_ATTRIBUTE = SessionFlashMapManager.class.getName() + ".FLASH_MAPS"; protected List<FlashMap> retrieveFlashMaps(HttpServletRequest request) { HttpSession session = request.getSession(false); // 从Session获取FlashMap属性 return (session != null ? (List<FlashMap>) session.getAttribute(FLASH_MAPS_SESSION_ATTRIBUTE) : null); } protected void updateFlashMaps(List<FlashMap> flashMaps, HttpServletRequest request, HttpServletResponse response) { // 在Session设置FlashMap属性 request.getSession().setAttribute(FLASH_MAPS_SESSION_ATTRIBUTE, flashMaps); }} 老系统迁移出现的问题,就在于(List<FlashMap>)session.getAttribute(FLASH_MAPS_SESSION_ATTRIBUTE)这里强制转换为了List<FlashMap>,而HttpSession已被重写为Redis版本,并且使用JSON序列化,并不像一些Servlet容器默认使用DataOutputStream对象序列化,因而抛出了java.lang.ClassCastException异常,并一旦请求过类似使用redirectAttributes.addFlashAttribute的方法后,后续的请求都将抛java.lang.ClassCastException,因为FLASH_MAPS_SESSION_ATTRIBUTE一直保存在Session中,最后只能重写retrieveFlashMaps以解决该问题:
public class RedisSessionFlashMapManager extends SessionFlashMapManager { private static final String FLASH_MAPS_KEY = "org.springframework.web.servlet.support.SessionFlashMapManager.FLASH_MAPS"; @Override protected List<FlashMap> retrieveFlashMaps(HttpServletRequest request) { HttpSession session = request.getSession(false); return session == null ? null : renderFlashMaps(session); } @SuppressWarnings("unchecked") private List<FlashMap> renderFlashMaps(HttpSession session) { List<HashMap<String, Object>> maps = (List<HashMap<String, Object>>)session.getAttribute(FLASH_MAPS_KEY); if (CollectionUtils.isEmpty(maps)){ return null; } List<FlashMap> flashMaps = Lists.newArrayListWithExpectedSize(maps.size()); FlashMap flashMap; for (Map<String, Object> map : maps){ flashMap = new FlashMap(); for (Map.Entry<String, Object> entry : map.entrySet()){ flashMap.put(entry.getKey(), String.valueOf(entry.getValue())); } flashMaps.add(flashMap); } return flashMaps; }} 并且需要在Spring Web Context配置使用的FlashMapManager:
<bean id="flashMapManager" class="my.RedisSessionFlashMapManager" /> Spring Web默认认为Servlet容器使用对象序列化保存Session,这似乎不是很合理,也许应该提供一个反序列化Flash Attribute的策略,更合理的方式应该是减少这种POST/Redirect/GET这种请求模式,这种模式体验并不友好,应交由前端,如AJAX去处理。