Spring Web FlashMap引发的血案
2016 年 01 月 15 日
spring

    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 AttributeFlash 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去处理。

好人,一生平安。