Spring Framework Advent Calendar 2011 part.8 - Spring MVC で String 配列にカンマ入り文字列を渡すと分割されてしまう問題

Spring framework Advent Calender 8 日目です。本日は Spring MVC に戻って、配列型のチェックボックスにまつわる話題です。

こんな画面を想像してください。

よくある Web の入力フォームです。チェックボックスの部分に注目してください。この部分は、対応する Form に配列が使われています。JSP のソースと、対応する Form のソースを見てみましょう。

  ...
  <div class="field">
    <div class="label">アイテム</div>
    <form:checkboxes items="${names}" path="items" delimiter=" "/>
  </div>
 
  <div class="button">
    <input type="submit" name="execute" value="登録" />
  </div>
  ...
import java.io.Serializable;

@SuppressWarnings("serial")
public class RegisterForm implements Serializable {
    ...
    
    private String[] items;
    
    ...
    
    public String[] getItems() {
		return items;
    }
    
    public void setItems(String[] items) {
		this.items = items;
    }
}


このとき、生成されるHTMLのソースは以下のようになっています(長過ぎる行には部分的に改行を入れています)。

<div class="label">アイテム</div>
  <span><input id="items1" name="items" value="orange" type="checkbox"><label for="items1">orange</label></span>
<span> <input id="items2" name="items" value="apple" type="checkbox"><label for="items2">apple</label></span>
<span> <input id="items3" name="items" value="melon" type="checkbox"><label for="items3">melon</label></span>
<span> <input id="items4" name="items" value="gra,pe" checked="checked" type="checkbox"><label for="items4">gra,pe</label></span>
<input name="_items" value="on" type="hidden"></div>
<div class="button">
  <input name="execute" value="登録" type="submit">
</div>
</form></div>


これらのチェックボックスをチェックしたとき、配列に渡る値はどのようになるでしょうか? 今回のエントリのタイトルから、読者の方は既にお分かりだとは思いますが...。



特に問題なさそうです。では、チェックを1つにしてみます。



おや? "gra,pe" が "gra" と "pe" に分割されてしまっていますね。
これは、Spring が Bean にリクエストの値をバインドするときに動作する ConversionService に原因があります。Spring MVC のリクエスマッピングは、HTTP でとんでくるリクエスト文字列をさまざまな型に変換して Bean にセットする仕組みになっています。その型変換をおこなうのが ConversionService なのですが、そのうちのひとつに StringToArrayConverter があります。これはカンマ区切り文字列を String 配列に置き換える Converter ですが、これが今回、こちらの意図しない動作をした原因です。

原因がわかれば対処法はあります。今回は、カスタムの PropertyEditor を登録する方法で回避してみます。

/**
 * カンマを分割しない {@link StringArrayPropertyEditor}.
 * <p>
 * 配列型のフォームに対して カンマを含む String のデータを渡すと、
 * StringToArrayConverter が動いて値が分割されてしまう問題の回避策。
 *
 * @see StringArrayPropertyEditor
 */
public class CommaIgnoredStringArrayEditor extends PropertyEditorSupport {
    @Override
    public void setAsText(String text) throws IllegalArgumentException {
        String[] array = new String[] { text };
        setValue(array);
    }
}


そして、この仕掛けが必要となる Controller の initBinder メソッドでカスタムエディタを追加します。

    @InitBinder
    public void initBinder(WebDataBinder binder) {
            binder.registerCustomEditor(String[].class, new CommaIgnoredStringArrayEditor());
        }
    }



こんどは大丈夫なようですね。
なお、今回のような問題は、

  • 配列型のプロパティで、
  • 画面側での選択が1つしかない
  • かつ、その選択した値にカンマが含まれている

という特殊な状況でしか起こりませんので、このような対処が必要になること自体あまりないかもしれません。ただ、こうして Spring の拡張ポイントを覚えていくのもまた一興かな、とも思います。

Spring Framework Advent Calendar 2011 part.7 - Spring Security で認証成功時に条件によって遷移先を変える

Spring Framework Advent Calendar の 7 日目です。今日はふたたび Spring Security の話です。小出しにしているような感もありますが、気にせずいきましょう。


以前 Spring Security でアカウントロック機構を実現する - 倖せの迷う森 にて、Spring Security では認証の成功や失敗に対応するイベントハンドラがあり、それらのイベントに対応する Listener を書いて Bean 登録しておけば自動的にそれが呼び出される、という話をしました。
さて、あなたが現在作成中の Web システムにこのような認証に関する要件が与えられたとします。

  • 認証はIDとパスワードで行う
  • パスワードには有効期限がある
  • 有効期限を過ぎたパスワードでログインした場合、パスワード強制変更画面に飛ばす


以前紹介した ApplicationListener を用いる手法では、ログイン後に遷移するURLを条件によって変更することはできませんので、このような要件を解決することはできませんでした。ApplicationListener は、認証イベントに対応して実行される「裏方」のようなもので、HTTP に関する操作(リダイレクトとか)というのは想定されていません。
ああ、認証成功時に遷移するURLに対して手を加えられるような方法があれば...そう思いますよね。まさにその部分を担当している Spring Security のインターフェースがあります。そう、AuthenticationSuccessHandler です。この Handler は、名前の通り認証成功時に実行される、遷移先のURLをコントロールするためのハンドラです。インターフェース宣言されているメソッドのシグニチャ

public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
        Authentication authentication) throws ServletException, IOException

であることからも分かる通り、HttpRequest や HttpResponse を操作するためにあるインターフェースであることが分かると思います。このインターフェースを適切に実装すれば、上記要件を満たすことができそうですね。
それでは実装例を見てみましょう。

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;

public class PasswordExpiredCheckAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {

    private String redirectUrl;

    public void setRedirectUrl(String redirectUrl) {
        this.redirectUrl = redirectUrl;
    }
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws ServletException, IOException {

        String userId = authentication.getName();

        if (isPasswordExpired(userId)) {
            RedirectStrategy redirectStrategy = getRedirectStrategy();
            redirectStrategy.sendRedirect(request, response, redirectUrl);
            return;
        }
        super.onAuthenticationSuccess(request, response, authentication);
    }
    
    private boolean isPasswordExpired(String userId) {
        // implementation...
    }
}


今回は AuthenticationSuccessHandler の実装のひとつである SavedRequestAwareAuthenticationSuccessHandler をさらに拡張する方式を採用しています。SavedRequestAwareAuthenticationSuccessHandler は、認証のために Spring Security が割り込んだ際の元のリクエストを、認証成功時に復元するというハンドラです*1
この拡張された AuthenticationSuccessHandler を利用するには、Spring Security の Bean 定義ファイルに以下のように書きます。

    ...
    <sec:http auto-config="true">
        ...
        <sec:form-login
            authentication-success-handler-ref="passwordExpiredCheckAuthenticationSuccessHandler" />
        ...
    </sec:http>
    <bean id="passwordExpiredCheckAuthenticationSuccessHandler"
        class="org.example.app.PasswordExpiredCheckAuthenticationSuccessHandler">
        <property name="redirectUrl" value="/expired" />
        <property name="alwaysUseDefaultTargetUrl" value="true" />
        <property name="defaultTargetUrl" value="/" />
    </bean>
    ...


このように、新しく作成したハンドラを Bean 登録した上で、それを の authentication-success-handler-ref 属性で参照します。
以前紹介した ApplicationListener を用いる手法と今回紹介した手法の使い分けとしては、

  • 認証後の遷移先を制御する目的なら AuthenticationSuccessHandler
  • 認証成功にトリガーして、裏で何かする (ロギングなど) なら ApplicationListener

このように意識しておけばよいでしょう。

*1:元リクエストは通常、Session スコープに保持されています。

Spring Framework Advent Calendar 2011 part.6 - 同一リクエスト内で同じ値を返す Date の実装

Spring Framework Advent Calendar の 6 日目です。本日は夜に更新時間が取れないため、前倒しでの投稿です。内容も簡易版となっております。

さて、時間もあまりないので単刀直入にいきます。同一リクエスト内で同じ値を返す実装と云うのは、つまりこういうことです。

/**
 * システム日付を返すインターフェースです。
 */
public interface DateContext {

    /**
     * システムの現在日時を返します。
     * @return 現在日時
     */
    public Date now();
}
/**
 * リクエストスコープにバインドされた {@link DateContext} です。
 * 同一のリクエスト内では、常に同じ時刻を指します。
 */
@Service
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.INTERFACES)
public class RequestScopeDateContext implements DateContext {
    public static final String NAME = "requestScopeDateContext";

    private Date now;

    /**
     * メソッドを呼び出したリクエストスコープ内で同一の現在日時を返します。
     */
    @Override
    public synchronized Date now() {
        if (now == null) {
            now = new Date();
        }
        return now;
    }
}
/**
 * {@link DateContext} のデフォルト実装。
 */
@Service
public class DefaultDateContext implements DateContext {

    /**
     * メソッドを呼び出した時点での現在日時を返します。
     * @return 現在日時
     */
    @Override
    public Date now() {
        return new Date();
    }
}

ScopedProxy を利用して RequestScope と Bean のライフサイクルを結びつけている例です。この方式の良いところは、

  • 「毎回、そのときのシステム時刻を返す DateContext」「同一リクエスト内では同じシステム時刻を返す DateContext」といった、時刻を返す機能は同じでも、異なる動作が求められるようなコンポーネントを Bean 定義ファイルやアノテーションで比較的簡単に交換できること。
  • テスト時にモックとの差し替えが容易なこと。
    • 内部で return new Date(); とかやってるコードをテストするのはなかなかしんどい。*1

といったところでしょうか。

なお、@Scope をすでに利用している方は既知の情報ですが、@Scope("request") や @Scope("session") を利用するには web.xml の listener 定義でorg.springframework.web.context.request.RequestContextListener を指定しておく必要があります。

  <listener>
    <listener-class>org.springframework.web.context.request.RequestContextListener</listener-class>
  </listener>

利用する側は、単に DateContext を利用したいサービスに DI してやるだけです。簡単ですね。


で、こんなものが何の役に立つのかという話なのですが、複数のテーブルを同一リクエスト内で更新するときに、更新日時をすべて同じにしたい、といったケースがあったりします。そんなときに、多少役に立つかもしれません。

Spring Framework Advent Calendar 2011 part.5 - Spring Security でセッションタイムアウト時の遷移先をカスタマイズする

Spring Framework Advent Calendar の 5 日目です。今日は Spring Security の話です。なんだか Spring MVC と Spring Security の話ばかりしているような気がしますが、あまり気にしないことにします。

セッションタイムアウト時の画面遷移

ログインが必要なWebアプリケーションの開発をしていると、たいていの場合、

といった要望を受けます。
Spring Security のデフォルトの動作は、セッションタイムアウトを特別扱いはしていませんから、ログインしていない状態でのアクセスと同じように画面遷移します。この挙動を変更したい場合はどのようにすればよいでしょうか。
Spring Security 3.0 以降の話になりますが、Spring 3.0 ではログイン画面へのリダイレクトを決定する LoginUrlAuthenticationEntryPoint があります*1。この AuthenticationEntryPoint をカスタマイズすることで、デフォルトの挙動を変更することができます。以下は、セッションタイムアウト時に、timeout=true という URL パラメーターを付加したうえでログイン画面へのリダイレクトを行う例です。

public class SessionExpiredDetectingLoginUrlAuthenticationEntryPoint
        extends LoginUrlAuthenticationEntryPoint {

    @Override
    protected String buildRedirectUrlToLoginPage(
            HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) {

        String redirectUrl = super.buildRedirectUrlToLoginPage(request, response, authException);
        if (isRequestedSessionInvalidate(request)) {
            redirectUrl += redirectUrl.contains("?") ? "&" : "?";
            redirectUrl += "timeout=true";
        }
        return redirectUrl;
    }

    private boolean isRequestedSessionInvalidate(HttpServletRequest request) {
        return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid();
    }
}


ただし、この方式では JavaScriptXMLHTTPRequest を用いて行われる通信で発生したセッションタイムアウトを検出することができません。より正確には、サーバーサイドはきちんとセッションタイムアウトを検出してエラー画面を返します。しかし、XMLHTTPRequest を用いて通信し、サーバーの応答が JSON であることを期待しているようなコードの場合はHTMLが返ってきても対応できず、おかしな動作となります。この問題を解決する方法としては Spring Securityで、セッションタイムアウト時のAjaxリクエストに対応する - penultの日記 にもあるとおり、HTTP応答コードで適切なエラーコードを返してやる方法がよいでしょう。この修正を取り込むと、最終的に以下のようなコードになりました。

public class SessionExpiredDetectingLoginUrlAuthenticationEntryPoint
        extends LoginUrlAuthenticationEntryPoint {

    private static final String AJAX_REQUEST_IDENTIFIER = "XMLHttpRequest";

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {

        if (AJAX_REQUEST_IDENTIFIER.equals(request.getHeader("X-Requested-With"))) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        super.commence(request, response, authException);
    }
    
    @Override
    protected String buildRedirectUrlToLoginPage(
            HttpServletRequest request, HttpServletResponse response,
            AuthenticationException authException) {

        String redirectUrl = super.buildRedirectUrlToLoginPage(request, response, authException);
        if (isRequestedSessionInvalidate(request)) {
            redirectUrl += redirectUrl.contains("?") ? "&" : "?";
            redirectUrl += "timeout=true";
        }
        return redirectUrl;
    }

    private boolean isRequestedSessionInvalidate(HttpServletRequest request) {
        return request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid();
    }
}


この AuthenticationEntryPoint を利用するには、Spring Security の Bean 定義ファイルに設定が必要です。security 名前空間 タグに entry-point-ref 属性がありますので、ここに AuthenticationEntryPoint の id を記述します。もちろん、カスタマイズ済みの AuthenticationEntryPoint については新たに Bean 定義を記述する必要があります。

  <sec:http auto-config="true" entry-point-ref="sessionExpiredDetectingEntryPoint">
    <sec:intercept-url pattern="/**" access="IS_AUTHENTICATED_ANONYMOUSLY" />
    <sec:intercept-url pattern="/admin/" access="ROLE_ADMIN" />
  </sec:http>

  <bean id="sessionExpiredDetectingEntryPoint"
    class="examples.security.SessionExpiredDetectingLoginUrlAuthenticationEntryPoint">
    <property name="loginFormUrl" value="/login" />
  </bean>

まとめ

セッションタイムアウト時の画面遷移の動きをカスタマイズするのは、方法を知っていれば簡単にできるということをお話ししました。

*1:以前は AuthenticationProcessingFilterEntryPoint と呼ばれていたようです。

Spring Framework Advent Calendar 2011 part.4 - Spring MVC 3.1 で導入される Flash Scope

Spring Framework Advent Calendar の 4 日目です。今日は Spring MVC 3.1 の新機能について触れようと思います。


Spring MVC 3.1 では、@MVC*1 関連の機能がさらに強化されています。そのうちのひとつである Flash Scope について紹介します。なお、本記事の執筆時点では Spring 3.1 のバージョンは RC1 であり、正式リリース版ではありません。そのため、最終的なリリース版と挙動が異なる可能性があるかもしれません(可能性は限りなく低いですが)。

Flash Scope

Flash Scope とは、通常の Request Scope より少し長い生存期間を持つスコープです。通常の HTTP Request のひとつ先の Request の時点まで生存しています。主に、リダイレクト後のビューで一回だけメッセージを表示する目的などで使用されます(画面をリロードすると消える)。 Spring MVCでフラッシュスコープの機能を簡単に実装する方法 - 達人プログラマーを目指して に、もう少し詳しい解説がありますので、併読されると良いと思います*2

実装例

それでは、実際の例を見てみます。以下は適当に作った Controller クラスです。

@Controller
public class ApplicationController {

    @RequestMapping("/")
    public String index() {
        return "index";
    }

    @RequestMapping(value = "/register", method = RequestMethod.GET)
    public String registerInput(Model model) {
    	if (!model.containsAttribute("registerForm")) {
            model.addAttribute(new RegisterForm());
    	}
        return "register/input";
    }

    @RequestMapping(value = "/register", method = RequestMethod.POST)
    public String register(@Valid RegisterForm form, BindingResult result, RedirectAttributes attrs) {
        if (result.hasErrors()) {
            return "register/input";
        }
        attrs
            .addAttribute("id", form.getName())
            .addFlashAttribute("message", "登録されました.");
        return "redirect:/register";
    }
}


注目すべきは、最後のメソッドです。

    @RequestMapping(value = "/register", method = RequestMethod.POST)
    public String register(@Valid RegisterForm form, BindingResult result, RedirectAttributes attrs) {
        if (result.hasErrors()) {
            return "register/input";
        }
        attrs
            .addAttribute("id", form.getName())
            .addFlashAttribute("message", "登録されました.");
        return "redirect:/register";
    }


メソッドの引数に RedirectAttributes とあります。これが、Spring 3.1 から追加されたインターフェースです。位置づけは org.springframework.ui.Model インターフェースの拡張になっています。
Spring 3.1 において、リダイレクトに関するパラメーターは、このインターフェースを通してフレームワークに通知することができます。そして、通知の際に addAttribute メソッドを使用すれば通常のURLパラメーターとして扱われ、addFlashAttribute メソッドを使用すれば Flash Scope として扱われる――つまり、リダイレクト後の画面まで引き継がれるパラメーターとなります。この機構は Spring MVC 本体に組み込まれているため、Flash Scope を利用する際に特別な Bean 宣言をする必要もありません。
なお、addAttribute や addFlashAttribute メソッドは、インスタンス自身を return するように設計されています。いわゆる「流れるようなインターフェース」で、メソッドチェーンを使用して連続で add の操作を行うことができるようになっています。細かい点ではありますが、嬉しいですね。


実際の画面で見てみましょう。簡単な入力フォームです。これは、先ほど登場した Controller に対応する画面です。


名前の欄に、適当な値を入力します。


入力後、「登録」を押すと、結果が表示されます。


ここで注意してほしいのは、id だけが URL のパラメーターとして表示されており message のほうは表示されていないことです。
では、画面を F5 等でリロードしてみます。POST したページであれば、リロードをかけようとすると、ブラウザによって「フォームを再送信しますか?」というような内容のダイアログが表示されますが、いまはリダイレクト後の画面ですので、もちろん表示されません。


ご覧の通り、message のほうは消えてしまいます。message のスコープは Flash Scope であることが確認できました。
参考までに、画面の HTML (正確には JSP) のソースを掲載しておきます。

<%@ page contentType="text/html; charset=utf-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>

<style type="text/css">
.inputForm .field {
    margin: 1em;
}

.inputForm .button ul li {
    list-style-type: none;
    float: left;
    margin: 0.5em;
}
</style>

<div class="inputForm">
    <form:form modelAttribute="registerForm" method="post">
        <div class="field">
            <div class="label">名前</div>
            <div class="form"><form:input path="name" /></div>
            <form:errors path="name" />
        </div>
        
        <div class="field">
            <div class="label">生年月日</div>
            <div class="form"><form:input path="birthday" /></div>
            <form:errors path="birthday" />
        </div>
        
        <div class="button">
            <input type="submit" name="execute" value="登録" />
        </div>
    </form:form>
</div>

<p><c:if test="${not empty param['id']}">id=<c:out value="${param['id']}" /></c:if></p>
<p><c:if test="${not empty message}">message=<c:out value="${message}" /></c:if></p>

<p><a href="<spring:url value="/" />">戻る</a></p>

まとめ

  • @MVC においては RedirectAttributes#addFlashAttribute メソッドを使用すると自動的に Flash Scope が使われる
  • その他の特別な設定は不要(!)

*1:2.5 より導入されたアノテーションベースのアプローチをこのように称することがあるようです。

*2:リンク先の記事では、Spring 3.0 で Flash Scope を先取りして実現する方法にも言及されており、大変有用です。

Spring Framework Advent Calendar 2011 part.3 - Spring Security で LDAP 認証を行う

Spring Framework Advent Calendar の 3 日目です。早くもネタが尽きてきました。本日も Spring Security ネタです。

業務アプリケーションにおけるユーザー認証で圧倒的に多いのは、DB接続を用いた認証でしょう。Spring Security はもちろん、ユーザーDBと認証基盤との接続レイヤーを用意してくれています。スタンダードであるこの方式については書籍などでもよく取り上げられるでしょうから、ここでは触れません。今回は、DB接続による認証に比べるとマイナーな LDAP 認証をあつかうケースについて紹介します。

Spring Security は、認証を行う方法を提供するサービスを AuthenticationProvider として抽象化しています。具体的な実装として、たとえば以下のようなものがあります。

AuthenticationProvider 説明
DaoAuthenticationProvider データベースアクセスによる認証
LdapAuthenticationProvider LDAP による認証
OpenIDAuthenticationProvider OpenID による認証
PreAuthenticatedAuthenticationProvider すでに認証されている場合にチェックなしで認証を通す
RememberMeAuthenticationProvider 「ログインを記憶する」のような remember me 機能での認証


認証において利用する AuthenticationProvider の情報は、例によって Spring の Bean 定義ファイルに記述しておく必要があります。が、DaoAuthenticationProvider のような利用頻度の高い認証プロバイダーの設定は、Spring Security の namespace 拡張によって、簡易的に記述できるようになっています。そして、LdapAuthenticationProvider も、この簡易記法がサポートされています!

  <sec:ldap-server url="ldap://your.ldap.server.domain:389/dc=foo,dc=bar" />
  
  <sec:authentication-manager>
    <sec:ldap-authentication-provider user-dn-pattern="uid={0},ou=people" />
  </sec:authentication-manager>


LDAP 認証を利用するには、

  • LDAP サーバーの設定
  • LdapAuthenticationProvider を使う宣言および、サーバーへの問い合わせ設定

の2つの設定を行う必要がありますが、ご覧の通り、これらは両方とも簡易記法がサポートされています。時間がなくて詳細まで説明している時間がないのが残念ですが、参考までに公式サイトへのリンクを張っておきます。
http://static.springsource.org/spring-security/site/docs/3.0.x/reference/ldap.html


こうした XML の namespace 拡張を用いるには、Bean 定義ファイルの先頭で namespace 宣言を追加してやる必要がある点には注意が必要です。たとえば、context, mvc, sec を利用できるようにした設定は以下のようになります。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:context="http://www.springframework.org/schema/context"
  xmlns:mvc="http://www.springframework.org/schema/mvc"
  xmlns:sec="http://www.springframework.org/schema/security"
  xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
    http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd
    http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
    http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.0.4.xsd">


Eclipse であれば、Spring IDE などのプラグインを導入することで、こうした記述をいくらか楽にしたり、また XML の補完もサポートしてくれますので、導入をおすすめします。STS *1 を使うのもよいでしょう。

*1:Spring Tool Suite. Spring 開発に特化した Eclipse ベースのIDE

Spring Framework Advent Calendar 2011 part.2 - Spring Security の FilterChainProxy を使って ServletFilter の設定を Spring に移管する

Spring Framework Advent Calendar の 2 日目です。本日は FilterChainProxy についてです。

Spring Framework を使っていると、協調する各プロダクトの設定ファイルを Spring の設定ファイルに統合したくなることでしょう。今回は、いつも web.xml に記述しているであろう ServletFilter の設定を Spring に移管する方法を紹介します。以前にも SpringFramework と Velocity の統合 - 倖せの迷う森 にて、Velocity の設定情報を Spring に統合する手法を紹介しました。

Spring Security には FilterChainProxy というクラスがあり、これを使用すると、サーブレットコンテナの FilterChain と同じような動作をさせることができます。こういったクラスを Spring が提供しているのは、フィルタークラスをも Spring Bean として取り扱いたいという野望があるのでしょうか。

FilterChainProxy は、Spring Security のアクセス制御を実現するためのエンジンとして用いられています*1が、もちろん他の用途に利用することもできます。たとえば、自作の Filter クラスを FilterChainProxy で束ねて取り扱いを簡単にする、といった方法が考えられます。以下のような web.xml のフィルター定義は、

  <filter>
    <filter-name>filter1</filter-name>
    <filter-class>path.to.your.package.Filter1</filter-class>
  </filter>
  <filter>
    <filter-name>filter2</filter-name>
    <filter-class>path.to.your.package.Filter2</filter-class>
  </filter>
  <filter>
    <filter-name>filter2</filter-name>
    <filter-class>path.to.your.package.Filter3</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>filter1</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
  <filter-mapping>
    <filter-name>filter1</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
  <filter-mapping>
    <filter-name>filter1</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>

filterChainProxy を使うと以下のようにまとめられます。

web.xml
  <filter>
    <filter-name>myFilterChainProxy</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
  </filter>
  <filter-mapping>
    <filter-name>myFilterChainProxy</filter-name>
    <url-pattern>/*</url-pattern>
  </filter-mapping>
applicationContext.xml*2
 <bean id="myfilterChainProxy" class="org.springframework.security.util.FilterChainProxy">
     <security:filter-chain-map pathType="ant">
         <security:filter-chain pattern="/do/not/filter" filters="none"/>
         <security:filter-chain pattern="/**" filters="filter1,filter2,filter3"/>
     </security:filter-chain-map>
 </bean>

フィルターのマッピング部分が1カ所に集約されていますね。また、FilterChainProxy のフィルタールールは pattern を複数組み合わせるなど柔軟に定義できるため、URL によって細かい制御が必要な Filter などは重宝するでしょう*3。pattern の記述方式は ant か regex を選べます。
ただし、web.xml からフィルターの定義が完全に消せるわけではありません。FilterChainProxy をkick するための Filter として DelegatingFilterProxy を記述しておく必要があります。
filter1, filter2, filter3 は通常の ServletFilter でもかまいませんが、新規に書き起こすのであれば org.springframework.web.filter.OncePerRequestFilter のサブクラスとして作成しておくことをオススメします。これらは Spring Bean として扱えますので、通常通り Bean 定義ファイルに記述してもよいですし、@Component や @Service アノテーションを与えて component-scan するアプローチも可能です。

*1:Spring Security は、認証プロセスを何層にも連なる ServletFilter の集まりで実現しています。 https://sites.google.com/site/soracane/home/springnitsuite/spring-security/spring-securityno-naibu-dousa に、より詳細な解説があります。

*2:filter="none" は、次期リリース予定の Spring Security 3.1 からサポートされなくなります

*3:そのような URL 設計が果たして適切なのかどうかはここでは言及しませんが