Spring MVCでフラッシュスコープの機能を簡単に実装する方法

JSF2.0やSeamなど、新しいフレームワークではフラッシュスコープという機能を利用することができます。これはもともとRuby on Railsで有名になった処理方式だと考えられますが、フラッシュにデータを登録しておくと一回のHTTPリダイレクトの最中のみデータが保持され、次回以降のリクエスト時までに自動的に削除されます。従来こうした仕掛けをHTTPセッションを使ってアプリロジック中で毎回個別に実現するのは結構面倒で、またデータが正しくクリアされずに残存するなどのバグも簡単に発生しがちでした。
以前はあまり知られていませんでしたが、2重送信の問題を回避するために、最近はPRG(Post/Redirect/Get)パターンというのがよく知られるようになっています。*1このパターンでは、POSTリクエストで画面遷移する場合は、間にリダイレクトとGETをはさむことでURLバーのアドレス表示と実際の画面の表示内容を常に一致させるようにしますが、この場合にメッセージなどを一時的に保持する役割でフラッシュスコープが特に役に立ちます。Spring MVCでももうすぐリリースされる予定のバージョン3.1でこのフラッシュスコープの機能が取り込まれるようです。
Flash Scope for Spring MVC (Without Spring Web Flow) [SPR-6464] · Issue #11130 · spring-projects/spring-framework · GitHub
以上のJIRAの記述をヒントに、一足先に現行バージョンでこのフラッシュスコープを実現する方法を考えてみました。まず、フラッシュスコープでデータを保持させるために、以下のクラスを作成します。*2

public class FlashMap {
	static final String FLASH_SCOPE_ATTRIBUTE = FlashMap.class.getName();
	
	public static Map<String, Object> getCurrent(HttpServletRequest request) {
		HttpSession session = request.getSession(); 

		synchronized (session) {
			@SuppressWarnings("unchecked")
			Map<String, Object> flash = (Map<String, Object>) session.getAttribute(FLASH_SCOPE_ATTRIBUTE);
			if (flash == null) {
				flash = new HashMap<String, Object>();
				session.setAttribute(FLASH_SCOPE_ATTRIBUTE, flash);
			}
			
			return flash;
		}
	}
	
	private FlashMap() {}
}

次に、このフラッシュMapに格納されたデータをリクエストスコープに転記した後に、セッションから自動的に削除する処理をサーブレットフィルターとして実装します。

public class FlashMapFilter extends OncePerRequestFilter {

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
		throws ServletException, IOException {
		HttpSession session = request.getSession(false);
		if (session != null) {
			synchronized (session) {
				@SuppressWarnings("unchecked")
				Map<String, ?> flash = (Map<String, ?>) session.getAttribute(FlashMap.FLASH_SCOPE_ATTRIBUTE);
				if (flash != null) {
                                	// フラッシュのデータが存在していたら、リクエストコンテキストにコピーする
					for (Map.Entry<String, ?> entry : flash.entrySet()) {
						Object currentValue = request.getAttribute(entry.getKey());
						if (currentValue == null) {
							request.setAttribute(entry.getKey(), entry.getValue());
						}					
					}

                                	// フラッシュを削除				
					session.removeAttribute(FlashMap.FLASH_SCOPE_ATTRIBUTE);
				}
			}
		}
		
		filterChain.doFilter(request, response);
	}
}

この処理はサーブレットフィルターの代わりにHandlerInterceptorやAOPでも実現できますが、フィルターで実装しておけば、Spring MVCに依存しない任意のクラスからフラッシュのデータを利用できるためよいと思います。もちろん、以上のフィルターをWEB-INF/web.xmlにて以下のように登録します。

  <filter>
    <filter-name>flashMapFilter</filter-name>
    <filter-class>com.github.ryoasai.springmvc.flashmap.FlashMapFilter</filter-class>
  </filter>
...
  <filter-mapping>
    <filter-name>flashMapFilter</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

Spring MVCではServletRequest中に転記されたデータは自動的にModelマップにコピーされるため、コントローラーやビューからは直接FlashのMapを意識することなく、普通にModelマップとやり取りすればよいというところが低結合度という点で設計上のポイントになります。以下がコントローラーの実装例です。

@Controller
@RequestMapping(value="/account")
@SessionAttributes("account")
public class AccountController {

	private Map<Long, Account> accounts = new ConcurrentHashMap<Long, Account>();
	
	@RequestMapping(method=RequestMethod.GET)
	public String getCreateForm(Model model) {
		model.addAttribute(new Account());
		return "account/createForm";
	}
	
	@RequestMapping(method=RequestMethod.POST)
	public String create(@Valid Account account, BindingResult result, Model model, SessionStatus sessionStatus) {
		if (result.hasErrors()) {
			return "account/createForm";
		}
		this.accounts.put(account.assignId(), account);
		
		sessionStatus.setComplete();

                // リダイレクト前にメッセージをモデルマップに保存しておく。		
		model.addAttribute("message", "An account of id = " + account.getId()+ " was created.");

                // フラッシュにモデル中のデータを保存させるためにredirect_with_flash:というプレフィックスをつけてリダイレクト
		return "redirect_with_flash:/account/" + account.getId();
	}
	
	@RequestMapping(value="{id}", method=RequestMethod.GET)
	public String getView(@PathVariable Long id, Model model) {
                // この画面を最初に表示したときのみフラッシュ中のメッセージが表示される。
		Account account = this.accounts.get(id);
		if (account == null) {
			throw new ResourceNotFoundException(id);
		}
		model.addAttribute(account);
		return "account/view";
	}
}

ここで、普通Spring MVCでリダイレクトを行う場合はredirect:というプレフィックスをつけるのですが、ここではフラッシュに保存することを示すためにredirect_with_flash:という別のプレフィックスをつけるようにしてみました。
あとは、このプレフィックスが付いていた場合にリダイレクト処理を行う前にフラッシュにデータを保存するようにすればよいだけなのですが、通常、redirect:というプレフィックスが付いていたときにRedirectViewを生成して処理を転送する仕掛けはUrlBasedViewResolverというクラスの以下のメソッドで実装されています。

(UrlBasedViewResolverの一部)

	@Override
	protected View createView(String viewName, Locale locale) throws Exception {
		// If this resolver is not supposed to handle the given view,
		// return null to pass on to the next resolver in the chain.
		if (!canHandle(viewName, locale)) {
			return null;
		}
		// Check for special "redirect:" prefix.
		if (viewName.startsWith(REDIRECT_URL_PREFIX)) {
			String redirectUrl = viewName.substring(REDIRECT_URL_PREFIX.length());
			return new RedirectView(redirectUrl, isRedirectContextRelative(), isRedirectHttp10Compatible());
		}
		// Check for special "forward:" prefix.
		if (viewName.startsWith(FORWARD_URL_PREFIX)) {
			String forwardUrl = viewName.substring(FORWARD_URL_PREFIX.length());
			return new InternalResourceView(forwardUrl);
		}
		// Else fall back to superclass implementation: calling loadView.
		return super.createView(viewName, locale);
	}

そこで、一つの拡張の方法としては以下のようにUrlBasedViewResolverのサブクラスを作成し、通常の処理を乗っ取ってしまう方法が考えられます。

public class FlashMapStoringRedirectViewResolver extends UrlBasedViewResolver {

	public static final String REDIRECT_WITH_FLASH_URL_PREFIX = "redirect_with_flash:";

	private String redirectWithFlashUrlPrefix = REDIRECT_WITH_FLASH_URL_PREFIX;

	public FlashMapStoringRedirectViewResolver() {
		setViewClass(FlashMapStoringRedirectView.class);
	}
       
        // 柔軟性を高めるためプレフィックスはDIにより外部で変更可能にしておく
	public String getRedirectWithFlashUrlPrefix() {
		return redirectWithFlashUrlPrefix;
	}

	public void setRedirectWithFlashUrlPrefix(String redirectWithFlashUrlPrefix) {
		this.redirectWithFlashUrlPrefix = redirectWithFlashUrlPrefix;
	}

	
	@Override
	protected Class<FlashMapStoringRedirectView> requiredViewClass() {
		return FlashMapStoringRedirectView.class;
	}

	@Override
	protected View createView(String viewName, Locale locale) throws Exception {
		if (!canHandle(viewName, locale)) {
			return null;
		}
		
		if (viewName.startsWith(getRedirectWithFlashUrlPrefix())) {
			String redirectUrl = viewName.substring(getRedirectWithFlashUrlPrefix().length());
			return new FlashMapStoringRedirectView(redirectUrl, isRedirectContextRelative(), isRedirectHttp10Compatible());
		}
		
		return null; //リダイレクト時以外は後続のレゾルバーのチェーンに処理をまわす。
	}

	private static class FlashMapStoringRedirectView extends RedirectView implements View {

		public FlashMapStoringRedirectView(String redirectUrl, boolean redirectContextRelative, boolean redirectHttp10Compatible) {
			super(redirectUrl, redirectContextRelative, redirectHttp10Compatible);
			setExposeModelAttributes(false); // リダイレクト時にURLのパラメーターとして値を埋め込む処理を無効化する。
		}

		@Override
		protected void renderMergedOutputModel(
				Map<String, Object> model, HttpServletRequest request, HttpServletResponse response)
				throws IOException {

			// ここでフラッシュマップにモデルマップ中のデータを保存しておく
			FlashMap.getCurrent(request).putAll(model);
			
			super.renderMergedOutputModel(model, request, response);
		}

	}
}

あとは、この独自のViewResolverをDIの設定ファイルで以下のように設定すれば完了です。

	<bean class="com.github.ryoasai.springmvc.flashmap.FlashMapStoringRedirectViewResolver">
		<property name="order" value="1" />
	</bean>

...
	<bean
		class="org.springframework.web.servlet.view.InternalResourceViewResolver">
		<property name="prefix" value="/WEB-INF/views/" />
		<property name="suffix" value=".jsp" />
		<property name="order" value="3" />
	</bean>

ここではChain of Responsibilityパターンによって順次異なるViewResolverに処理が委譲されていくのですが、order属性の低いものから優先して処理が呼び出されるという点に注意してください。したがって、今回作成したFlashMapStoringRedirectViewResolverの優先度を高く設定することで、フラッシュスコープの独自処理が必要な場合には通常の処理を乗っ取ることが可能になります。
サンプルのソースコードの全体は以下に登録してあります。GitHub - ryoasai/spring-mvc-exts: Some extensions to the Spring MVC web application framework.

*1:2重クリックや更新ボタンには効果があるが戻るボタンへの対処は必要ならトランザクショントークンなどの別の仕組みが別途必要になる。

*2:より本格的な実装でブラウザーの複数タブ対応などを考えた場合、セッションではなく会話スコープを実装するのが正式ですが、ここでは簡単にセッションの特定のキーに対する値としてフラッシュのデータを格納する実装としています。