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

Javaで主にシステム開発をしながら思うところをツラツラを綴る

ConversationのBeginをviewAction以外で実現する

vermeer.hatenablog.jp

vermeer.hatenablog.jp

課題
viewActionはルートとなるViewに記述しないといけない
に対する対処です。

方式として、Interceptorが使えないというのは 検討済みでした。

会話が未開始の画面から遷移したときには、良い感じにBeginしてくれます。 問題は会話中の画面から別のフォルダに遷移したときです。
Interceptor内でEndしてStartすると、Conversatinが同一IDになってしまいます。
おそらくですが、ConversationIdはクライアントとの往復をもって実現しているため、同一フェーズでOff・Onしても「終わったこと」になっていなくて、新しいConversationIdを採番しないためだと考えられます。 ということで、望むタイミングでConversationをBeginするためには、xhtmlの初期表示タイミングでクライアントから明示的に実行するしかないと思います。

これに加えて、viewActionも使えないということになると、
あとは f:event type="preRenderView"を使うことになります。

欠点は、ActionListenerであるため遷移先を指定できません。

つまり 会話開始前に index.xhtml以外のページへアクセスをしようした際の会話開始ページ(index.xhtml)への強制遷移が実現できなくなります。

そこで「会話開始前に開始ページ以外へのアクセスする行為を 例外ルート、つまり実行時例外として扱う」という方式で実装しました。

ただし、今回は、一旦 会話スコープの開始と強制遷移までとして、その他の実行時例外や メッセージ出力については 別検討にします。

やりたいこと

  • f:event で 画面開設時に会話開始処理を行う。
  • 開始前に index.xhtml以外にアクセスしたら実行時例外をスローして、例外ハンドリングにて index.xhtmlへ遷移させる

実装

web.xml

他のConversationに関する例外を扱うにあたって、方式を再見直ししなくて良いように 前提として ServletFilterで Conversationの実行時例外の順序を先に指定しておきます。

    <!-- ConversationExceptionFilter -->

    <filter>
        <filter-name>ConversationExceptionFilter</filter-name>
        <filter-class>ee.filter.conversationexception.ConversationExceptionFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>ConversationExceptionFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    <filter-mapping>
        <filter-name>CDI Conversation Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- ConversationExceptionFilter -->

※実行時例外を扱うフィルタークラスには、Conversationに関連する例外である BusyConversationExceptionNonexistentConversationExceptionをキャッチするようにしていますが、今回は両例外に関する処置はしません。

/**
 * 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) {
            System.err.println(Arrays.toString(ex.getStackTrace()));
        }
    }

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

}

会話の開始制御

共通テンプレートとなる xhtmlに 画面描画時に会話スコープを開始するリスナーを記述します。 以前は、ここをf:viewActionで実装していましたが、それでは駄目なので f:event で対応します。 ポイントは typeの指定値です。

<f:event type="postAddToView" listener="#{conversationLifecycleManager.startConversation()}" />

一般的に f:event を使う場合は preRenderVIewとペアにしますが、これだと ページ情報を構築しようとするため 遷移前の情報を使った実装箇所があると エラーになります。
postAddToViewを指定すれば、コンポーネント構築前なので、そうしたエラーを回避できます。

会話を開始するメソッドでは、開始ページ以外から会話を開始しようとすると実行時例外をスローします。

    public void startConversation() {
        if (this.conversation.isTransient() == false) {
            return;
        }

        if (context.currentViewId().endsWith("/index.xhtml") == false) {
            throw new ConversationException();
        }

        this.conversation.begin();
        this.conversation.setTimeout(300000);

    }

あとは、例外ハンドラーにて、強制的に 同一パスの index.xhtmlへ遷移させます。

/**
 * {@link spec.scope.conversation.ConversationException}の捕捉後の処理を行う機能を提供します.
 * <p>
 * 会話スコープが既に終わっている場合の実行時例外なので、会話のスタートに位置する{@code index.xhtml}へ遷移させます。
 * 同時に会話スコープも終了させてから、再開させます。
 *
 * @author Yamashita,Takahiro
 */
public class ConversationExceptionHandler implements ThrowableHandler {

    private final ConversationLifecycleManager conversationLifecycleManager;
    private final FacesContext facesContext;

    public ConversationExceptionHandler(ConversationLifecycleManager conversationLifecycleManager, FacesContext facesContext) {
        this.conversationLifecycleManager = conversationLifecycleManager;
        this.facesContext = facesContext;
    }

    /**
     * {@inheritDoc }
     * <p>
     * {@code NavigationHandler} では正しく画面遷移が実現しなかったので、{@code ExternalContext} で遷移させます.
     */
    @Override
    public void execute() {
        conversationLifecycleManager.endConversation();

        String contextPath = facesContext.getExternalContext().getRequestContextPath();
        String currentPage = facesContext.getViewRoot().getViewId();
        String indexRootPath = currentPage.substring(0, currentPage.lastIndexOf("/") + 1);
        String indexPage = indexRootPath + "index.xhtml";
        String forwardPage = contextPath + indexPage;
        ExternalContext externalContext = facesContext.getExternalContext();
        try {
            ServletContext servletContext = (ServletContext) facesContext.getExternalContext().getContext();
            if (servletContext.getRealPath(indexPage) == null) {
                throw new ThrowableHandlerException("Target context file could not find.");
            }
            externalContext.redirect(forwardPage);
        } catch (IOException ex) {
            throw new ThrowableHandlerException(ex);
        }
    }

}

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

会話順序を守っていないシーケンスは ユースケースとしても「例外」とするし、 当初案よりも例外を使った制御の方が 個人的には分かりやすいように思います。

ConversationScopedを扱うにあたっての課題

もしくは、JSFのConversationScopedをフォルダでBegin・Endの方式見直しに向けて。
あるいは、ConversationScopedにおけるExceptionハンドリング です。

vermeer.hatenablog.jp

で、f:viewActionで制御をしていたのですが、テンプレートを使って 同じことをやろうとしたら、上手く行かず f:event type="preRenderView"でとりあえず処理をしました。

その時は何でだろうと思いつつ、とりあえず まぁ良いかとしていたのですが、その後 Conversationに関して色々やっている中で 本件を放置したままでは 不安定な仕組みになり得ると分かりました。

原因

eclipse - f:viewAction does not invoke action method - Stack Overflow

In the target page, apparently index.xhtml, put this somewhere in top, right before <h:head>

JSFのテンプレート機能をつかって、ui:include先で f:viewActionを指定したのですが、URLを示す xhtmlで指定しないとアクションが起きないという仕様でした。

課題

対処する、しないは別にして、現時点で思いつく課題を整理します。

viewActionはルートとなるViewに記述しないといけない

これは上述の通りです。

Exceptionハンドリング

ServletFilterでBusyConversationExceptionを扱うために web.xmlへの以下の記述のために、全てのConversationに関する例外を一旦 Servlet Filterで処理します。
そのためNonexistentConversationExceptionを扱うにあたって、そのままでは 当初案であるExceptionHandlerWrapperで処理できません。
ServletFilterと同一レイヤーでハンドリングするか、ExceptionHandlerWrapperへ例外処理を委譲するような対処の検討が必要だと思います。

    <!-- ConversationExceptionFilter -->

    <filter>
        <filter-name>ConversationExceptionFilter</filter-name>
        <filter-class>ee.filter.conversationexception.ConversationExceptionFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>ConversationExceptionFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    <filter-mapping>
        <filter-name>CDI Conversation Filter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>

    <!-- ConversationExceptionFilter -->

メッセージの出力

Exception時のメッセージ出力情報を SessionScopedのクラスを介在して制御していましたが、そのやり方では エラーのトリガーとなったタブ以外のConversationScopedのアクションにも影響が起こり得ます。
実際に懸念を動作確認をしているわけではないので確実ではありませんが、想定としては複数タブを開いて 高負荷で登録処理を行った場合に不具合が発生する可能性があります。
RequestScoped相当か、Flashを使うやり方で見直す必要があると思います。

常にBeginさせる?

基本的に ControllerによるActionをトリガーとして ConversationScopedをBeginするという方式として、あとはDIされるクラス側のScopedによって実際にサーバー側で情報を保持するか しないか という方式で考えていました。
ただ、それだと コンテナ上に意味のない管理情報インスタンスが作成されてしまったり、無駄なConversationのカウントアップがされてしまいます。
実際のメモリ使用量までは調べていませんが、これは良くない可能性があります。
当初は、会話スコープを使うものと そうでないもので テンプレートを分けるだけで良いと考えたのですが、上述のviewActionの件も含めて テンプレートによる制御を分類するというやり方から駄目になったら そうもいきません。

全てのルートとなるxhtmlに共通で必要となるviewActionを記述すれば良いというのが、パッと思いつく案ではありますが、共通記述を強いる方式は避けられるのであれば避けたいです。

さいごに

今回は、課題の整理までです。
今後も 課題があったら、本記事に追記しようと思います。


ConversationScopedのタイムアウトからの画面遷移

他のExceptionHandlerを実装している中で 以前の記事

vermeer.hatenablog.jp

で扱っていなかったタイムアウト周りの制御を やってみました。
ベースとするプロジェクトは、以前のサンプルのものではなく 以下のものに実装を加えて行いました。

vermeer.hatenablog.jp

やりたいこと

  • ConversationScopedがタイムアウトしたら「会話スコープの開始位置」へ遷移
  • もし 遷移先の画面描画でエラーがあったら、その時はどうしようもないのでエラー画面に遷移

会話スコープが終わった状態で操作をしたら NonexistentConversationException がスローされます。 この際、他の例外と同じ扱いをしてエラー画面へ強制遷移させるというやり方もあると思います。
セッションタイムアウトでも無い「一定操作をしていなかったから その操作を無効にします」というだけで エラー画面から、ときには再ログインを誘導するというのは ちょっと違うのではないかな?という理由です。

実装とポイント

ExceptionHandlerWrapper

JSFでの例外処理は ExceptionHandlerWrapperを拡張したクラスで制御します

public class CustomExceptionHandler extends ExceptionHandlerWrapper {

    private final ExceptionHandler wrapped;
    private final ThrowableHandlerFactory throwableHandlerFactory;
    private final ErrorPageNavigator errorPageNavigator;

    CustomExceptionHandler(ExceptionHandler exception, ThrowableHandlerFactory throwableHandlerFactory, ErrorPageNavigator errorPageNavigator) {
        this.wrapped = exception;
        this.throwableHandlerFactory = throwableHandlerFactory;
        this.errorPageNavigator = errorPageNavigator;
    }

    @Override
    public ExceptionHandler getWrapped() {
        return this.wrapped;
    }

    @Override
    public void handle() {

        final Iterator<ExceptionQueuedEvent> it = getUnhandledExceptionQueuedEvents().iterator();

        while (it.hasNext()) {

            ExceptionQueuedEventContext eventContext = (ExceptionQueuedEventContext) it.next().getSource();
            Throwable throwable = getRootCause(eventContext.getException()).getCause();

            ThrowableHandler throwableHandler = this.throwableHandlerFactory.createThrowableHandler(throwable, eventContext);

            try {
                throwableHandler.execute();

            } catch (Exception ex) {
                this.errorPageNavigator.navigate(ex);

            } finally {
                // 未ハンドリングキューから削除する
                it.remove();
            }

            getWrapped().handle();
        }

    }
}

コードは省略しますが

vermeer.hatenablog.jp

で紹介したように、DIもできます。

例外を制御

もし会話スコープの開始が任意のタイミングだったら結構面倒なことになったと思うのですが、方式として会話スコープの開始を常に index.xhtmlになるようにしているので、やることは単純です。
NonexistentConversationExceptionを受けたら index.xhtmlに遷移させるだけです。

画面遷移には ExternalContextを使用します。
NavigationHandlerを使って画面遷移をしたのですが、フェーズのタイミングが違うのか 上手く行きませんでした。*1

なお、エラー画面への遷移は、個別の例外処理で行うのではなくて ThrowableHandler の具象クラスにおいて 実行時例外としてThrowさせて共通化を図っています。

あとメッセージ出力をさせるために、状態を持ちまわします。
FacesMessageへ出力する実装もしたのですが、それだとメッセージが出力されなかったための処置です。*2

/**
 * NonexistentConversationException の捕捉後の処理を行う機能を提供します.
 * <p>
 * 会話スコープが既に終わっている場合の実行時例外なので、会話のスタートに位置する{@code index.xhtml}へ遷移させる。
 * 同時に会話スコープも終了(終了しているが念のため終了)させてから、再開させる。
 *
 * @author Yamashita,Takahiro
 */
public class NonexistentConversationExceptionHandler implements ThrowableHandler {

    private final ConversationLifecycleManager conversationLifecycleManager;
    private final FacesContext facesContext;
    private final NonexistentConversationExceptionMessage nonexistentConversationExceptionMessage;

    public NonexistentConversationExceptionHandler(ConversationLifecycleManager conversationLifecycleManager, NonexistentConversationExceptionMessage nonexistentConversationExceptionMessage, FacesContext facesContext) {
        this.conversationLifecycleManager = conversationLifecycleManager;
        this.facesContext = facesContext;
        this.nonexistentConversationExceptionMessage = nonexistentConversationExceptionMessage;
    }

    /**
     * {@inheritDoc }
     * <p>
     * {@code NavigationHandler} では正しく画面遷移が実現しなかったので、{@code ExternalContext} で遷移させます.
     */
    @Override
    public void execute() {
        conversationLifecycleManager.endConversation();
        String contextPath = facesContext.getExternalContext().getRequestContextPath();
        String currentPage = facesContext.getViewRoot().getViewId();
        String indexRootPath = currentPage.substring(0, currentPage.lastIndexOf("/") + 1);
        String indexPage = indexRootPath + "index.xhtml";
        String forwardPage = contextPath + indexPage;
        ExternalContext externalContext = facesContext.getExternalContext();
        try {
            ServletContext servletContext = (ServletContext) facesContext.getExternalContext().getContext();
            if (servletContext.getRealPath(indexPage) == null) {
                throw new ThrowableHandlerException("Target context file could not find.");
            }
            nonexistentConversationExceptionMessage.setException();
            externalContext.redirect(forwardPage);
        } catch (IOException ex) {
            throw new ThrowableHandlerException(ex);
        }
    }

}


会話開始画面へ遷移して会話を再開するときにメッセージを出力します。

/**
 * 会話スコープのライフサイクルを操作するクラスです.
 *
 * @author Yamashita,Takahiro
 */
@Named
@ApplicationScoped
public class ConversationLifecycleManager {

   (略)

    /**
     * 会話スコープの開始します。
     * <P>
     * 会話スコープが未開始にもかかわらず、indexページ以外を遷移先として指定していた場合は、強制的にindexページへ遷移させます.
     *
     * @return 会話スコープ開始済みの場合は指定のページ、未開始の場合はindexページ
     */
    public String startAndForwardIndexPage() {

        // NonexistentConversationException があった場合にメッセージを出力します.
        if (nonexistentConversationExceptionMessage.state()
            == NonexistentConversationExceptionMessage.State.HAS_EXCEPTION) {
            String message = this.messageConverter.toMessage(nonexistentConversationExceptionMessage.message());
            this.messageWriter.appendErrorMessage(message);
        }

        String currentViewId = context.currentViewId();
        if (this.conversation.isTransient() == false) {
            return currentViewId;
        }

        this.startConversation();

        if (currentViewId.equals("index.xhtml") == false) {
            return (String) context.responseViewId("index.xhtml");
        }
        return (String) context.responseViewId(currentViewId);
    }

   (略)

エラー画面への遷移をするクラス

これは、このクラスが読みだされる場合、必ずしも会話スコープを使っているとは限らないので、 念のため会話スコープを終了させつつ、エラー画面へ遷移させます。

@ApplicationScoped
public class ErrorPageNavigator {

    private ConversationLifecycleManager conversationLifecycleManager;

    public ErrorPageNavigator() {
    }

    @Inject
    public ErrorPageNavigator(ConversationLifecycleManager conversationLifecycleManager) {
        this.conversationLifecycleManager = conversationLifecycleManager;
    }

    /**
     * エラー画面へ遷移させます.
     */
    public void navigate(Exception ex) {
        System.err.println(ex.getMessage());
        System.err.println(Arrays.toString(ex.getStackTrace()));
        conversationLifecycleManager.endConversation();

        FacesContext facesContext = FacesContext.getCurrentInstance();
        NavigationHandler navigationHandler = facesContext.getApplication().getNavigationHandler();
        String forwardPage = "/error.xhtml?faces-redirect=true";
        navigationHandler.handleNavigation(facesContext, null, forwardPage);
        facesContext.renderResponse();
    }
}

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

参考情報

stackoverflow.com

jjug-jsf/MyExceptionHandler.java at master · MasatoshiTada/jjug-jsf · GitHub

JSF2.0のエラーハンドリング - 見習いプログラミング日記

さいごに

NavigationHandlerで画面遷移が思ったように出来なかったのは想定外でしたが、こういうのも色々と学びにつながって良いですね。
ちなみに、まだ BusyConversationExceptionについては対応していません。
どっかでやんないとなぁ。

追記(2018/12/6)

このやり方はスレッドアンセーフな気がします。
コードの記述を省略しているNonexistentConversationExceptionMessageはスコープをSessionScopedにしており、複数の会話スコープが並列に存在すると適切に処理できない可能性が大きい気がします。
別対応に関連して、方式自体を見直しているため検証はしませんが、参考にされる方は ご自身でテストをするなどしてください。 補足として、メッセージを出力後 ステータスは初期化しているので「操作でエラーが出て、画面に表示されるまでのステータス」となっているので 概ね問題は発生しないとは思いますが、複数タブを開いて それぞれで操作をしていると 想定していない挙動になる可能性は あるにはあると思います。


*1:勝手にこういうことなのかな?という想像はありますが、それを調べるのは、、まぁ良いかなと。。

*2:NavigationHandlerで画面遷移が出来なかった理由と同じような理由だと想像しています。

JSFで指定の場所に検証結果のメッセージを出力する

vermeer.hatenablog.jp

の続き。

出力順序とは別モノですが メッセージを指定の場所に出力するというのをやりたいと思います。

おそらく、ですが 一般的な JSFにおける Valisationと 対象項目に対してメッセージを出力するための流れとしては

  1. 入力項目のidとh:messagefor を一致させて関連をつけておく
  2. xhtmlでvalidateを実行するか、Ajaxでvalidateして結果を返却する

という感じだと思います。

具体的には、以下の記事のような感じです。

JSFのカスタムバリデータでメッセージを表示する時は、メッセージにSEVERITY_ERRORを設定しないとh:messageのerrorClassは適用されません。 - Qiita

JSFのメッセージのレンダリング

メッセージとは違いますが、validationと項目との関連付けと言う意味では

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

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

というのも近しいことかもしれません。
ツールチップについては、今後 改めて考えるつもりですが、ここでは xhtmlがポイントになるんだなというのが 何となく伝われば十分です。

さて、今回の目的である
メッセージを指定の場所に出力する ですが、そもそも私のこれまでの実装方式では発想が全く異なります。*1
また、Serviceにおける検証不正の結果を、Viewに反映させようとした場合、トリガーを xhtmlにするというのは難しいですし、あまりスッキリとした感じもしません。

実際にできるかどうかも分かりませんが 例えば

  1. Serviceの検証不正を捕捉する
  2. 補足した結果を xhtmlに反映させる
  3. xhtmlから検証不正のvalidateに相当するアクションを行う
  4. 最終的な結果のxhtmlをクライアントに返却する

というようなイメージです。
「2. 補足した結果を xhtmlに反映させる」で どういう情報を保持させてトリガーさせるのか 良く分かりませんし
「3. xhtmlから検証不正のvalidateに相当するアクションを行う」も不自然です。 *2

もう1つ JSFならでは というところとして、クライアントIDも考慮が必要です。
詳細は、ブログ(JSFのIDあれこれ - システム開発で思うところ)で 実際の挙動を確認してもらうとして、ざっくりいうと xhtmlで記述した IDと UIComponentで評価するクライアントIDは 異なる ということです。
対象項目のIDに親となる項目IDを含めて、画面内にてユニークになるように編集されたものが JSF内部で 実際に使用されます。
これを分かっていないと「あれ?IDを取得しているのに なんでメッセージの領域と関連付け出来ないの?」ということに陥ります。

概要

検証不正の発行から、出力までの流れだけでも結構なボリュームです。
ざっくり やらないといけないことを説明すると

  • 正確なクライアントIDはUIComponentから取得しないといけない
  • 検証結果から出力先のプロパティ名が特定できるけど、それはクライアントIDではないので加工が必要
  • Serviceからのメッセージの場合はプロパティ名ではなくて、メッセージから関連するViewのプロパティ名を特定した上で 更にクライアントIDに変換が必要
  • 関連付けが無い場合は、h:messagesを出力先としてリスト出力をデフォルトの挙動にしておく

と言う感じです。

改めて、詳細の流れは以下です。

  1. 検証不正を発行
  2. 検証不正をインターセプターで捕捉
  3. Viewに使用するクラスを特定(a) Controlellrの @ViewContextで対応するViewクラスを関連付けしておきます
  4. メッセージとViewプロパティのペアを取得・作成(b)
    (a)のクラスでメッセージとフィールドを @InvalidMessageMapping で関連付けしておきます。 ((ちなみに @InvalidMessageMappingは ソートキーの取得でも使用した関連付け用のアノテーションです))
  5. 属性値とクライアントIDのペアを作成(c)
    HtmlMessageコンポーネントの 属性 for が 出力先となる IDです。
    あわせてクライアントIDも取得・編集しておきます。
  6. プロパティ名をクライアントIDに変換(d)
    (b)のプロパティ名を、(c)の情報を元に JSFで評価できる クライアントIDに変換します。
  7. 検証結果の情報から宛先を加工
    h:messages を出力先とする場合は nullh:messageを出力先とする場合は forに該当するクライアントID(forの属性値ではありません)となるように (c)を使用して編集します。
    Serviceの検証結果は、(d)を使って関連付けをします。
  8. 出力メッセージ情報として編集

実装(ポイントの抜粋)

1. 検証不正を発行

Formおよびドメインオブジェクトの不変条件不正

@View
public class UserRegistrationPage implements Serializable {

(略)

    @Valid
    @FieldOrder(1)
    @InvalidMessageMapping(RegisterUser.Error.SAME_EMAIL_USER_ALREADY_EXIST)
    private EmailForm email;

(略)

の各プロパティのクラスで指定する 以下のような制約

フォームにおける必須条件

public class EmailForm implements DefaultForm<UserEmail>, Serializable {

    private static final long serialVersionUID = 1L;

    @NotBlank(groups = ValidationGroups.Form.class)
    private String value = "";

    public EmailForm() {
    }

    public EmailForm(String userEmail) {
        this.value = userEmail;
    }

    /**
     * @inheritDoc
     */
    @Override
    public String display() {
        return this.getValue().getValue();
    }

    /**
     * @inheritDoc
     */
    @Valid
    @Override
    public UserEmail getValue() {
        return new UserEmail(this.value);
    }

}

とか、ドメインの型・桁チェック

public class UserEmail {

    @Nonnull
    @Email
    private final String value;

    public UserEmail(String value) {
        this.value = value;
    }

(略)


Serviceにおける事前条件不正

@Service
public class RegisterUser implements Command<User> {

(略)

    @AssertTrue(message = Error.SAME_EMAIL_USER_ALREADY_EXIST, groups = PreCondition.class)
    private boolean isNotExistSameEmail() {
        return userRepository.isNotExistSameEmail(user);
    }

(略)
}


が満たされない場合に、検証不正を発行します。

2. 検証不正をインターセプターで捕捉

説明に関係するところに コメントを入れています。
(実際のコードにはコメントはありません)

@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) {
            MessageMappingInfos messageMappingInfosNotYetReplaceClientId
                                // 3. Viewに使用するクラスを特定(a)
                                // 4. メッセージとViewプロパティのペアを取得・作成(b)  
                                = ViewContextScanner
                            .of(ic.getTarget().getClass().getSuperclass())
                            .messageMappingInfosNotYetReplaceClientId();
            
            // 5. 属性値とクライアントIDのペアを作成(c)
            // 6. プロパティ名をクライアントIDに変換(d)
            // 7. 検証結果の情報から宛先を加工  
            // 8. 出力メッセージ情報として編集
            ClientidMessages clientidMessages
                             = messageConverter.toClientidMessages(ex.getValidatedResults(), messageMappingInfosNotYetReplaceClientId);

            messageWriter.appendErrorMessages(clientidMessages);
            return currentViewId;
        }

    }
}

3. Viewに使用するクラスを特定(a)

Controlellrの @ViewContextで対応するViewクラスを関連付けしておきます

@Controller
public class UserRegistrationAction {

    @ViewContext
    private UserRegistrationPage registrationPage;

    (略)
}


4. メッセージとViewプロパティのペアを取得・作成(b)

@ViewContextから情報を取得するクラス

/**
 * Controllerと関連付くViewクラス({@link spec.annotation.presentation.controller.ViewContext}で特定したクラス)から
 * {@link spec.annotation.presentation.view.InvalidMessageMapping}が付与されたフィールド情報を取得する機能を提供します.
 * <p>
 * {@link spec.annotation.FieldOrder} により 出力するメッセージの順序を指定します。
 *
 * @author Yamashita,Takahiro
 */
public class ViewContextScanner {

    Class<?> actionClass;
    MessageMappingInfos messageMappingInfos;

    private ViewContextScanner(Class<?> actionClass) {
        this.actionClass = actionClass;
        this.messageMappingInfos = new MessageMappingInfos();
    }

    public static ViewContextScanner of(Class<?> actionClass) {
        return new ViewContextScanner(actionClass);
    }

    /**
     * メッセージとプロパティを関連付けた情報を返却します.
     * <p>
     * 保持しているクライアントIDは、取得したプロパティ名のまま(クライアントIDへ変換する前の状態)です.
     *
     * @return メッセージとプロパティを関連付けた情報
     */
    public MessageMappingInfos messageMappingInfosNotYetReplaceClientId() {
        Field[] fields = actionClass.getDeclaredFields();

        // 3. Viewに使用するクラスを特定(a)
        for (Field field : fields) {
            ViewContext viewContext = field.getAnnotation(ViewContext.class);
            if (viewContext == null) {
                continue;
            }
            resursiveAppendField(field.getType(), field.getType().getCanonicalName());
        }
        return messageMappingInfos;
    }

    //
    // 4. メッセージとViewプロパティのペアを取得・作成(b)  
    void resursiveAppendField(Class<?> clazz, String appendKey) {
        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {
            InvalidMessageMapping invalidMessageMapping = field.getAnnotation(InvalidMessageMapping.class);

            if (invalidMessageMapping == null) {
                continue;
            }

            String fieldOrder = fieldOrder(field);
            String key = appendKey + fieldOrder + field.getName();

            String[] messages = invalidMessageMapping.value();

            for (String message : messages) {
                messageMappingInfos.put(message, key, field.getName());
            }

            this.resursiveAppendField(field.getType(), key);
        }
    }

    //
    String fieldOrder(Field field) {
        short index = Short.MAX_VALUE;

        FieldOrder fieldOrder = field.getAnnotation(FieldOrder.class);
        if (fieldOrder != null) {
            index = fieldOrder.value();
        }

        return String.format("%03d", index);
    }

}

5. 属性値とクライアントIDのペアを作成(c)

HtmlMessageコンポーネントの 属性 for が 出力先となる IDです。
あわせてクライアントIDも取得・編集しておきます。

編集のメインとなるクラス
他の説明に相当するところにもコメントを入れています。

@ApplicationScoped
public class JsfMessageConverter implements MessageConverter {

    private MessageInterpolatorFactory interpolatorFactory;

    private CurrentViewContext context;

    public JsfMessageConverter() {
    }

    @Inject
    public JsfMessageConverter(CurrentViewContext context) {
        this.context = context;
    }

    @PostConstruct
    protected void init() {
        this.interpolatorFactory = MessageInterpolatorFactory.of("Messages", "FormMessages", "FormLabels");
    }

    /**
     * {@inheritDoc }
     */
    @Override
    public List<String> toMessages(Collection<ConstraintViolation<?>> constraintViolations) {
        MessageInterpolator interpolator = interpolatorFactory.create(context.clientLocate());
        return constraintViolations.stream()
                .map(interpolator::toMessage)
                .collect(Collectors.toList());
    }

    /**
     * {@inheritDoc }
     */
    @Override
    public ClientidMessages toClientidMessages(Set<ConstraintViolation<?>> constraintViolationSet, MessageMappingInfos messageMappingInfosNotYetReplaceClientId) {

        // 5. 属性値とクライアントIDのペアを作成(c)  
        TargetClientIds targetClientIds = this.scanTargetClientIds(
                FacesContext.getCurrentInstance().getViewRoot().getChildren(), 0, new TargetClientIds());

        // 6. プロパティ名をクライアントIDに変換(d)
        MessageMappingInfos messageMappingInfos
                            = messageMappingInfosNotYetReplaceClientId.replacedClientIds(targetClientIds);

        // 7. 検証結果の情報から宛先を加工(1)
        ConstraintViolationForMessages constraintViolationForMessages = PresentationConstraintViolationForMessages
                .of(constraintViolationSet, targetClientIds)
                .toConstraintViolationForMessages();

        // 8. 出力メッセージ情報として編集
        return constraintViolationForMessages
                // 7. 検証結果の情報から宛先を加工(2)
                .update(c -> messageMappingInfos.updateConstraintViolationForMessage(c))
                .toClientidMessages(c -> this.toClientidMessage(c));
    }

    private TargetClientIds scanTargetClientIds(List<UIComponent> uiComponents, int depth, TargetClientIds targetClientIds) {
        for (UIComponent uiComponent : uiComponents) {

            /**
             * h:message と対象要素が並列の構造の動作確認が出来ている状態です.
             * 繰り返し領域の対応などをする場合には、改修が必要であると想定されますが 未対応です.
             */
            if (uiComponent instanceof HtmlMessage) {
                Object obj = uiComponent.getAttributes().get("for");
                if (obj != null) {
                    String clientId = uiComponent.getClientId();
                    String id = uiComponent.getId();
                    String targetId = clientId.substring(0, clientId.length() - id.length()) + obj.toString();
                    targetClientIds.put(obj.toString(), targetId);
                }
            }

            if (uiComponent.getChildren().isEmpty() == false) {
                this.scanTargetClientIds(uiComponent.getChildren(), depth + 1, targetClientIds);
            }

        }
        return targetClientIds;
    }

    private ClientidMessage toClientidMessage(ConstraintViolationForMessage constraintViolationForMessage) {
        MessageInterpolator interpolator = interpolatorFactory.create(context.clientLocate());
        String message = interpolator.toMessage(constraintViolationForMessage.getConstraintViolation());
        String targetClientId = constraintViolationForMessage.getId();
        return new ClientidMessage(targetClientId, message);
    }

}

再帰処理のアイディアは パーフェクトJava EE (P296) を参考にしました。

※ 後で気が付きましたが depthは不要でした。

6. プロパティ名をクライアントIDに変換(d)

(b)のプロパティ名を、(c)の情報を元に JSFで評価できる クライアントIDに変換します。

例えば、xhtml

<form id="f" class="ui form" jsfc="h:form">

    <div class="ui vertical segment">
        <div class="field">
            <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>
  (略)

というように、Viewクラスのプロパティ名と インプットフォームのID と メッセージ出力先のID(forで指定している値) を「emai」として 一致させていたとしても、 実際に JSFが処理するときには、その上位となる <form id="f"> を含んだ f-emailとなるため 正しく処理がされません。

ということで、実際のUIComponentから取得した情報である(b)を使って、値の置き換えをします。

public class MessageMappingInfos {

    (略)

    // 6. プロパティ名をクライアントIDに変換(d)

    /**
     * 項目名であるIDからクライアントID(フルパス)に置き換えた、新たなインスタンスを返却します.
     * <p>
     * TODO:まだクライアントIDを複数保持した機能は実装していません。(繰り返し処理を扱っていないため)
     * {@link TargetClientIds} はクライアントIDを複数保持していますが、デフォルトとして先頭のクライアントIDで置き換えます.<br>
     *
     * @param targetClientIds 項目名とクライアントIDを置き換えるための情報
     * @return 項目名であるIDからクライアントID(フルパス)に置き換えた 新たなインスタンス
     */
    public MessageMappingInfos replacedClientIds(TargetClientIds targetClientIds) {

        List<MessageMappingInfo> replaceItems = messageMappingInfos.entrySet().stream()
                .map(entry -> {
                    String message = entry.getKey();

                    MessageMappingInfo messageMappingInfo = entry.getValue();
                    TargetClientIds clientIds = messageMappingInfo.getTargetClientIds();
                    String replaceClientId = targetClientIds.getClientIdOrNull(clientIds);
                    MessageMappingInfo replacedMessageMappingInfo = new MessageMappingInfo(message,
                                                                                           messageMappingInfo.getSortKey(),
                                                                                           replaceClientId);
                    return replacedMessageMappingInfo;
                })
                .collect(Collectors.toList());

        MessageMappingInfos replacedMessageMappingInfos = new MessageMappingInfos();
        for (MessageMappingInfo replaceItem : replaceItems) {
            replacedMessageMappingInfos.put(replaceItem);
        }

        return replacedMessageMappingInfos;
    }

    // 7. 検証結果の情報から宛先を加工(2)
    public ConstraintViolationForMessage updateConstraintViolationForMessage(ConstraintViolationForMessage constraintViolationForMessage) {

        MessageMappingInfo messageMappingInfo = messageMappingInfos.get(
                constraintViolationForMessage.getConstraintViolation().getMessageTemplate());

        String _sortKey = (messageMappingInfo != null)
                          ? messageMappingInfo.getSortKey() : constraintViolationForMessage.getSortKey();

        String _id = (messageMappingInfo != null)
                     ? messageMappingInfo.firstClientId()
                     : constraintViolationForMessage.getId();

        return new ConstraintViolationForMessage(_sortKey,
                                                 _id,
                                                 constraintViolationForMessage.getConstraintViolation());
    }

}

7. 検証結果の情報から宛先を加工

h:messages を出力先とする場合は nullh:messageを出力先とする場合は forに該当するクライアントID(forの属性値ではありません)となるように (c)を使用して編集します。
Serviceの検証結果は、(d)を使って関連付けをします。

まずは、検証結果から 対象項目のプロパティ名を取得します。
ただし、Serviceから発行された検証結果のプロパティ名は 使えないので null にしておくことで デフォルトとして h:messages を出力先としておきます。

/**
 * クライアントメッセージの出力に必要な情報をPresentation層から取得して
 * ConstraintViolationと関連付ける機能を提供します.
 * <P>
 * <ul>
 * <li>{@link spec.annotation.FieldOrder} で指定したソート情報を付与します</li>
 * <li>UIComponentで指定した{@code for} で指定した情報がある場合は その項目を対象に、出力対象にない場合は 全体メッセージの対象にします</li>
 * </ul>
 *
 * @author Yamashita,Takahiro
 */
public class PresentationConstraintViolationForMessages {

    private final Set<ConstraintViolation<?>> constraintViolationSet;
    private final TargetClientIds targetClientIds;

    PresentationConstraintViolationForMessages(Set<ConstraintViolation<?>> constraintViolationSet, TargetClientIds targetClientIds) {
        this.constraintViolationSet = constraintViolationSet;
        this.targetClientIds = targetClientIds;
    }

    public static PresentationConstraintViolationForMessages of(Set<ConstraintViolation<?>> constraintViolationSet, TargetClientIds targetClientIds) {
        return new PresentationConstraintViolationForMessages(constraintViolationSet, targetClientIds);
    }

    public ConstraintViolationForMessages toConstraintViolationForMessages() {
        return new ConstraintViolationForMessages(
                constraintViolationSet
                        .stream()
                        .map(this::toConstraintViolationForMessage)
                        .collect(Collectors.toList())
        );
    }

    //
    private ConstraintViolationForMessage toConstraintViolationForMessage(ConstraintViolation<?> constraintViolation) {
        Class<?> clazz = constraintViolation.getRootBeanClass();
        List<String> paths = Arrays.asList(constraintViolation.getPropertyPath().toString().split("\\."));
        String key = this.recursiveAppendKey(clazz, paths, 0, clazz.getCanonicalName());
        String id = this.toId(clazz, paths.get(0));
        return new ConstraintViolationForMessage(key, id, constraintViolation);
    }

    //
    private String recursiveAppendKey(Class<?> clazz, List<String> paths, Integer index, String appendedKey) {
        if (paths.size() - 1 <= index) {
            return appendedKey;
        }

        String field = paths.get(index);
        String fieldOrder = fieldOrder(clazz, field);
        String key = appendedKey + fieldOrder + field;

        try {
            Class<?> nextClass = clazz.getDeclaredField(field).getType();
            return this.recursiveAppendKey(nextClass, paths, index + 1, key);
        } catch (NoSuchFieldException | SecurityException ex) {
            throw new ConstraintViolationConverterException("Target field or filedtype can not get.", ex);
        }
    }

    //
    private String fieldOrder(Class<?> clazz, String property) {
        short index = Short.MAX_VALUE;

        try {
            Field field = clazz.getDeclaredField(property);
            FieldOrder fieldOrder = field.getAnnotation(FieldOrder.class);
            if (fieldOrder != null) {
                index = fieldOrder.value();
            }
        } catch (NoSuchFieldException | SecurityException ex) {
            throw new ConstraintViolationConverterException("Target field can not get.", ex);
        }

        return String.format("%03d", index);
    }

    /**
     * View情報から取得される情報から判断できる情報で message出力先を編集します.
     * <p>
     * xhtmlのforで指定したIdが存在しない場合は、messageの宛先が無いと言えるため nullを返却します.
     * ここではPresentation層から判断できる判断できる情報だけで編集して、他レイヤーによる更新は別に行います.
     *
     * @param clazz 検証不正のルートとなるクラス
     * @param path 検証不正のルートとなるフィールド名
     * @return xhtmlのforで指定したIdが存在しない場合は {@code null}、存在したら フィールド名を返却
     */
    private String toId(Class<?> clazz, String path) {

        // 7. 検証結果の情報から宛先を加工(1)
        return clazz.getAnnotation(View.class) != null
               ? this.targetClientIds.getOrNull(path)
               : null;
    }

}


このまま(nullのまま)では、Viewのメッセージしか関連付けできないので、メッセージとViewプロパティのペア(c) を使って 置き換えていきます。

コードは遡りますが「6. プロパティ名をクライアントIDに変換(d)」の「 7. 検証結果の情報から宛先を加工(2)」のところです。


8. 出力メッセージ情報として編集

ようやく辿り着きました。

クライアントIDと変換した出力用のメッセージのペアを作成します。

/**
 * {@link ConstraintViolationForMessage} の集約を扱う機能を提供します.
 *
 * @author Yamashita,Takahiro
 */
public class ConstraintViolationForMessages {

    private final List<ConstraintViolationForMessage> items;

    public ConstraintViolationForMessages(List<ConstraintViolationForMessage> constraintViolationForMessages) {
        this.items = constraintViolationForMessages;
    }

    /**
     * 関数で情報を更新した新たなインスタンスを返却します.
     * <P>
     * 循環参照をさせないために関数型で呼出し元で処理します.
     *
     * @param unaryOperator 更新する関数
     * @return 更新した新たなインスタンス
     */
    public ConstraintViolationForMessages update(UnaryOperator<ConstraintViolationForMessage> unaryOperator) {
        return new ConstraintViolationForMessages(
                items.stream()
                        .map(c -> unaryOperator.apply(c))
                        .collect(Collectors.toList())
        );
    }


    // 8. 出力メッセージ情報として編集

    /**
     * クライアントIDとメッセージの組み合わせた情報に変換した情報を返却します.
     * <p>
     * 出力順序は本メソッドで行い、メッセージの出力用変換は呼び出し側のクラスから関数によって編集を行います.
     *
     * @param function メッセージの出力変換を行う関数
     * @return 変換したクライアントIDとメッセージの組み合わせた情報
     */
    public ClientidMessages toClientidMessages(Function<ConstraintViolationForMessage, ClientidMessage> function) {
        List<ClientidMessage> clientidMessages = this.items.stream()
                .sorted(comparing(ConstraintViolationForMessage::getSortKey)
                        .thenComparing(s -> s.getConstraintViolation().getMessageTemplate()))
                .map(c -> function.apply(c))
                .collect(Collectors.toList());
        return new ClientidMessages(clientidMessages);
    }

}

あとは、その情報を出力してオシマイです。

/**
 * メッセージ出力する機能を提供します.
 *
 * @author Yamashita,Takahiro
 */
@ApplicationScoped
public class JsfMessageWriter implements MessageWriter {

    (略)

    /**
     * {@inheritDoc }
     */
    @Override
    public void appendErrorMessages(ClientidMessages clientidMessages) {
        this.templateMethod(facesContext -> {
            clientidMessages.getList().stream()
                    .forEachOrdered(clientidMessage -> {
                        FacesMessage facemsg = new FacesMessage(FacesMessage.SEVERITY_ERROR, clientidMessage.getMessage(), null);
                        facesContext.addMessage(clientidMessage.getClientId(), facemsg);
                    });
        });
    }

    private void templateMethod(Consumer<FacesContext> consumer) {
        FacesContext facesContext = FacesContext.getCurrentInstance();

        consumer.accept(facesContext);

        // リダイレクトしてもFacesMessageが消えないように設定
        facesContext.getExternalContext().getFlash().setKeepMessages(true);
    }

}

実行結果

トップページ
下の「JSF DDD 入力項目の横にメッセージ」が今回新しく追加したVIewです。

f:id:vermeer-1977:20181121153339p:plain


f:id:vermeer-1977:20181121153343p:plain


「利用者の新規登録」を選択して
何も入力しないで「確認する」を押したら、入力必須のメッセージが 項目の横に出力されます。 入力必須のメッセージはViewで定義しているValidationです。

f:id:vermeer-1977:20181121153346p:plain


一旦、一覧に戻って 今後は 一番上の行の「変更」を押して 既に存在するメールアドレスである「bbbbbb@example.com」を入力すると、メールアドレスが既に登録されている旨のメッセージが項目の横に出力されます。 このエラーは、変更における事前条件不正として定義しているものです。

f:id:vermeer-1977:20181121153350p:plain

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

参考情報

デザインの参考として以下のプロジェクトを参照させていただきました。

GitHub - system-sekkei/isolating-the-domain: Spring Boot : gradle, Spring MVC, Thymeleaf, MyBatis and Spring Security sample

さいごに

結構、面倒なことをしたとは思いますが ドメインに関する実装に対して、必要最低限の追記のみで実現できたので及第点かな と思っています。

ただし、このやり方では まだ ui:repeatのような繰り返しがあった場合については対応が出来ていません。
この手の繰り返し関連は、具体的な要件をもって検討したいため、現状 全般的に 保留にしています。

大体は これで良いのですが、h:messageh:messagesxhtmlにない場合の制御が不足しています。
これはブログに残すか分かりませんが、対応としては 実行時例外からエラー画面へ遷移する というような実装をしようと考えています。

JSF面倒だねって思われると辛いのですが、クライアントIDの編集が必要なのはJSFならではかもしれませんが Serviceの情報を踏まえてクライアントの項目に関連付けるというのは、多分ですけど JSF以外のフレームワークでも そこそこ手間はかかると思っていたりします。実際は どうなんでしょうね。


*1:どちらかというと アクション指向といわれるものに近いと思います。

*2:PostBackみたいなものでしょ? といえば まぁ近いかもしれないので 不自然じゃないという人もいるかもしれませんが・・・

Oracle Code One 2018 報告会 に行ってきました

感想とかではなく、今後、振り返るための情報収集です。

togetter

togetter.com

スライド

Code One Overview & Java in Container

いとうちひろさん (@chiroito)

スライド公開なし

Vector API from Project Panama

吉田 真也さん(@bitter_fox)

speakerdeck.com

<プチメモ>
日本語としては ベクターというより、ベクトルと読んだ方が正しい理解につながる気がしました。

Graal Update(仮題)

きしだなおきさん (@kis)

www.slideshare.net

Jakarta EE and Microservices

西川 彰広さん [日本オラクル]

スライド公開なし

JDKライセンスモデルの変更

伊藤 敬(@itakash)さん

www.slideshare.net

Twitter4j: Duke's choice Award 2018受賞記念セッション

山本 ユースケさん (@yusuke)

スライド公開なし

Helidon demo by Oracle

西川 彰広さん [日本オラクル]

スライド公開なし

ゆるふわ! Comminity Activities

まーや(Maaya)さん(@maaya8585)

20181117_OracleCodeOne2018 - Google スライド

懇親会&LT

www.slideshare.net

Application層の検証結果の出力順序を制御する

vermeer.hatenablog.jp

上記ではDomainやFormでの検証結果については、Pageクラスで指定した順序でメッセージ出力する事が出来ました。

ですが、Application層以降の検証不正については、レイヤーを跨った関連付けをする仕組みを持たないと その順序性を管理する事ができません。ということで色々と考察をしたのが以下です。

vermeer.hatenablog.jp

そして今回は考察を踏まえて実装した機能について説明します。

実装例であれば「こういう実装をして実行したら、結果として こんな感じになります」ということになると思いますが、今回は 仕組みを中心に説明をしたいと思います。
つまりApplication層から、どういうやってメッセージの順序を編集しているのか という感じで 遡るイメージです。

Code(関連するもの)

以下の説明でポイントになるコード。 (全コードは最後にリンクしています)

Service

package exsample.jsf.application.service;

import spec.interfaces.application.commnand.Command;
import spec.validation.PostConditionValidationGroups.PostCondition;
import spec.validation.PreConditionValidationGroups.PreCondition;
import spec.validation.Validator;
import spec.annotation.application.Service;
import exsample.jsf.domain.model.user.User;
import exsample.jsf.domain.model.user.UserRepository;
import javax.inject.Inject;
import javax.validation.constraints.AssertTrue;

@Service
public class RegisterUser implements Command<User> {

    private User user;

    private UserRepository userRepository;
    private Validator validator;

    public static class Error {

        public static final String SAME_EMAIL_USER_ALREADY_EXIST = "{same.email.user.already.exist}";
        public static final String USER_CANNOT_REGISTER = "{user.cannot.register}";
    }

    public RegisterUser() {
    }

    @Inject
    public RegisterUser(UserRepository userRepository, Validator validator) {
        this.userRepository = userRepository;
        this.validator = validator;
    }

    @AssertTrue(message = Error.SAME_EMAIL_USER_ALREADY_EXIST, groups = PreCondition.class)
    private boolean isNotExistSameEmail() {
        return userRepository.isNotExistSameEmail(user);
    }

    @Override
    public void validatePreCondition(User entity) {
        this.user = entity;
        validator.validatePreCondition(this);
    }

    public void with(User user) {
        validatePreCondition(user);
        userRepository.register(user);
        validatePostCondition(userRepository.persistedUser(user));
    }

    @AssertTrue(message = Error.USER_CANNOT_REGISTER, groups = PostCondition.class)
    private boolean isExistEntity() {
        return userRepository.isExistEntity(user);
    }

    @AssertTrue(message = Error.SAME_EMAIL_USER_ALREADY_EXIST, groups = PostCondition.class)
    private boolean isNotExistSameEmailAtOtherEntity() {
        return userRepository.isNotExistSameEmailAtOtherEntity(user);
    }

    @Override
    public void validatePostCondition(User entity) {
        this.user = entity;
        validator.validatePostCondition(this);
    }

}

ポイント

    public static class Error {

        public static final String SAME_EMAIL_USER_ALREADY_EXIST = "{same.email.user.already.exist}";
        public static final String USER_CANNOT_REGISTER = "{user.cannot.register}";
    }

ServiceとPageで使用するメッセージ情報を関連付けるために 定数のインナークラスを作成しました。

Controller

import spec.annotation.presentation.controller.Controller;
import spec.annotation.presentation.controller.EndConversation;
import spec.annotation.presentation.controller.ViewContext;
import exsample.jsf.application.service.RegisterUser;
import exsample.jsf.application.service.UserService;
import exsample.jsf.domain.model.user.User;
import javax.inject.Inject;

@Controller
public class UserRegistrationAction {

    @ViewContext
    private UserRegistrationPage registrationPage;

    private UserService userService;

    private RegisterUser registerUser;

    public UserRegistrationAction() {
    }

    @Inject
    public UserRegistrationAction(UserRegistrationPage registrationForm, UserService userService, RegisterUser registerUser) {
        this.registrationPage = registrationForm;
        this.userService = userService;
        this.registerUser = registerUser;
    }

    public String fwPersist() {
        this.registrationPage.init();
        return "persist-edit.xhtml";
    }

    public String confirm() {
        User requestUser = this.registrationPage.toUser();
        registerUser.validatePreCondition(requestUser);
        return "persist-confirm.xhtml";
    }

    public String modify() {
        return "persist-edit.xhtml";
    }

    public String register() {
        User requestUser = this.registrationPage.toUser();
        registerUser.with(requestUser);
        User responseUser = userService.persistedUser(requestUser);
        this.registrationPage.update(responseUser);
        return "persist-complete.xhtml";
    }

    @EndConversation
    public String fwTop() {
        return "index.xhtml";
    }

}

ポイント

Serviceから発行された検証不正とPageのFieldを関連付ける対象は @ViewContextで宣言したものを対象とします。

Page(Form)

import spec.annotation.FieldOrder;
import spec.validation.Validator;
import spec.annotation.presentation.view.InvalidMessageMapping;
import spec.annotation.presentation.view.View;
import exsample.jsf.application.service.RegisterUser;
import exsample.jsf.domain.model.user.GenderType;
import exsample.jsf.domain.model.user.User;
import exsample.jsf.domain.model.user.UserId;
import exsample.jsf.presentation.userregistration.form.DateOfBirthForm;
import exsample.jsf.presentation.userregistration.form.EmailForm;
import exsample.jsf.presentation.userregistration.form.GenderForm;
import exsample.jsf.presentation.userregistration.form.NameForm;
import exsample.jsf.presentation.userregistration.form.PhoneNumberForm;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;
import javax.inject.Inject;
import javax.validation.Valid;

@View
public class UserRegistrationPage implements Serializable {

    private static final long serialVersionUID = 1L;

    private Validator validator;

    private UserId userId;

    @Valid
    @FieldOrder(1)
    @InvalidMessageMapping(RegisterUser.Error.SAME_EMAIL_USER_ALREADY_EXIST)
    private EmailForm userEmail;

    @Valid
    @FieldOrder(2)
    private NameForm name;

    @Valid
    @FieldOrder(3)
    private DateOfBirthForm dateOfBirth;

    @Valid
    @FieldOrder(4)
    private PhoneNumberForm phoneNumber;

    @Valid
    @FieldOrder(5)
    private GenderForm gender;

(メソッドは省略)

}

ポイント

    @FieldOrder(1)
    @InvalidMessageMapping(RegisterUser.Error.SAME_EMAIL_USER_ALREADY_EXIST)
    private EmailForm userEmail;

Serviceで発行した検証不正のメッセージと、Pageのフィールドを関連付け。

Interseptor

import ee.jsf.messages.JsfMessageConverter;
import ee.validation.ConstraintViolationsHandler;
import ee.validation.ViewContextScanner;
import java.util.List;
import javax.annotation.Priority;
import javax.enterprise.context.Dependent;
import javax.inject.Inject;
import javax.interceptor.AroundInvoke;
import javax.interceptor.Interceptor;
import javax.interceptor.InvocationContext;
import spec.annotation.presentation.controller.Action;
import spec.interfaces.infrastructure.CurrentViewContext;
import spec.interfaces.infrastructure.MessageConverter;
import spec.interfaces.infrastructure.MessageHandler;
import spec.validation.BeanValidationException;

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

    private final CurrentViewContext context;

    private final MessageConverter messageConverter;

    private final MessageHandler messageHandler;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context, JsfMessageConverter messageConverter, MessageHandler messageHandler) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageHandler = messageHandler;
    }

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

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {
            ConstraintViolationsHandler handler = new ConstraintViolationsHandler.Builder()
                    .messageSortkeyMap(ViewContextScanner.of(ic.getTarget().getClass().getSuperclass()).scan())
                    .constraintViolationSet(ex.getValidatedResults())
                    .build();
            List<String> messages = messageConverter.toMessages(handler.sortedConstraintViolations());
            messageHandler.appendMessages(messages);
            return currentViewId;
        }

    }
}

と、そのInterceptorで使っている主たる機能

public class ConstraintViolationsHandler {

    private final List<SortKeyConstraintViolation> sortKeyConstraintViolations;

    private ConstraintViolationsHandler(List<SortKeyConstraintViolation> sortKeyConstraintViolations) {
        this.sortKeyConstraintViolations = sortKeyConstraintViolations;
    }

    public List<ConstraintViolation<?>> sortedConstraintViolations() {
        return sortKeyConstraintViolations.stream()
                .sorted(comparing(SortKeyConstraintViolation::getSortkey)
                        .thenComparing(s -> s.getConstraintViolation().getMessageTemplate()))
                .map(SortKeyConstraintViolation::getConstraintViolation)
                .collect(Collectors.toList());
    }

    public static class Builder {

        private final MessageTmplateSortKeyMap messageTmplateSortKeyMap;
        private Set<ConstraintViolation<?>> constraintViolationSet;

        public Builder() {
            messageTmplateSortKeyMap = new MessageTmplateSortKeyMap();
            constraintViolationSet = new HashSet<>();
        }

        public Builder messageSortkeyMap(MessageTmplateSortKeyMap messageTmplateSortKeyMap) {
            this.messageTmplateSortKeyMap.putAll(messageTmplateSortKeyMap);
            return this;
        }

        public Builder constraintViolationSet(Set<ConstraintViolation<?>> constraintViolationSet) {
            this.constraintViolationSet = constraintViolationSet;
            return this;
        }

        public ConstraintViolationsHandler build() {
            return new ConstraintViolationsHandler(
                    messageTmplateSortKeyMap.replaceSortKey(
                            SortkeyConstraintViolationConverter.of(constraintViolationSet).toList()
                    ));
        }

    }
}

ポイント

前提として@Controller@Stereotype@Actionを組み込んでいます。

BeanValidationExceptionInterceptor

コードに説明用コメントを追記しました。

try {
    return ic.proceed();
} catch (BeanValidationException ex) {
    ConstraintViolationsHandler handler = new ConstraintViolationsHandler.Builder()
    
            // ViewContextScanner で @ViewContext で指定したクラスから 
            // @InvalidMessageMapping から Serviceのメッセージとフィールドを関連付けて
            // @FiledOrderの順序を取得して ソートに必要な情報を編集
            .messageSortkeyMap(ViewContextScanner.of(ic.getTarget().getClass().getSuperclass()).scan())
            .constraintViolationSet(ex.getValidatedResults())
            .build();
            
    // 検証不正をソート順で並べてメッセージ出力する
    List<String> messages = messageConverter.toMessages(handler.sortedConstraintViolations());
    messageHandler.appendMessages(messages);
    return currentViewId;
}

ざっくり

上述のポイントを何となく見てもらえれば雰囲気は伝わるかと思いますが、ざっくりと流れを説明すると

  • Serviceから検証不正を発行
  • Presentation層のInterceptorで捕捉
  • Interceptorでは Controllerで使用するPageクラスを@ViewContextで特定
  • @ViewContextで特定したPageクラスの@InvalidMessageMappingとServiceのメッセージで検証不正の結果を関連付け
  • 関連付けしたフィールドの@FieldOrderで順序を取得
  • 「メッセージと順序」をペアを作成
  • 検証不正情報から「メッセージと検証不正結果クラス」のペアを作成
  • 「メッセージと順序」と「メッセージと検証不正結果クラス」で「順序と検証不正結果クラス」を作成
  • 「順序と検証不正結果クラス」をソートして、検証不正結果クラスの情報から メッセージを取得して出力

という感じです。

最後の方の「順序と検証不正結果クラス」で まとめておいて、最終的にメッセージを出力するだけなところに冗長さを感じるかもしれませんが、この辺りは Domainの検証不正の仕組みとあわせて処理するようにしているための施策です。

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

今回は順序情報について取得・編集をしました。
この方法で、フィールド情報との関連付けができそうなので 色々と応用が出来る気がします。

パッケージ構成の考察(2)

vermeer.hatenablog.jp

で ベースを考えて 以下の参考プロジェクトを コツコツと肉付けしています。

vermeer.hatenablog.jp

新しい機能を実装する中で段々と どのパッケージに どのクラスを配置させるのが良いのか混とんとしてきました。
このまま なんとなく続けるのではなく、一旦 手を止めてパッケージの構成を整理することにしました。

概要

以前の考察のメインは青色のところで、今回の考察のメインはオレンジのところ。

実際のパッケージ構成

+---ee
|   +---interceptor
|   |   +---controller
|   |   +---scope
|   |   |   \---conversation
|   |   \---validation
|   +---jsf
|   |   +---context
|   |   +---exceptionhandler
|   |   \---messages
|   \---validation
|       \---priority
|
+---exsample
|   \---jsf
|       +---application
|       |   \---service
|       +---domain
|       |   +---model
|       |   |   \---user
|       |   \---validation
|       +---infrastructure
|       |   \---datasource
|       |       \---user
|       \---presentation
|           \---userregistration
|               \---form
|
+---spec
    +---annotation
    |   +---application
    |   \---presentation
    |       +---controller
    |       \---view
    +---exception
    +---interfaces
    |   +---application
    |   |   \---commnand
    |   \---infrastructure
    +---scope
    |   \---conversation
    \---validation

specパッケージ

spec、つまり仕様を表現するパッケージです。

プロダクトにおける統一的なインターフェースであったり、宣言的実装のためのアノテーションだったりを まとめたものです。

オレオレFWにおける枠組みであり、 特定の技術に極力依存しないようにして、純粋な仕様として可搬性を担保することを意識しています。

例えば依存するパッケージは java.*またはjavax.*に限定するといった感じです。

eeパッケージ

実装のためのパッケージです。

各種ライブラリはプロダクトの infrastructureで ライブラリを直接使用しても良いのですが、毎回つくるのは 面倒です。
specのアノテーションを マーカーとして各種機能を提供したりします。

オレオレFWの実装にあたります。

考察

specにおけるパッケージの命名ルールですが、用途と レイヤーのどちらを先にするのか迷いました。

今回は 用途を先にしてレイヤーを後にしました。
理由はvalidationなどのレイヤーを跨った仕様を扱う際、レイヤーを上位にすると どうもしっくりしなかったからです。

spec.interfacesは、厳密には spec.forproduct.interfacesとかにした方が良いかもしれませんが必要以上に長くなるのも面倒だな、と思って 短くしました。ただ、他のパッケージが増えて来たりする中でコンテキストとして分類しておくべきだと思ったら、spec.forproduct.interfaces または spec.product.interfaces というように サブパッケージを設けるかもしれません。

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

今後もサンプルプロジェクトとして肉付けを続けていきますが、最終的には specパッケージを仕様の集約として、eeパッケージを そのサンプル実装として まとめ上げたいと思っています。

パッケージ構成について、迷いが無いかと言うと、、あります。
ありますが、何を見直すべきか まだ分かりません。

焦らずコツコツ。