システム開発で思うところ

Javaで主にシステム開発をしながら思うところをツラツラを綴る。主に自分向けのメモ。EE関連の情報が少なく自分自身がそういう情報があったら良いなぁということで他の人の参考になれば幸い

JSFでエラーフィールドの背景色を変える(3)

vermeer.hatenablog.jp

の続き。

前回の記事で 課題とした

https://github.com/system-sekkei/isolating-the-domain
のように 背景色というよりも、指定領域のスタイルを変更する という要件には応えられていません。

について、今回は取り組みたいと思います。

やりたいこと

  • エラー対象に関連付けた任意の場所のスタイルを変更する
  • (ついでに)jsf:styleClassを使わないHTML Friendlyなxhtmlの記述ができる

やりかた

  • xhtmlで 関連付け対象のIDを引数としてスタイルを返却する

イメージは、

JSFでエラーのある項目の背景色を変える - じゃばらの手記

に近しいですが、異なるのは component.clientIdではなく、通常のIDを指定するところです。 ここまでに、通常のIDとJSFのClinetIdを関連付ける仕組みを実装済みなので、それを使います。

実装

xhtmlから使用するスタイル制御を行うクラス

BeanValidationExceptionのInterceptorで

  • 画面上のコンポーネントを取得して通常のIDとJSFのClinetIdを関連付けたクラス
  • エラーとなったClientIdのリスト

を取得しているので、それらをマージして目的の判定を行う情報を編集します。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@Dependent
public class BeanValidationExceptionInterceptor {

    private final CurrentViewContext context;
    private final MessageConverter messageConverter;
    private final MessageWriter messageWriter;
    private final ClientComplementManager clientComplementManager;
    private final ErrorStyle errorStyle;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context,
                                              MessageConverter messageConverter, MessageWriter messageWriter,
                                              ClientComplementManager clientComplementManager, ErrorStyle errorStyle) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.clientComplementManager = clientComplementManager;
        this.errorStyle = errorStyle;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {

            ClientIdsWithComponents clientIdsWithInputComponents = new InputComponentScanner().scan();

            ClientIdMessages clientidMessages
                             = messageConverter.toClientIdMessages(ex.getValidatedResults(),
                                                                   ic.getTarget().getClass().getSuperclass(),
                                                                   clientIdsWithInputComponents);

            ClientIdsWithComponents clientIdsWithHtmlMessages = new HtmlMessageScanner().scan();
            messageWriter.appendErrorMessageToComponent(clientidMessages.toClientIdMessagesForWriting(clientIdsWithHtmlMessages));

            FacesContext.getCurrentInstance().validationFailed();
            clientComplementManager.setClientidMessages(clientidMessages);

            //ここで、エラースタイルを管理するクラスへ情報を渡す
            this.errorStyle.set(clientIdsWithInputComponents, clientidMessages);
            return currentViewId;
        }

    }
}


実際に状態を保持するクラス

@Named
@RequestScoped
public class ErrorStyle {

    private ClientIdsWithComponents clientIdsWithComponents;

    private String errorStyle;

    @PostConstruct
    private void init() {
        this.errorStyle = "error";
        this.clientIdsWithComponents = new ClientIdsWithComponents();
    }

    public void set(ClientIdsWithComponents clientIdsWithInputComponents, ClientIdMessages clientidMessages) {

        Set<String> clientIds = clientidMessages.getList().stream()
                .map(ClientIdMessage::getClientId)
                .collect(Collectors.toSet());

        this.clientIdsWithComponents = clientIdsWithInputComponents.filter(clientIds);
    }

    /**
     * 指定したIDの項目が検証不正だった場合に適用する styleClass を返却します.
     * <P>
     * xhtmlでのパラメータ指定時には、シングルクウォートで値を指定してください.
     *
     * @param id 対象となるコンポーネントのID(JSFのクライアントIDではありません)
     * @return 当該項目IDにエラーがない場合は 空文字を返却します.
     */
    public String byId(String id) {
        return this.clientIdsWithComponents.getOrNull(id) == null ? "" : errorStyle;

    }

}

xhtml側での記述

エラーになった際に関連付けさせてCSSクラス文字列を返したいところに EL式を記述します。 ポイントは xhtmlのIdを そのまま使っているところです。

また、jsf:styleClassで記述していた箇所を classとすることで、HTML Friendlyな記述が出来ました。
これで デザイナーとの協業も実現できますね。

気をつけるのは、Idの指定は シングルクウォートで囲むというところです。*1

<div class="field #{errorStyle.byId('email')}">
    <label>利用者ID</label>
    <input class="short-input" type="text" placeholder="someone@example.com" jsf:id="email" jsf:value="#{userRegistrationPage.email}"/>
    <h:message for="email" styleClass="error-message ui left pointing red basic label" />
</div>

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

結局のところ、xhtmlで記述したIDと JSFが導出するIDとの関連付けを どうにかすれば色んなことが出来るんですよね。
私は自作をしましたが*2 JSFのオリジナルの部品として、そういうのを扱うAPIなりクラスがあれば もっと使いやすくなるんじゃないでしょうかねぇ。*3


*1:ダブルクウォートで起動時にエラーになったのは凡ミス。。

*2:まだ繰り返し領域についての扱いは未対応ですが

*3:JavaDocを全て見たわけではないので、あるかもしれませんが

JSFでエラーフィールドの背景色を変える(2)

vermeer.hatenablog.jp

の続きです。

私の仕組みでは、Application層の検証不正に対しても画面項目と関連付け出来るようにしています。
そこで その仕組みを利用して 先のパターンに独自の実装を追加します。

やりたいこと

メッセージ出力に関係なく、任意の入力項目の背景色を変更する

やりかた

  • BeanValidationExceptionの対象となったUIInputのClientIdを取得して出力先情報として保持
  • PhaseListnerで保持した情報を使う

前回のやり方は getClientIdsWithMessagesを使って、h:messageが出力対象になっている事が前提になっていますが、このやり方であれば h:messageの有無に関係なく背景色を変えられます。

実装

UIInputのClientIdを取得

前回の h:message に関する情報を取得するだけでしたが、今回は コンポーネント内すべてのUIInputのClientIdも取得します。

この情報とBeanValitionの結果をPhaseListnerで参照するようにします。

なお、メッセージ出力に関係する箇所は、前回同様 h:message に関する情報を元に編集をします。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@Dependent
public class BeanValidationExceptionInterceptor {

    private final CurrentViewContext context;
    private final MessageConverter messageConverter;
    private final MessageWriter messageWriter;
    private final ClientComplementManager clientComplementManager;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context, MessageConverter messageConverter, MessageWriter messageWriter,
                                              ClientComplementManager clientComplementManager) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.clientComplementManager = clientComplementManager;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {

            ClientIdsWithComponents clientIdsWithInputComponents = new InputComponentScanner().scan();

            ClientIdMessages clientidMessages
                             = messageConverter.toClientIdMessages(ex.getValidatedResults(),
                                                                   ic.getTarget().getClass().getSuperclass(),
                                                                   clientIdsWithInputComponents);

            ClientIdsWithComponents clientIdsWithHtmlMessages = new HtmlMessageScanner().scan();
            messageWriter.appendErrorMessageToComponent(clientidMessages.toClientIdMessagesForWriting(clientIdsWithHtmlMessages));

            FacesContext.getCurrentInstance().validationFailed();
            clientComplementManager.setClientidMessages(clientidMessages);

            return currentViewId;
        }

    }
}

PhaseListner

UIInputのClientIdと検証不正対象となった入力項目の関連付けを保持したインスタンスを参照先として追加します。

public class InputFieldColorHandlerPhaseListner implements PhaseListener {

    private static final long serialVersionUID = 1L;

    @Override
    public void afterPhase(PhaseEvent phaseEvent) {
    }

    @Override
    public void beforePhase(PhaseEvent phaseEvent) {
        FacesContext context = phaseEvent.getFacesContext();

        InputFieldColorHandler fieldColorHandler = CDI.current().select(InputFieldColorHandler.class).get();
        ClientComplementManager clientComplementManager = CDI.current().select(ClientComplementManager.class).get();
        fieldColorHandler.updateErrorFieldColor(context, clientComplementManager);
    }

    @Override
    public PhaseId getPhaseId() {
        return PhaseId.RENDER_RESPONSE;
    }

}


実際のUIComponentの更新をしているクラス

既存の仕組み(updateColorHtmlMessage)も残しつつ、新たな制御(updateColorInputComponent)を追加します。

@ApplicationScoped
public class InputFieldColorHandler {

    private String errorClass;

    @PostConstruct
    public void init() {
        this.errorClass = "error-field";
    }

    public void updateErrorFieldColor(FacesContext context, ClientComplementManager clientComplementManager) {
        this.clearErrorColor(context);

        if (context.isValidationFailed() == false) {
            return;
        }

        this.updateColorHtmlMessage(context);
        this.updateColorInputComponent(context, clientComplementManager);
    }

    private void clearErrorColor(FacesContext context) {

        recursiveScan(context.getViewRoot().getChildren())
                .forEach(c -> {
                    String styleClass = String.valueOf(c.getAttributes().get("styleClass"));
                    if (styleClass != null && styleClass.contains(errorClass)) {
                        c.getAttributes().put("styleClass", styleClass.replace(errorClass, "").trim());
                    }
                });

    }

    private Set<UIComponent> recursiveScan(List<UIComponent> components) {
        Set<UIComponent> set = new HashSet<>();
        if (components == null) {
            return set;
        }

        components.forEach(component -> {
            set.add(component);
            set.addAll(recursiveScan(component.getChildren()));
        });
        return set;
    }

    private void updateColorHtmlMessage(FacesContext context) {
        context.getClientIdsWithMessages().forEachRemaining(clientId -> {
            if (clientId == null) {
                return;
            }
            UIComponent component = context.getViewRoot().findComponent(clientId);
            String styleClass = String.valueOf(component.getAttributes().get("styleClass"));
            if (styleClass != null) {
                component.getAttributes().put("styleClass", styleClass.trim() + " " + errorClass);
            }
        });
    }

    private void updateColorInputComponent(FacesContext context, ClientComplementManager clientComplementManager) {
        clientComplementManager.clientIds().stream()
                .forEach(clientId -> {
                    UIComponent component = context.getViewRoot().findComponent(clientId);
                    String styleClass = String.valueOf(component.getAttributes().get("styleClass"));
                    if (styleClass != null && styleClass.contains(errorClass) == false) {
                        component.getAttributes().put("styleClass", styleClass.trim() + " " + errorClass);
                    }
                });
    }

}

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

背景色の変更は、概ね これで良いかなと思います。
ただ、参考にした

https://github.com/system-sekkei/isolating-the-domain

のように 背景色というよりも、指定領域のスタイルを変更する という要件には応えられていません。

つぎは、そのあたりかな?

JSFでエラーフィールドの背景色を変える

ConversationScopedで 戯れた日々を終え、メッセージ関係に戻って 新たな取り組みを。

入力フィールドの背景色を変えるという定番です。

定番ですが、ここまで引っ張ってしまったのは この定番をやろうと思ったときに

  • ViewIDをイイ感じに BeanValidationと連携しないと出来ない
  • 背景色を変える前に、そもそもメッセージを出力するところから整理できていない

ということが分かって、一からメッセージについて整理し直したためです。

まだ階層や繰り返し領域への仕組みは まだ出来ていませんが、とりあえず 最低限 求めている機能の実装が なんとか出来たので、 ようやく 本丸だったフィールドの背景色を変えるということについて考えられる土台ができたというところです。

やりたいこと

  • フィールドの色を変える

やりかた

主に JSFでエラー項目の背景色を変える - 中年プログラマーの息抜き を参考。

h:messageにメッセージ出力している 画面項目と関連する入力領域の背景色を変更する(CSSのクラスを付与する)という方式です。

実装

FacesContextに検証不正があったことを保存

これは、元々 やるべきだった実装が漏れていたということになると思います。
BeanValidationによりエラーを検知した場合には、当該Contextについては 検証不正情報があったことを保存すべきでした。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@Dependent
public class BeanValidationExceptionInterceptor {

    private final CurrentViewContext context;

    private final MessageConverter messageConverter;
    private final MessageWriter messageWriter;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context, MessageConverter messageConverter, MessageWriter messageWriter) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {
            ClientidMessages clientidMessages
                             = messageConverter.toClientidMessages(ex.getValidatedResults(),
                                                                   ic.getTarget().getClass().getSuperclass());

            messageWriter.appendErrorMessages(clientidMessages);
            
            //検証不正があるということを Contextにも保持させる
            FacesContext.getCurrentInstance().validationFailed();
            
            
            return currentViewId;
        }

    }
}

PhaseListner

クライアントにレスポンスするところ(メッセージ出力など 本対応以外のContextの更新が終わっている状態)で 背景色に関係する Context情報を更新します。

public class InputFieldColorHandlerPhaseListner implements PhaseListener {

    private static final long serialVersionUID = 1L;

    @Override
    public void afterPhase(PhaseEvent phaseEvent) {
    }

    @Override
    public void beforePhase(PhaseEvent phaseEvent) {
        FacesContext context = phaseEvent.getFacesContext();

        InputFieldColorHandler fieldColorHandler = CDI.current().select(InputFieldColorHandler.class).get();
        fieldColorHandler.updateErrorFieldColor(context);
    }

    @Override
    public PhaseId getPhaseId() {
        return PhaseId.RENDER_RESPONSE;
    }

}


実際のUIComponentの更新をしているクラス
h:messageにメッセージ出力されている画面項目に関係する input要素の cssのClassタグに 背景色を管理する error-fieldを追記します。

@ApplicationScoped
public class InputFieldColorHandler {

    private String errorClass;

    @PostConstruct
    public void init() {
        this.errorClass = "error-field";
    }

    public void updateErrorFieldColor(FacesContext context) {
        this.clearErrorColor(context);

        if (context.isValidationFailed() == false) {
            return;
        }

        context.getClientIdsWithMessages().forEachRemaining(clientId -> {
            UIComponent component = context.getViewRoot().findComponent(clientId);
            String styleClass = String.valueOf(component.getAttributes().get("styleClass"));
            if (styleClass != null) {
                component.getAttributes().put("styleClass", styleClass.trim() + " " + errorClass);
            }
        });

    }

    void clearErrorColor(FacesContext context) {

        recursiveScan(context.getViewRoot().getChildren())
                .forEach(c -> {
                    String styleClass = String.valueOf(c.getAttributes().get("styleClass"));
                    if (styleClass != null && styleClass.contains(errorClass)) {
                        c.getAttributes().put("styleClass", styleClass.replace(errorClass, "").trim());
                    }
                });

    }

    private Set<UIComponent> recursiveScan(List<UIComponent> components) {
        Set<UIComponent> set = new HashSet<>();
        if (components == null) {
            return set;
        }

        components.forEach(component -> {
            set.add(component);
            set.addAll(recursiveScan(component.getChildren()));
        });
        return set;
    }

}


validation.css

とりあえず、強制的に背景色を変更

.error-field {
    background-color: bisque !important;
}


PhaseListenrの呼出している箇所

テンプレートxhtmlから、当該PhaseListnerを呼び出します。
faces-config.xml へのlifecycle 要素の追加ではありません

baseLayer.xhtml

<f:phaseListener type="ee.phaselistener.InputFieldColorHandlerPhaseListner" />

出力ページ

classjsf:styleClassに置き換え

一項目だけ抜粋

<div class="field">
    <label>利用者ID</label>
    <input jsf:styleClass="short-input" type="text" placeholder="someone@example.com" jsf:id="email" jsf:value="#{userRegistrationPage.email}"/>
    <h:message for="email" styleClass="error-message ui left pointing red basic label" />
</div>

ハマったところ

UIComponentが更新できない

FacesContext.getCurrentInstance()から取得したUIComponentは、参照は出来るのですが更新は出来ませんでした。

上述の通り、PhaseEvent の #getFacesContext()を更新対象として解決しました。

faces-config.xmlの指定では動かない

やろうとしていることとの相性なのか分かりませんが、faces-config.xmlに PhaseListnerを記載しても 想定通りの動きをしませんでした。
同じようなことで困っている人がいてくれて助かりました。
jsf 2 - How to apply a JSF2 phaselistener after viewroot gets built? - Stack Overflow

上述の通り、f:phaseListener で解決しました。

Injectが出来ない

JSF の相関項目チェック by Bean Validation | 寺田 佳央 - Yoshio Terada

を見ると出来そうなんですが、DIできませんでした。
あくまで想像ですが、faces-config.xmlの指定では動かないとも関連しているような気がします。

上述の通り、CDI.current().select()で解決しました。

jsf:styleClassと classの併用不可

classの方が強い、かつ 編集可能な要素は jsf:styleClassなので、内容変更しても反映されない。
JSF2.2から html friendly といいつつ、肝心のデザインを司る cssクラスの指定で 併用が出来ないのは、正直 かなりイケて無いと思う。
せめて 併用している場合は、UIComponentのベースは jsf:styleClassにして、classは無視してくれるだけでも良かったのに・・・*1

参考資料

JSFでエラーのある項目の背景色を変える - じゃばらの手記

JSFのバリデーション - DENの思うこと

JSF バリデーション

JSFでエラー項目の背景色を変える - 中年プログラマーの息抜き

JSF の相関項目チェック by Bean Validation | 寺田 佳央 - Yoshio Terada

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

一般的な、エラー時の背景色制御は これで良いと思います。
ただ、この方式での課題は、h:messageの出力が必須となる方式ということです。
ぼんやりと オレオレFW的に進めている方式を活かせば、その課題も対応できるように思うので、次回は そのあたりをアレコレ試していければと思います。


*1:あくまで、自分の確認の範囲です。回避策があるかもしれませんし、JSF2.3になったら解消しているかもしれません。そこまで調べるのは 一旦 止めました

NonexistentConversationExceptionからの画面遷移

vermeer.hatenablog.jp

vermeer.hatenablog.jp

の続きです。

今回はNonexistentConversationExceptionからの画面遷移を考えます。

やりたいこと(妥協もあり)

NonexistentConversationExceptionからの会話開始画面へ遷移する

NonexistentConversationExceptionは、元となるConversationScopeのインスタンスタイムアウトしているか、何らかの理由で(URLパラメータ変更とか)対象のページに遷移できないために発生します。 BusyConversationExceptionと違って、遷移前の状態を取得することは出来ない前提で考える必要があります。
したがって、新たな会話を開始させるしかありません。

状態管理は不可

上述の通り、情報が存在しません。

メッセージはデフォルトだけでなく独自指定も

デフォルトのメッセージは、Message.propertiesにキーを javax.enterprise.context.BusyConversationExceptionとして記述しておきます。
基本的に「対象操作はタイムアウトしたので、始めからやり直してください」ということを伝えるだけなので、個々のユースケースで出力を変えることはないかもしれませんが、「登録」と言う表現が妥当な場合もあれば、「保存」や「処理」が必要なケースは容易に想像できます。
したがって、ユースケースに応じたメッセージも出力できるようにします。
BusyConversationと違って、遷移先は開始ページ固定なので ユースケース全体で1つ指定できることをゴールとします。

メッセージ実装はURL(フォルダパス)

正直、妥協です。

ServletFilterから使える情報は基本的にはリクエストです。
BusyConversationであれば、会話スコープのインスタンス自体は残っていたので 迂回しても色々な情報を活用できましたが、今回はそうはいきません。
リクエストに出力するメッセージに関する情報を載せてこないといけません。
ですが、NonexistentConversationExceptionはいつ発生するか分かりません。
となると常にリクエストに 当該ユースケースにおけるメッセージ情報を持ちまわらないといけません。
正直、それは内部実装の暴露ですし、URLに「対象操作は云々」というパラメータが表示されているのは良くありません。
HttpHeaderなどで保持しておくということも考えましたが、結局 毎回のレスポンスに 暴露的な情報を持たせるという設計思想は どうもしっくりきません。

結論としては、URLのパスが同一フォルダである場合を 同一会話スコープとしている設計指針から考えると当該ユースケースを現す情報として フォルダパスを使うのが良いかな?という考えに至りました。

実装

例外補足からデフォルトのメッセージ出力まで当初案を そのままなので割愛します。
(多少のパラメータ追加があっただけ)

独自メッセージに関するところだけ記します。

Message.propertiesに以下の記述をしておけば、当該パスの会話中にNonexistentConversationExceptionが発生した場合は、
デフォルトメッセージではなく 記述しているメッセージが適用されます。

/apps/taskbase/=タイムアウトしました。

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

とりあえず、ConversationScoped周りは これで一段落ついたかな?
観測範囲で ここまで変態的に ConversationScopedを使い倒している例は 見当たらないから やった意義はあったと自己満足。


BusyConversationExceptionからの画面遷移

vermeer.hatenablog.jp

の続きです。

前回は、強制的に会話スコープの開始ページに遷移するという仕様としましたが、正直 これは 私の求めている機能ではありません。
それぞれの例外に対して、目指す振る舞い(できる処理)が異なるので まずは BusyConversationExceptionから考えてみます。

やりたいこと

BusyConversationExceptionのトリガーとなった画面へ遷移する(つまり自画面へ遷移させる)

BusyConversationExceptionは、元となるConversationScopeのインスタンスが破棄されていません。 したがって、そのまま cidをつけたリクエストを送信すれば、前の状態を取得する事が出来ます。

ただ、2回目の操作が無効になっただけです。

状態管理は不要

2重Submitのような、操作による誤ったデータ登録への考慮ですが、この制御で扱うものではありません
データの重複チェックや、2重Submit対策として 別に行います。
そして、そうあるべきであると考えます。

メッセージはデフォルトだけでなく独自指定も

デフォルトのメッセージは、Message.propertiesにキーを javax.enterprise.context.BusyConversationExceptionとして記述しておきます。
基本的に「処理中に 操作してはいけませんよ」ということを伝えるだけなので、個々のユースケースで出力を変えることはないかもしれませんが、「登録」と言う表現が妥当な場合もあれば、「保存」や「処理」が必要なケースは容易に想像できます。
したがって、ユースケースに応じたメッセージも出力できるようにします。
基本的には会話中の全ての操作に対して適用する文言であれば良いと思いますので、会話の開始ページである index.xhtml のアクションを実装したクラスに アノテーションを付与すれば十分でしょう。 といいつつ、メソッド毎にも変更できるようにもしておきます。

メッセージ実装はアノテーション

メッセージは 業務の主たる関心事ではないので宣言的なアノテーションによる表現が適当であると考えます。

実装

例外補足

ServletFilerで例外を捕捉するのは前回の同じです。
例外種類によって処理が違うところが前回との違いです。

public class ConversationExceptionFilter implements Filter {

     (略)

    /**
     * {@inheritDoc }
     */
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } catch (NonexistentConversationException ex) {

     (略)

        } catch (BusyConversationException ex) {
            HttpServletRequest httpServletRequest = (HttpServletRequest) request;

            String servletPath = httpServletRequest.getServletPath();
            request.setAttribute(ConversationExceptionKey.FORWARD_PAGE, servletPath);

            String cid = httpServletRequest.getParameter("cid");
            request.setAttribute(ConversationExceptionKey.CONVERSATION_ID, cid);

            request.setAttribute(ConversationExceptionKey.EXCEPTION, ConversationExceptionValue.BUSY);

            httpServletRequest.getRequestDispatcher("/parts/conversation/busy-conversation.jsp").forward(request, response);

        }
    }

     (略)

操作をした画面への遷移

遷移制御をするJSP
JSPを使う理由は前回のブログの通りです。
ポイントは、cidを次の遷移のために持ちまわるというところです。

こうすることで、未終了のConversationScopedのインスタンスを使用できます。

busy-conversation.jsp

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<%@ page import="spec.scope.conversation.ConversationExceptionKey"%>

<%
    String forwardPage = (String) request.getAttribute(ConversationExceptionKey.FORWARD_PAGE);
    String cid = (String) request.getAttribute(ConversationExceptionKey.CONVERSATION_ID);
    String ex = (String) request.getAttribute(ConversationExceptionKey.EXCEPTION);
%>
<c:redirect url="<%=forwardPage%>">
    <c:param name="cid" value="<%=cid%>"/>
    <c:param name="<%=ConversationExceptionKey.EXCEPTION%>" value="<%=ex%>"/>
</c:redirect>

メッセージ出力

画面共通のテンプレートに、メッセージ出力ための リスナーを指定しておきます。
viewActionが使えない理由は以下のブログの通りです。

ConversationScopedを扱うにあたっての課題 - システム開発で思うところ

BusyConversationExceptionのルートで関係あるのは
変数requestParameterExceptionです。
例外の分類ですが、Flashによるデータ共有方式では f:viewParamによって情報を持ちまわる必要があるため使えません。 共通テンプレートでは リスナーしか使えないので、直接 URLパラメータから取得することにしました。
逆に言えば、例外種類の状態把握としては、Flashに遷移先を指定しないルートともいえます。この違いを利用してどちらの例外処理(BusyConversationなのか NonexistentConversationなのか)の判定にも使っています。

共通テンプレート(baseLayer.xhtml

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets"
      xmlns:h="http://java.sun.com/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core"
      >

    <h:head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <h:outputStylesheet library="css" name="base.css" />
        <h:outputStylesheet library="css" name="validation.css" />
        <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/semantic-ui/2.2.10/semantic.min.css" />

        <f:event type="postAddToView" listener="#{conversationLifecycleManager.startConversation()}" />
        <f:event type="preRenderView" listener="#{conversationExceptionHandler.writeMessage()}" />
    </h:head>

    <h:body>
        <div id="page-content">
            <ui:insert name="content">Content</ui:insert>
        </div>
        <div id="page-bottom">
            <ui:include src="./bottom.xhtml"/>
        </div>
    </h:body>

</html>
@Named
@RequestScoped
public class ConversationExceptionHandler {

  (略)

    @PostConstruct
    public void init() {
        externalContext = FacesContext.getCurrentInstance().getExternalContext();
    }

    public void writeMessage() {
        String flashException = (String) externalContext.getFlash().get(ConversationExceptionKey.EXCEPTION);
        String requestParameterException = externalContext.getRequestParameterMap().get(ConversationExceptionKey.EXCEPTION);

        if (busyConversationMessageHandler.isBusyConversationException(flashException, requestParameterException) == false
            && nonExistentConversationMessageHandler.isNonExistentConversation(flashException) == false) {
            return;
        }

        if (busyConversationMessageHandler.isBusyConversationException(flashException, requestParameterException)) {
            this.busyConversationMessageHandler.write();
        }

        if (nonExistentConversationMessageHandler.isNonExistentConversation(flashException)) {
            this.nonExistentConversationMessageHandler
                    .write((String) externalContext.getFlash().get(ConversationExceptionKey.FROM_PATH));
        }

        // 一度だけメッセージ出力をするために 共通メソッドで trueにしている設定を falseで上書きします
        externalContext.getFlash().setKeepMessages(false);
    }

  (略)

}

実際の判定や 出力をしているクラス。
先のクラスのメソッドとして実装しても良かったのですが、クラスとして切り出した方が 役割が分かりやすいと思って 別クラスにしました。

@RequestScoped
public class BusyConversationMessageHandler {

    private BusyConversationMessageManager busyConversationMessageManager;

    private MessageConverter messageConverter;
    private MessageWriter messageWriter;
    private Conversation conversation;

    public BusyConversationMessageHandler() {
    }

    @Inject
    public BusyConversationMessageHandler(BusyConversationMessageManager busyConversationMessageManager,
                                          MessageConverter messageConverter, MessageWriter messageWriter, Conversation conversation) {
        this.busyConversationMessageManager = busyConversationMessageManager;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.conversation = conversation;
    }

    public boolean isBusyConversationException(String flashException, String requestParameterException) {
        return (flashException == null || flashException.equals(""))
               && (requestParameterException != null && requestParameterException.equals(ConversationExceptionValue.BUSY))
               && this.conversation.isTransient() == false;
    }

    public void write() {
        String message = messageConverter.toMessage(this.busyConversationMessageManager.getMessage());
        messageWriter.appendErrorMessage(message);
    }

}

独自メッセージ

独自メッセージは、@BusyConversationMessageをアクションクラスに実装します。
メッセージは、事前に保持しておいて、BusyConversationExceptionが捕捉されたら使います。

インターセプター

@BusyConversationMessage
@Interceptor
@Priority(Interceptor.Priority.APPLICATION + 10)
public class BusyConversationMessageInterceptor {

    private final BusyConversationMessageManager busyConversationManager;

    @Inject
    public BusyConversationMessageInterceptor(BusyConversationMessageManager busyConversationManager) {
        this.busyConversationManager = busyConversationManager;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {
        BusyConversationMessage classMessage = ic.getTarget().getClass().getSuperclass().getAnnotation(BusyConversationMessage.class);
        BusyConversationMessage methodMessage = ic.getMethod().getAnnotation(BusyConversationMessage.class);

        this.busyConversationManager.message(classMessage, methodMessage);
        return ic.proceed();
    }
}

実際にメッセージを保持しておくクラス。

@ConversationScoped
public class BusyConversationMessageManager implements Serializable {

    private static final long serialVersionUID = 1L;

    private String message;
    private String defaultMessage;

    @PostConstruct
    void init() {
        defaultMessage = BusyConversationException.class.getName();
        this.message = defaultMessage;
    }

    public String getMessage() {
        return message;
    }

    public void message(BusyConversationMessage classMessage, BusyConversationMessage methodMessage) {
        String _message = methodMessage != null
                          ? methodMessage.value()
                          : classMessage != null
                            ? classMessage.value()
                            : "";

        if (_message.equals("")) {
            return;
        }
        this.message = _message;
    }
}


メッセージを指定する場合の実装例は、以下。

クラス全体に適用する場合は

@Controller
@BusyConversationMessage("処理中に操作をしないでください。(クラス全体のアクションに適用)")
public class UserRegistrationAction {


特定のメソッドだけに適用したい場合は

@Controller
public class UserRegistrationAction {

    @BusyConversationMessage("登録画面への処理中に操作をしたので中断しました。(対象メソッドのアクションに適用)")
    public String fwPersist() {
        this.registrationPage.init();
        return "persist-edit.xhtml";
    }

    @BusyConversationMessage("登録確認への処理中に操作をしたので中断しました。(対象メソッドのアクションに適用)")
    public String confirm() {
        User requestUser = this.registrationPage.toUser();
        registerUser.validatePreCondition(requestUser);
        return "persist-confirm.xhtml";
    }



両方に実装した場合は、メソッドで適用したメッセージを優先するようにしています。

@Controller
@BusyConversationMessage("処理中に操作をしないでください。(クラスの全アクションに適用)")
public class UserRegistrationAction {

    @BusyConversationMessage("登録画面への処理中に操作をしたので中断しました。(メソッド指定の方を優先します)")
    public String fwPersist() {
        this.registrationPage.init();
        return "persist-edit.xhtml";
    }

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

トリッキーな実装も一部あるように思いますが、対応途中は もっとひどかったです。
2重サブミットも併せて考慮しないといけないんじゃないか?とか思って 色々な状態管理をして…、みたいな。
最終的に この例外管理で 主にやるべき責務って なんだろう? と考え直す工程を 一旦設けたことで、それまでの考えすぎと言うか 盛り込みすぎだったところが 見えてきて、個人的には 満足しています。
最終的に作り切ったもの振り返ると、大したことをしていないように見えますが、正直 ここに至るまでに とても時間を使ってしまいました。
次は、コードは全く同じで、NonexistentConversationExceptionからの画面遷移について書こうと思います。


BusyConversationException、NonexistentConversationExceptionからの画面遷移

vermeer.hatenablog.jp

に続けて

vermeer.hatenablog.jp

でも、一旦 保留とした BusyConversationExceptionが発生した場合の制御です。

実行時例外はエラー画面へ遷移させるというのが基本原則だとは思いますが、コミットした情報(例えば注文)について把握しないまま再操作をしてしまうと2重登録(もしくは注文)をしてしまうことに繋がり兼ねません。 考慮すべきことは、大体 2重サブミットと同じだと思います。そのあたりを鑑みながら仕様を考えます。

なお、連打による過剰スレッド発生そのものの課題については、JavaScriptによるモーダルとか、WAFとか そういうDDos攻撃的なものへの対処と合わせて対応する事も前提としておきます。特に JavaScriptによる制御を考慮すると、基本的に「ボタン連打」については、かなり確率的に低いとは思っています。だからといってサーバーサイドで対処を全くしないというわけにはいきませんので考えておくべきです。

最低限やりたいこと

  • BusyConversationException、NonexistentConversationException になったら、会話開始ページへ強制遷移させる
  • メッセージで どうして会話開始ページへ遷移させられたか分かるようにする

わかったこと・できること

分かったこと、留意しておくべきこと

  • Servlet Filter内でも DIは出来る(少なくともApplicationScopedであれば問題ない)。
  • Servlet FilerではFacesContext#getCurrentInstance()からはnullが返却される。
  • web.xmlCDI Conversation Filterを設定するとExceptionがFilterで扱われるので、CustomExceptionHandlerに制御が移らない。
  • Servlet Filterから別の画面へ遷移しながらデータを渡したい場合は、forwardでパラメータで渡さないといけない。
  • Servlet Filterでは 画面遷移の指定くらいはできるけど、それ以上の制御は出来ない。
  • Servlet Filterから forwardで JSFxhtmlページには遷移できない。*1
  • Servlet Filterから forwardで JSPには遷移できる。

実装

エラーをServletFilterで捕捉

/**
 * Conversationに関する例外を扱うフィルターです.
 *
 * @author Yamashita,Takahiro
 */
public class ConversationExceptionFilter implements Filter {

    /**
     * {@inheritDoc }
     */
    @Override
    public void init(FilterConfig filterConfig) {
    }

    /**
     * {@inheritDoc }
     */
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } catch (BusyConversationException | NonexistentConversationException ex) {

            HttpServletRequest httpServletRequest = (HttpServletRequest) request;
            String servletPath = httpServletRequest.getServletPath();
            String indexRootPath = servletPath.substring(0, servletPath.lastIndexOf("/") + 1);
            String indexPage = indexRootPath + "index.xhtml";

            String exception = ex instanceof BusyConversationException
                               ? BusyConversationException.class.getCanonicalName()
                               : NonexistentConversationException.class.getCanonicalName();

            request.setAttribute(ConversationExceptionKey.START_PAGE, indexPage);
            request.setAttribute(ConversationExceptionKey.EXCEPTION, exception);
            httpServletRequest.getRequestDispatcher("/parts/conversation/forward-jsf.jsp").forward(request, response);

        }
    }

    /**
     * {@inheritDoc }
     */
    @Override
    public void destroy() {
    }

}

ポイントは、異常のトリガーとなったURLとExceptionの情報を渡すために forwardを使っているところです。
JSFへ遷移したらエラーになりました。
Servlet Filterは、まだJSFのライフサイクル前だからかな?と思います。
仕方が無いので JSPを経由させることにしました。
ついでにパラメータも渡します。

JSFのページの橋渡し

中身のない、ただリダイレクトするためだけのJSPです。

forward-jsf.jsp

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<%@ page import="spec.scope.conversation.ConversationExceptionKey"%>

<%
    String conversationException = (String) request.getAttribute(ConversationExceptionKey.EXCEPTION);
    String conversationStartPage = (String) request.getAttribute(ConversationExceptionKey.START_PAGE);
%>
<c:redirect url="/parts/conversation/conversation-exception-handler.xhtml">
    <c:param name="conversation-start-page" value="<%=conversationStartPage%>"/>
    <c:param name="conversation-exception" value="<%=conversationException%>"/>
</c:redirect>

エラー制御をするためのJSF

会話開始ページへ遷移するためのJSFです。
これも、先のJSPと同じくリダイレクトすることを目的とした共通のJSFページです。
更に経由ページを設けている理由は

  • 遷移前のJSPからパラメータを受領したかった
  • viewActionを使って会話開始ページへ遷移をさせたかった

ためです。

conversation-exception-handler.xhtml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:f="http://xmlns.jcp.org/jsf/core"
      >

    <f:metadata>
        <f:viewParam name="conversation-start-page" value="#{conversationExceptionHandler.startPage}"/>
        <f:viewParam name="conversation-exception" value="#{conversationExceptionHandler.exception}"/>
        <f:viewAction action="#{conversationExceptionHandler.forward()}"/>
    </f:metadata>

</html>

ManagedBeanでは、遷移後の画面でメッセージを出力するための処理も行っています。
今回は 簡単に情報を引き渡す手段として Flashを使いました。*2

画面遷移および 後述するテンプレートからのメッセージ出力処理も以下のクラスで実施しています。
(System.out.println・・はデバッグ用の記述です。本来は不要なものです。消し忘れていました)

/**
 * ConversationExceptionが発生した際に画面遷移とメッセージ出力を行う機能を提供します.
 *
 * @author Yamashita,Takahiro
 */
@Named
@RequestScoped
public class ConversationExceptionHandler {

    private String exception;
    private String startPage;
    private MessageConverter messageConverter;
    private MessageWriter messageWriter;

    public ConversationExceptionHandler() {
    }

    @Inject
    public ConversationExceptionHandler(MessageConverter messageConverter, MessageWriter messageWriter) {
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
    }

    public String forward() {
        FacesContext.getCurrentInstance().getExternalContext().getFlash()
                .put(ConversationExceptionKey.EXCEPTION, this.exception);
        return this.startPage + "?faces-redirect=true";
    }

    public void writeMessage() {
        Flash flash = FacesContext.getCurrentInstance().getExternalContext().getFlash();
        String value = (String) flash.get(ConversationExceptionKey.EXCEPTION);
        if (value == null || value.equals("")) {
            return;
        }
        System.err.println("ttttt");
        String message = messageConverter.toMessage(value);
        messageWriter.appendErrorMessage(message);

        // 一度だけメッセージ出力をするために 共通メソッドで trueにしている設定を falseで上書きします
        flash.setKeepMessages(false);

        flash.remove(ConversationExceptionKey.START_PAGE);
        flash.remove(ConversationExceptionKey.EXCEPTION);
    }

    public String getException() {
        return exception;
    }

    public void setException(String exception) {
        this.exception = exception;
    }

    public String getStartPage() {
        return startPage;
    }

    public void setStartPage(String startPage) {
        this.startPage = startPage;
    }

}

テンプレート

会話スコープを使用するxhtmlにおいて共通で使用するxhtmlに、メッセージ出力の画面開設処理(conversationExceptionHandler.writeMessage())を追加しました。
URLを示すxhtmlではないので、f:eventを使います。

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets"
      xmlns:h="http://java.sun.com/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core"
      >

    <h:head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <h:outputStylesheet library="css" name="base.css" />
        <h:outputStylesheet library="css" name="validation.css" />
        <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/semantic-ui/2.2.10/semantic.min.css" />

        <f:event type="postAddToView" listener="#{conversationLifecycleManager.startConversation()}" />
        <f:event type="preRenderView" listener="#{conversationExceptionHandler.writeMessage()}" />
    </h:head>

    <h:body>
        <div id="page-content">
            <ui:insert name="content">Content</ui:insert>
        </div>
        <div id="page-bottom">
            <ui:include src="./bottom.xhtml"/>
        </div>
    </h:body>

</html>

Javaコードは上述の通りです。

実行

更新処理を行うControllerの 一覧へ戻るリンクに対するアクションに対して タイマーを設けました。

@Controller
public class UserUpdateAction {

 (略)

    @EndConversation
    public String fwTop() {

        try {
            Thread.sleep(3000);
        } catch (InterruptedException ex) {
        }
        return "index.xhtml";
    }
}

ダブルクリックをすると、BusyConversationExceptionがスローされます。
また、urlのcid=1というConversationIdを 存在しない数値(例えば、3)にして遷移をしようとしたらNonexistentConversationExceptionがスローされます。

いずれに例外でも、メッセージとして それぞれの実行時例外に合わせたメッセージが 一覧に表示されます。

Code

タイマーを設けて、BusyConversationExceptionを発生させるようのもの

vermeer_etc / jsf-ddd / source / — Bitbucket

タイマーの実装を外したもの

vermeer_etc / jsf-ddd / source / — Bitbucket

参考

リダイレクトとフォワードの違いを知る:JavaTips 〜JSP/サーブレット編 - @IT

JSF勉強メモ - 気まぐれラボラトリィ

jsp - HttpServletRequest for jsf - Stack Overflow

https://community.liferay.com/ja/forums/-/message_boards/message/53411185

さいごに

結構、煮詰まった上での苦肉の策です。
なんとか、形になったので 正直 ほっとしています。

より良いやり方をご存知の方がいらっしゃったら、twitterでもコメントでもアドバイスなど頂けると非常に嬉しいです。


*1:厳密には、拡張子がxhtmlというだけだったら遷移できるし、ManagedBeanの値を参照するくらいならできる。でもアクションなどは出来ない

*2:共通部品であれば用途目的も明確なので Flashを使っても良いと思いますが、個人的には業務ロジックで Flashを使うのは好ましいと思っていません。
型クラスに相当する Scopedを持ったManagedBeanを使う方が良いと思っています。