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