Spring Web FlashMap引发的血案

JerryXia 发表于 , 阅读 (0)
2016 年 01 月 15 日
spring

Spring Web 3.1之后引入了叫作Flash Attribute的功能,旨在解决类似POST/Redirect/GET这种请求模式中的属性传递问题,但由于从老系统到新系统的迁移过程中,用户Session都开始使用基于Redis的会话组件(HttpSession的Redis版本),在老系统的POST/Redirect/GET这种请求模式下出现了问题,因此记下一笔。

常见的用户场景

比如,用户在浏览器端填充完表单,然后提交,服务器端处理完,可以使用Forward的方式将用户转发到对应的页面,但Forward完成之前,用户有可能强制刷新页面,这样可能造成重复提交,因此可能会使用Redirect来响应用户:

post_redirect_get.png

这样的确可以减少服务端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去处理。