2014-02-10

@SessionAttributes 遇到多頁籤使用時,有很大的問題(Multiple Forms in One session)

Spring MVC 引入 Annotaion 時,將原本的 Controller 與 FormController 二合一,變成一隻 Controller 處理一個 Domain 的所有 Web request。

初期不習慣,久了也慢慢覺的好用,直到有一天,資料庫裡的資料暴走了,久思不得其解,最後 PM  點出,問題在使用者同時開啟多個頁籤編輯。

以前的 FormController 的作法是:
  • GET request進來,用 uid 到資料庫取 model 資料,然後再準備一些表單需要用到的資料,再將這些資料全部放到request裡。
  • 回到 JSP 後,從request裡取出資料顯示。
  • 使用者 submit 後,POST request進來,再用 uid 到資料庫取 model 資料,然後進行 data binding 與 validation,最後存到資料庫。
當初 FormController 也可以將 model 存在session中,但直覺怪怪的,所以並未使用過。

Annotation 以後的作法是:
  • GET request進來,用 uid 到資料庫取 model 資料,然後再準備一些表單需要用到的資料,再將這些資料全部放到request裡,另外將 model 資料放到session
  • 回到 JSP 後,從request裡取出資料顯示。
  • 使用者 submit 後,POST request進來,直接到session裡取出 model 資料,然後進行 data binding 與 validation,最後存到資料庫。
問題就發生在POST reqeust進來後是到session取 model 資料,這在同時開啟多個頁籤時,必定爆炸。

因為同一個controller@SessionAttributes只會用同一個名稱,也就是第一個頁籤取出約翰用user存到session,第二個頁籤取出比爾還是用user存到session

在這個當下,session裡只有一個叫做比爾的user,約翰被蓋掉了

然後使用者編輯完約翰送出,噹噹,比爾變成約翰了。

目前官方沒有奉送解答,但有提到怎麼解,說穿了很簡單,就是每個 form 都用不同的@SessionAttributes名稱就好了。

怎麼做呢?這才是問題的開始。

首先,在org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter有一個sessionAttributeStore屬性可以客制。

不要以為<mvc:annotation-driven />是用org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter這個HandlerAdapter,我被唬弄了好久,才發現已經改用org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter

sessionAttributeStore實作如下,重點只有getAttributeNameInSession(),但另外三個xxxAttribute()一定要有,是從DefaultSessionAttributeStore複製來的,如果沒有這三個xxxAttribute()的話,那神奇的Java機制會去呼叫DefaultSessionAttributeStoregetAttributeNameInSession(),還是回傳固定的名稱。

這裡的作法很簡單,就是在原本的名稱後面加上一個時間戳記,然後放到request裡,讓稍後的JSP可以放到form裡,等到submit時,又會回來sessionAttributeStore,然後就可以從session裡取回剛剛寄放獨一無二的約翰了。
public class ConversationSessionAttributeStore extends DefaultSessionAttributeStore {

  @Override
  public void storeAttribute(WebRequest request, String attributeName, Object attributeValue) {
    Assert.notNull(request, "WebRequest must not be null");
    Assert.notNull(attributeName, "Attribute name must not be null");
    Assert.notNull(attributeValue, "Attribute value must not be null");
    String storeAttributeName = getAttributeNameInSession(request, attributeName, true);
    request.setAttribute(storeAttributeName, attributeValue, WebRequest.SCOPE_SESSION);
  }

  @Override
  public Object retrieveAttribute(WebRequest request, String attributeName) {
    Assert.notNull(request, "WebRequest must not be null");
    Assert.notNull(attributeName, "Attribute name must not be null");
    String storeAttributeName = getAttributeNameInSession(request, attributeName, false);
    return request.getAttribute(storeAttributeName, WebRequest.SCOPE_SESSION);
  }

  @Override
  public void cleanupAttribute(WebRequest request, String attributeName) {
    Assert.notNull(request, "WebRequest must not be null");
    Assert.notNull(attributeName, "Attribute name must not be null");
    String storeAttributeName = getAttributeNameInSession(request, attributeName, false);
    request.removeAttribute(storeAttributeName, WebRequest.SCOPE_SESSION);
  }

  private String getAttributeNameInSession(WebRequest request, String attributeName, boolean isNew) {
    String a = super.getAttributeNameInSession(request, attributeName);
    if (isNew) {
      String c = String.valueOf(new Date().getTime());
      request.setAttribute("conversationKey", c, RequestAttributes.SCOPE_REQUEST);
      a += "_" + c;
    }
    else {
      String c = request.getParameter("conversationKey");
      if (StringUtils.isNotBlank(c)) {
        a += "_" + c;
      }
    }
    return a;
  }

}
但在這個年代,大家都是用<mvc:annotation-driven />一槍打掉所有的設定,怎麼客制RequestMappingHandlerAdapter呢?

很抱歉,<mvc:annotation-driven />裡有很多客制的屬性,就是沒有sessionAttributeStore

那就先以我淺薄的知識蠻幹吧!
<bean id="conversationSessionAttributeStore" class="idv.neil.web.ConversationSessionAttributeStore" />

<bean name="handlerAdapter" class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
 <property name="sessionAttributeStore">
  <ref bean="conversationSessionAttributeStore" />
 </property>
</bean>

<mvc:annotation-driven />
哇咧,居然就可以用,我出運了,收工關電腦下班閃人。

當然事情沒這麼簡單,過不久電話就響了,除了這功能好了,其他功能全掛了!

也沒這麼誇張了,但原本前台透過 Ajax 向後台撈資料的$.getJSON(),都死死去了。

測試第一步,當然先把<bean name="handlerAdapter" ... />拿掉看看,哇,好了,這下賽了,我的sessionAttributeStore又飛走了。

好吧,面對現實,<mvc:annotation-driven />到底做了什麼?

http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd追到org.springframework.web.servlet.config.annotation.EnableWebMvc,再追到org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport,終於找到requestMappingHandlerAdapter()
@Bean
public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {
 ConfigurableWebBindingInitializer webBindingInitializer = new ConfigurableWebBindingInitializer();
 webBindingInitializer.setConversionService(mvcConversionService());
 webBindingInitializer.setValidator(mvcValidator());

 List<HandlerMethodArgumentResolver> argumentResolvers = new ArrayList<HandlerMethodArgumentResolver>();
 addArgumentResolvers(argumentResolvers);

 List<HandlerMethodReturnValueHandler> returnValueHandlers = new ArrayList<HandlerMethodReturnValueHandler>();
 addReturnValueHandlers(returnValueHandlers);

 RequestMappingHandlerAdapter adapter = new RequestMappingHandlerAdapter();
 adapter.setMessageConverters(getMessageConverters());
 adapter.setWebBindingInitializer(webBindingInitializer);
 adapter.setCustomArgumentResolvers(argumentResolvers);
 adapter.setCustomReturnValueHandlers(returnValueHandlers);
 return adapter;
}
這裡就是<mvc:annotation-driven />預設出產HandlerAdapter的地方,逐行翻成XML之後,得到以下的結果。
<bean name="handlerAdapter" class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
  <property name="sessionAttributeStore">
    <ref bean="conversationSessionAttributeStore" />
  </property>
  <property name="webBindingInitializer">
    <bean class="org.springframework.web.bind.support.ConfigurableWebBindingInitializer">
      <property name="conversionService">
        <bean class="org.springframework.format.support.DefaultFormattingConversionService" />
      </property>
      <property name="validator">
        <bean class="org.springframework.validation.beanvalidation.LocalValidatorFactoryBean" />
      </property>
    </bean>
  </property>
  <property name="customArgumentResolvers">
    <list></list>
  </property>
  <property name="customReturnValueHandlers">
    <list></list>
  </property>
  <property name="messageConverters">
    <list>
      <bean class="org.springframework.http.converter.ByteArrayHttpMessageConverter" />
      <bean class="org.springframework.http.converter.StringHttpMessageConverter">
        <property name="writeAcceptCharset" value="false" />
      </bean>
      <bean class="org.springframework.http.converter.ResourceHttpMessageConverter" />
      <bean class="org.springframework.http.converter.xml.SourceHttpMessageConverter" />
      <bean class="org.springframework.http.converter.xml.XmlAwareFormHttpMessageConverter" />
      <bean class="org.springframework.http.converter.xml.Jaxb2RootElementHttpMessageConverter" />
      <bean class="org.springframework.http.converter.json.MappingJacksonHttpMessageConverter" />
      <!--
      <bean class="org.springframework.http.converter.feed.AtomFeedHttpMessageConverter" />
      <bean class="org.springframework.http.converter.feed.RssChannelHttpMessageConverter" />
      -->
    </list>
  </property>
</bean>

<mvc:annotation-driven />
這下真的可以下班了。

應該吧!
---
---
---

沒有留言:

張貼留言