Apache MyFaces Extension Validator(extVal)で相関チェック

BeanValidationはJSF2.xと統合した場合に、JSFから自動的に呼び出されるのはプロパティ単位のバリデーションのみである。クラス単位のバリデーションは呼び出されない。
(参考 http://stackoverflow.com/questions/11972419/cross-field-bean-validation-why-you-no-work )

この仕様上、BeanValidationの自作ルールでフィールド間チェック(cross-field valudation)を実装してJSFに適用するのは難しい。そんなときの使えるのがApache MyFacesが提供している拡張バリデータ「extVal」である。

MyFacesと関連がありそうだが、JSFの仕様を満たしているソフトウェア上ではなんでも動くようだ。手元ではJBossAS7にバンドルされたmojjaraで動かしているが、正しく動作しているように見える。
JavaEE仕様には準拠していないが、便利そうなので使ってみた。

1. インストール手順

クラスパスに直接ライブラリを格納するやり方を試してみた。
もちろんMavenでも依存性解決は可能のようだが、Antをずっと使っていると、ライブラリはダウンロードする方がなじみがある。

http://myfaces.apache.org/extensions/validator/download.html から最新版のライブラリがダウンロード可能だ。
JSF2.xに対応したmyfaces-extval20-2.0.5-bin.zip(2012/11/18 現在の最新版)をダウンロードする。

ダウンロードすると5つのJARが入っているが、今回使う最低限のjarは以下の2つ。

  • myfaces-extval-core-2.0.5.jar
  • myfaces-extval-property-validation-2.0.5.jar

WEB-INF/libに格納するだけでインストールは完了。
f:id:n_agetsuma:20121118223200p:plain

最初、5つ全部をクラスパスに通してみたが、cglibを必要とするjarが中に含まれているようで、例外が発生した。今回使う、入力フィールドの値が等しいか比較する「@Equals」を使う分には上記の2つだけで動作する。

Mavenを使う場合は、公式のwikiにの書き方が書いてある。
https://cwiki.apache.org/confluence/display/EXTVAL/Module+Overview

2. extValで相関チェック

今回はextValを使って、以下のようなパスワード再登録フォームを考えてみた。

f:id:n_agetsuma:20121118224825p:plain

extValの@Equalsを使って、新しいパスワードとその再入力の値が正しいか検証する。

import javax.enterprise.inject.Model;
import javax.validation.constraints.NotNull;
import org.apache.myfaces.extensions.validator.crossval.annotation.Equals;

@Model
public class UserManageController {
	
    @NotNull
    private String currentPassword;
	
    @NotNull
    @Equals(value="newPasswordRetype")
    private String newPassword;
	
    @NotNull
    private String newPasswordRetype;

        // 以下、省略

使い方はとても簡単で、@Equalsの引数のvalue属性に対象のフィールド名を書くだけである。
今回は新しいパスワード入力側に@Equalsを付与し、再入力された値を格納するフィールドを指定した。

3. エラーメッセージの制御

以下のように、入力フォームの横にエラー時のメッセージを表示する。

f:id:n_agetsuma:20121118225558p:plain

メッセージの制御はJSF2.0に慣れていなかったので、大変だった。
メッセージについて、やりたいことはいくつかある。

  • デフォルトは英語メッセージ(Input is different)なので、日本語対応させる。
  • Twitter BootStrapを使っているので、エラーメッセージは<span>で囲みたい。また、<span>はエラー時のみ表示したい
  • 入力フィールドを示すdivを赤色のstyle(control-group error)に変更したい

幅の都合上、若干汚いがxhtmlは最終的に以下のようになった。

<div class="#{changePasswordFormStyle.selectStyle('changePasswordForm:newPassword')}">
  <label class="control-label" for="newPassword">新しいパスワード</label>
  <div class="controls">
    <h:inputSecret id="newPassword"
      value="#{userManageController.newPassword}" label="新しいパスワード"/>
    <h:panelGroup rendered="#{changePasswordFormStyle
          .isErrorMsgRendered('changePasswordForm:newPassword')}">
      <span class="help-inline"><h:message for="newPassword"/></span>
    </h:panelGroup>
  </div>
</div>
<div class="#{changePasswordFormStyle.selectStyle('changePasswordForm:newPasswordRetype')}">
  <label class="control-label" for="newPasswordRetype">新しいパスワードを再入力
  </label>
  <div class="controls">
    <h:inputSecret id="newPasswordRetype"
      value="#{userManageController.newPasswordRetype}"
      label="新しいパスワードを再入力"/>
    <h:panelGroup renndered="#{changePasswordFormStyle
       .isErrorMsgRendered('changePasswordForm:newPasswordRetype')}">
      <span class="help-inline"><h:message for="newPasswordRetype"/></span>
    </h:panelGroup>
  </div>
</div>

3-1. extValエラーメッセージの日本語対応

wiki (https://cwiki.apache.org/confluence/display/EXTVAL/Internationalization)によると、カスタムメッセージバンドルを指定すれば、デフォルトと違うメッセージが表示できるようだ。

まずはメッセージファイルを作る。メッセージファイルはクラスパスのルートに来るようにsrc/main/resourcesに置く。

ValidationMessage_ja_JP.properties

javax.faces.validator.BeanValidator.MESSAGE={1} {0}
javax.validation.constraints.NotNull.message=は必須入力です
org.hibernate.validator.constraints.NotBlank.message=は必須入力です
duplicated_content_required=同じ値を入力してください
duplicated_content_required_detail=同じ値を入力してください

@Equalsでは、デフォルトでキーduplicated_content_required_detailのエラーメッセージが表示される。
@Equals以外のextValアノテーションのメッセージキーについては前述したwikiに掲載されている。

次にfaces-config.xmlでメッセージ定義を行う。

<application>
  <message-bundle>ValidationMessages</message-bundle>
</application>

最後にweb.xmlにextValのカスタムリソースバンドルを示す設定を加える。

<context-param>
  <param-name>
    org.apache.myfaces.extensions.validator.CUSTOM_MESSAGE_BUNDLE
  </param-name>
  <param-value>ValidationMessages</param-value>
</context-param>

これで、設定した日本語メッセージ「同じ値を入力してください」が出力される。

3-2. エラーメッセージは<span>で囲み、エラー時のみ表示する

Struts1.xでは<logic:present>タグで、スコープの内容に応じて表示/非表示が切り替えることができるが、JSF2.0においても<h:panelGroup>タグのrendered属性をtrue/falseに設定することでコントロールできる。
あとは、どうやって入力チェックに失敗したのか検知するか。今回は画面からコンポーネントIDをサーバに送信して、UIInput#isValid()を実行することで入力チェックの失敗をチェックした。コンポーネントIDは「Formのid属性+Inputのid属性」で決まる。id属性は何もしないとJSFに自動生成されてしまって、プログラム上でコンポーネントIDが扱いにくくなるので、id属性を明示的に指定してみた。

isErrorMsgRendered('changePasswordForm:newPassword')と、引数を文字列で直接指定してるのはいけてないと思ったが、代替手段が思いつかなかったので諦めた。

<!-- Form部分の抜粋 -->
<h:form id="changePasswordForm" styleClass="form-horizontal">
<!-- 途中省略 -->
<!-- InputSecret部分の抜粋 -->
<h:inputSecret id="newPassword" value="#{userManageController.newPassword}" label="新しいパスワード"/>
<h:panelGroup rendered="#{changePasswordFormStyle
        .isErrorMsgRendered('changePasswordForm:newPassword')}">
    <span class="help-inline"><h:message for="newPassword"/></span>
</h:panelGroup>


Java側は以下のようになった。Struts1.xだと今回のようにデザインに関すること(rendered属性のtrue/false)もSubmitボタン押下時のActionクラスで処理しなければいけないが、JSF2.0では属性内にアクションバインディング使って処理を呼び出すことができるので、パスワード変更のバッキングBean(UserManageController)と、デザインに関するバッキングBean(ChangePasswordFormStyle)を分けることができることが嬉しい。

@Model
public class ChangePasswordFormStyle {
    @Inject
    private FacesContext facesContext;

    /**
      * PanelGroupのrendered属性に指定されて呼び出される。  
      * エラーメッセージを表示する場合(UIInput#isValidがfalse)の場合はtrue
      * エラーメッセージを表示しない場合(UIInput#isValidがtrue)の場合はfalseを返す。
      * @param 画面から指定されたコンポーネントID
      */
    public boolean isErrorMsgRendered (String componentId) {
        UIInput input = (UIInput)facesContext.getViewRoot().findComponent(componentId);
        return !input.isValid();
    }

    /**
      * 入力フォームのdivのclass属性を入力チェックの結果に応じて決定する
      * エラーなし : control-group
      * エラーあり : control-group error
      * @param 画面から指定されたコンポーネントID
      */
    public String selectStyle(String componentId) {
        if (isErrorMsgRendered(componentId)) {
            // control-group error を返す
            return BootStrapFormStyle.ERROR.getStyleClass();
        }
        // control-group を返す
        return BootStrapFormStyle.DEFAULT.getStyleClass();
    }
}


まだユニットテストのことは考えていないが、staticメソッドに依存するとJMockit使わないとテストが書きにくくなる為、CDI実装のWeldリファレンスで紹介されていたやり方でFacesContextをインジェクションした。

public class FacesContextProducer {
    @Produces
    @RequestScoped
    public FacesContext getFacesContext() {
        return FacesContext.getCurrentInstance();
    }
}

3-3. 入力フィールドを示すdivを赤色のstyle(control-group error)に変更したい

せっかくBootStrapを使っているので、エラー時は<div class="control-group error">を指定して、入力フォームを以下のように赤くしたい。

f:id:n_agetsuma:20121119225334p:plain

赤くするか否かも、エラーメッセージの表示要否と同様にアクションバインディングの実行結果によって決めてみた。

<div class="#{changePasswordFormStyle.selectStyle('changePasswordForm:newPassword')}">

java側でスタイルシートのClass属性を返している。

// 前述したChangePasswordFormStyleクラスからの抜粋
public String selectStyle(String componentId) {
    if (isErrorMsgRendered(componentId)) {
        // control-group error を返す
        return BootStrapFormStyle.ERROR.getStyleClass();
    }
    // control-group を返す
    return BootStrapFormStyle.DEFAULT.getStyleClass();
}

Enumの使い道がこれで正しいかはよくわからないが、スタイルシート属性はEnumにまとめてみた。

public enum BootStrapFormStyle {
    DEFAULT("control-group"),
    ERROR("control-group error");

    private String styleClass;
	
    private BootStrapFormStyle(String styleClass) {
        this.styleClass = styleClass;
    }

    public String getStyleClass() {
        return styleClass;
    }
}

JavaScriptが苦手な為、CSSのコントロールをサーバサイドjavaで行ってみた。
JavaScriptが得意な人は、サーバサイド入力チェックのエラー結果をもっと効率よくHTMLに反映できるかもしれない。

ソースコードはgithub(https://github.com/n-agetsu/ExtValSample)においた。