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

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

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

Bitbucket

さいごに

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

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

最新の考察

vermeer.hatenablog.jp


はじめに

vermeer.hatenablog.jp

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

vermeer.hatenablog.jp

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

概要

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

実際のパッケージ構成

+---ee
|   +---interceptor
|   |   +---controller
|   |   +---scope
|   |   |   \---conversation
|   |   \---validation
|   +---jsf
|   |   +---context
|   |   +---exceptionhandler
|   |   \---messages
|   \---validation
|       \---priority
|
+---example
|   \---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

Bitbucket

さいごに

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

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

焦らずコツコツ。

Application層の検証結果をPageに関連付ける方法を考える

試行錯誤の 垂れ流しです。

やりたいこと

  • BeanValidationの結果や実行時例外を Pageクラスの特定のフィールドに関連付ける
  • 関連付ける対象フィールドは複数のケースもある

フィールドに直接関連付けたいのはメッセージ順序の情報を保持している Pageクラスのフィールドに付与した Annotation(@FieldOrder)から情報を取得したいからです。

なお、BeanValidationの結果は 特定のフィールドに関連付けて明示するべき要件になると思いますが、検証不正以外の実行時例外において 特定のフィールドに関連付ける必然は無いようにも思います。ということで、実行時例外とフィールドとの関連付けは後回し、もしくはしなくても良いかもしれないとして一旦は 考慮から外しておきます。

必要な要素

BeanValidationのメッセージキー(message template)と Pageクラスのフィールドのペアとなる情報。

例えば、Factoryクラスで表現するとしたら

ErrorMappingField.of("message.key",Hoge.class.fieldA)

複数だったら

ErrorMappingField.of("message.key",{Hoge.class.fieldA,Hoge.class.fieldB})

みたいな感じ。
なお、例としてFactoryで表現はしたけど、Factoryにするという訳ではありません。

関連付け処理は、BeanValidationをクライアント用のメッセージ変換をするのと同様に ControllerのAction用のInterceptorで行います。 要素としては、マッピング処理用のInterceptorを準備するということになります。

レイヤー

処理はInterceptorで行うとして、定義は どのレイヤーに配置するのが良いでしょうか?

発生源から遡る形で考察します。

Application

Application層の検証不正は、主にServiceから発行されるので、Serviceをベースに考えます。 ここで出来ることは、BeanValidationで検証不正理由となる情報を message で表現することだけです。

次に考えるとしたら、message に指定する情報を String(文字列)とするのか、EnumクラスのValueを使用するようにするか、です。

@AssertTrueのメッセージを見れば、要件を把握することは出来ますし、幸い Annotationで情報を付与しているので 実行時であれ ツールであれ Serviceに どのような message が指定されているのか抽出することも可能です。
その上で Enumクラスでわざわざクラス実装までする必要があるか?というところです。

ここで、検証不正を発行する側ではなく、発行された検証不正情報を扱う 利用側の立場で考えてみます。 変換するためには、元の検証不正の情報(message)を漏れなく、誤記なく把握したいです。
となると、

  • ビルド時にAnnotationから情報を取得してチェックする
  • Enumで定数型クラスとして定義する

のどちらかのやり方が想定されます。
前者は そのために仕組みを構築しないといけないし、回りくどさのあるアプローチだと思います。 (ほぼ必然的ですが)Enumクラスを作って Serviceにおける message の設定でも、その value を使用するというのが良さそうです。

Enumクラスにするとして、それは どのレイヤーで管理するのが良いでしょう?

Serviceクラスから発行されるものだから、Serviceクラスで定義して、可視性をpublicにしておいて 利用側でも参照できるようにするというアプローチが1つ。
利用側がPresentation層のクラスであるということは明確で、Interceptorで変換する際にも利用するので 全レイヤーから横断的に使用するということで Domainレイヤーで扱うというのが、もう1つ。

直感的には、Serviceクラス内で定義しておくのが良さそうに思います(つまり1つ目のアプローチ)。
その上で全レイヤーから扱えるような設計をカバーしたいところです。
全レイヤーから操作をするために、Enumクラスに message と 関連するターゲット を取得するインターフェースを設けて、それを経由して操作するようにすれば 良いかな。

もう1つのDomainレイヤーで扱うというのも悪くは無いと思っていて、雑に言うと message.propertiesで検証不正の情報をメッセージに変換するのだから、逆に それを使うだけという考え方。
message.properties の情報から Enumクラスを同期を取って作成して それを使えば 検証不正の結果出力において 表示が出来なかったということにはならないので 表示安全と言えます。 欠点は、Serviceが発行する検証不正の種類がServiceクラス内で定義している場合と違って、実際のServiceの実装を見ないと分からないというところ。
つまり連携対象とすべき情報が漏れてしまう可能性があるということです。 ただ、情報が欠落するというのではなく、あくまで並び順が制御できないだけなので致命的な障害という訳でも無いという考え方もあります。

なお、補足的な話としてmessage.properties の情報から Enumクラスを同期を取って作成については、以前作成したaptライブラリを使用することを想定しています。*1

vermeer.hatenablog.jp

ただ、今考えている構想だと Keyの扱いへの考慮が足りないですし 、BeanValidation用の messageTemplate として使う場合、{ } で囲っておかないといけないなど 多少手直しというか機能追加は必要です。

ちなみに、上述だと message.properties としていますが、このライブラリは Service単位(もしくはユースケース単位)で プロパティファイルを作成しておいて、Enumクラスに変換することも可能なので 2つの要件を満たすことも出来るのではないかな?と思っています。

なんとなく、方向性は見えてきた気がします。

  • Serviecの検証不正のメッセージはEnumクラスで実装
  • システム全体 もしくは サービス単位で Enumクラスを作成
  • 当該Serviceが どのような検証不正を発行するか、については 出来そうだったら追加考察するけど、出来なかったとしても致命的な障害を引き起こすわけではないとして割り切ることも視野に入れる

一旦このくらいで次のことを考えます。

Presentation

Serviceからの検証不正をマッピングする先が、Pageクラスのフィールドなので 連携マッピングは Presentation層のパッケージで扱います。

とりあえずマッピングにおけるKey情報は、Application側で考えた Enum値だとして、ここで考えるのは Valueとなる画面側です。

なお、フィールドの背景色を変える場合は画面IDとなる文字列を 何かしらの形で関連付ける必要がありますが、とりあえず今回は メッセージ表示順なので Pageクラスのフィールドと関連付けさえできれば 一旦良しとします。

では、それを実装する場所は Controllerか Pageか?

Controller

以前、検証不正のあったフィールドの背景色を変更する時にやったのは、検証前に 実行時例外で保持させているメッセージ と 画面フィールドのIDをマッピングしておいてから検証およびServiceの実行をするという やり方をしていました。
ということで、Controllerから考えてみたいと思います。

Controllerで「BeanValidationのメッセージキー(message template)と Pageクラスのフィールドのペアとなる情報」と類似なものを 設定しておいて Serviceを実行する感じになると思います。
以前は エラー用のScopeを持ったBeanを作っておいて、DIをして実現しました。Controllerのメソッド内で設定をしていましたが、今 思えば Annotationで宣言しておいて Interceptorで処理するときに 情報を取得して 裏側で処理しても良かったかもしれません。
ということで、もし 今回 Controllerでマッピングするなら、Anntaionでクラス全体、またはメソッド単位に Annotationで宣言的に実装することにするとして、
これでも良いかな、という印象はあるのですが 検証不正とPageのフィールドとのマッピングをしようとすると、Pageクラスのフィールドの可視性を privateよりも広く公開しないといけません。そうしないと Controllerから指定できないからです。

その上で気になるというか、どうしたものか、というところ

  • 多量のマッピングがあった時には ControllerがAnnotationまみれになってしまう
  • ネストや繰り返しになっている場合の指定方法は どうする?

正直、ノーアイディアなので、以前の検証不正の背景色変更の時に どうやって実装的にやったのかな?と思って確認してみると。。
メッセージ と 画面フィールドのIDをマッピングした情報を Scope管理下のBeanに保持しておいて、Faceletsの 色を意味するclass値を メソッド経由でDIしたBeanから取得するということをしていました。*2

単一フィールドは

class="#{errCssForm.find().cd}"

テーブルのフィールドは

 class="#{errCssForm.find(row.id).familyName}"

こんな感じです。 JSF(Facelets)だと、参照する Map型のインスタンスのキー値の指定ができるというだけです。
errCssForm.find()の戻り値として、画面IDとclass値のMapが返却されます。そのMapにキー値cdがあった場合は、そのvalueとしてエラーを表現するclass値(背景色)が返却されるという仕掛けです。 .find以降のフィールド風の値は 型安全なものではありませんし入力補完もありません*3。あくまで キー値(String)というだけです。

もしこれを Classファイルのフィールドにやろうとしたら、、んー、やっぱり いいアイディアが浮かばない。。

ただ、ここまで考えたり思い出したりする中で分かったことはマッピングする情報はPageクラスのフィールドだから、Controllerで指定するのではなくて Pageクラス側で宣言的に定義しておいた方が良いんじゃないか?ということです。

ということで、Controllerで考えることは ここまでとします。

Page

宣言的に指定するとした場合、イメージとしては

@InvalidMessageMapping(value=RegisterUser.Error.EXISTS_USER)
@FieldOrder(1)
private UserName uesName;

みたいな感じ。
特定のフィールドに関連付けるものだけにしか付与しないとはいえ、1つのフィールドに大量の検証不正を関連付けると Annotationまみれになってしまうのは避けようがないかもしれません。

@InvalidMessageMapping(value=RegisterUser.Error.EXISTS_USER)
@InvalidMessageMapping(value=RegisterUser.Error.ALREADY_CHANGE)
@FieldOrder(1)
private UserName uesName;

引数を配列にしたら、どうでしょう?

@InvalidMessageMapping(value={RegisterUser.Error.EXISTS_USER,RegisterUser.Error.ALREADY_CHANGE})
@FieldOrder(1)
private UserName uesName;

これは これでアリな気もしますが、手間と可読性自体は あまり変わりが無いように思います。 Annotationに第二引数を設けてグループ化する必要があれば、配列にする意味もありますが、そうでないから そこまででも無いかなぁという感じです。

次に、Pageクラスがネストした場合はどうしましょう?

全てのフィールドの配下全てのクラスに当該Annotationが存在しないか走査するという やり方もあると思いますが、さすがに性能面でやりたくありません。
とするとBeanValidationの@Validでマーカーするのと同じような仕組みで親クラス側で指定すれば良いかなと考えています。
例えば @MessageMappingが付いていれば、そのクラスのフィールドでも @InvalidMessageMapping があれば 連携情報として使用する感じです。

ところで、このようなネスト構造は 今後も色々とありそうです。

例えば

vermeer.hatenablog.jp

で扱った@GroupLabelも似たような発想のものです。 もっと広く見ると、@FieldOrderが付与されているフィールドも、同じかもしれません。
細かくAnnotationを設けておくことで、細かい制御ができるメリットはありますが、細かすぎるとAnnotationまみれになってしまいます。
多少、冗長であることは覚悟の上で、マーカーとなるAnnotationについては、もっとザックリと 一纏めにした方が良いような気もします。
たとえば、Pageクラスに対してはスコープを管理するAnnotationとして@Stereotypeで集約した@Viewというものを作成しました。それと同じようにフィールド用に@ViewItemというものを作成しても良いかもしれません。
イメージとしては、基本仕様は@FieldOrderと同じで、任意項目はパラメータで指定する感じ。 悩ましいのは、Annotationまみれから、パラメータ過多のAnnotaionになっただけ、となりそうなところ。
逆の発想としては@ViewItemを作るよりも、配下の情報を参照するマーカーとして@FieldOrder@GroupLabelの いずれかがあった場合は対象とするという やり方の方が良いかもしれません。 これなら、任意のAnnotationを後から追加できる仕組みだから柔軟かもしれません。

ネスト構造については、このくらいかな?

さて、ネストに似ているけど 異なるテーブル構造(繰り返し)は どうでしょう?

これまでの整理では、行情報よりも列情報の指定が優先されることになることになりそうです。
対処案としては Annotation、@Valid が付与できるコレクション、Annotationという順番で 順序制御をすれば、目的は満たせそうです。
@Valid が付与できるコレクションとは、具体的には「配列、 Iterable を実装したコレクション、 Map」を考えています。
発想の元がBeanValidationだから、まぁこれで良いかな?というくらいの雑な整理です。 ただ、現状準備している実験場は、テーブル形式の入力サンプルは無いので、とりあえず保留。

段々と 整理出来つつあるように思いますがServiceで検証不正が発生した結果をマッピングするPageクラスと そもそも どうやって関連付けしましょう?

Actionで指定

Actionのクラスまたはメソッドに、レスポンスに使用するコンテキストとして指定するイメージです。

@ResponseContext(HogePage.class)
public String hoge(){
    return "index.xhtml"
}

とか

@ResponseContext(HogePage.class)
public class UserRegistrationAction {

    public String hoge(){
        return "index.xhtml"
    }

}

クラスに指定したら、配下のAction全てに適用するイメージです。
この場合の発想としては、ServiceのCallerであるControllerのAction単位で、そのレスポンスとなるPageクラスを指定するという考え方です。
PageクラスはAggregate Rootとして扱っていますが、例えば 複数のコンポーネントで構築されているという実装をした場合なら、パラメータに複数のクラス指定をするイメージです。

フィールドに指定

PageクラスのFieldにAnnotationを付与して宣言的に表現する。

public class HogeAction {

    @ViewContext
    private FugaPage;

    public String hoge(){
        return "index.xhtml"
    }

}

もし、複数のコンテキストで実装されている場合は、それぞれのFieldにAnnotationを付与します。

で、どっち?

コードを見つつ思った印象としては、後者の方が良い気がします。

理由は、Annotationのパラメータとしてクラスを指定するやり方だと、実際にActionで使用しているPageクラスであるかどうか保証がない気がするからです。単純なタイプミスも含めて 実際に使用しているFieldの型と目視なりコンパイラなりで確認をしないといけません。
また基本的にServiceから発行された検証不正のマッピング用途が現状の想定ですが、それ以外の「Service - Controller - Form」を関連付けるマーカーという意味で考えても 意図が伝わりやすい方式のように思います。

参考情報

JavaEE使い方メモ(Bean Validation) - Qiita

さいごに

考え始めた時の構想とは随分と異なる結論になった気がしますが、なんとなく自分としては 腑に落ちる整理が出来た気がします。
あとは実際に実装してみて、出来る出来ない 実際に出来たコードを見て考え直す、というプロセスに入ろうと思います。


追記

2018/10/26

Annotationのパラメータとしてインターフェースを指定できませんでした。
Enumは指定できますが、そうするとEnum毎にAnnotationを準備しないといけません。

Service毎にEnumを作るのではなく、システム全体で1つのEnumを作って 全てのServiceで参照をするというやり方ならできるとは思います。
出来れば Service単位で管理したいのだけれど最悪 そうするしかないのかなぁ。。
でも、そうすると@InvalidMessageMappingが構造(仕様)を現すAnnotionではなくで具体的な実装と密になってしまうのでNGです。

となると、Stringで指定する方法で考えるしかないかな? 考えられる やり方としては Serviceクラスに 昔懐かしい 定数クラス方式。
少なくとも 構造的な記述を静的に実装はできます。
型安全な実装指針ではなく、実装作法を示す必要があるので、出来れば 基本型を使うような方式は取りたくなかったけど 仕方ないかなぁ。。

あっ、また あのライブラリが日の目を見るタイミングを逸してしまったのでは・・・。

なお、配列指定については、@InvalidMessageMapping側で 配列指定しておけば、上述の両方の要件は満たせるようです。


*1:やっと日の目を見るか!?

*2:文章だと訳が分からないところですが、とりあえず雑に書いておきます

*3:型が無いMapに格納される値なので 入力補完のやりようがない

Page/Formのvalidateを見直す

vermeer.hatenablog.jp

vermeer.hatenablog.jp

で考えたことに対して、実際の実装を経て 妥協(?)した

vermeer.hatenablog.jp

を踏まえて、改めて整理をした方が良さそうに思い 考えてみることしました。

はじめに

考え直してみようと思ったことを呟いたもの。

振り返り

  • Pageは、Formの集約ルート
  • Formは、DomainObjectをPresentation用に包含したクラス
  • ScopeはPage単位で制御
  • Pageは、Entityへの変換をする
  • FormとDomainObjectにValidationを定義してControllerで検証
  • Pageは、Formを管理するPOJOとしたい

ざっくりいうと、Viewのデータ管理と変換をするPOJO ということを役割にしようと思いました。 ですが「FormにValidationを定義してControllerで検証」が 想定通りには出来ませんでした。

CDIインスタンスから値を取得するためには、アクセッサ(getter)を使わないといけない」というところが理由です。

対処案としては

  • Pageクラスにvalidateメソッドを準備して それを使う
  • 検証用のアクセッサ(getter)を準備して@Validを設定する
  • 検証用に新たにインスタンスを生成する

いずれかで、できそうでした。

「Pageクラスにvalidateメソッドを準備して それを使う」は そもそもvalidateはControllerで行いたいということで採用しませんでした。
理由はValidatorをPageクラスにDIしないといけないから、私の想定しているPOJOの範疇を超えている気がしたというのも理由です。
「検証用のアクセッサ(getter)を準備して@Validを設定する」を採用しなかったのは、 検証用のアクセッサというのが、どうもしっくりこないというか アクセッサが多すぎるというか、それだったら「検証用に新たにインスタンスを生成する」で 役割を明確にしたインスタンスを返却するということで まとめた方が可読性が良いかな?ということで一旦 整理をしました。

考察

「検証用に新たにインスタンスを生成する」で、一旦 良し としたのですが、フィールドの並び順の指定など、Viewのデータ管理を Pageクラスで 実装していくことで「Pageクラスではなく 検証用のインスタンスに 関心事が集約されている」という状態になってしまっていると感じるようになりました。

となると、有力な候補となる実装は validate(this) を使った やり方です。

あとは、JSFらしく作って、インスタンス生成メソッドを準備するやり方でしょうか。
発想としては検証用としてインスタンス生成をするのであれば、いっそのことプロパティをStringで管理してしまおう、と。
どうせ割り切るのであれば、これくらい割り切ってしまえ という発想です。

再考察

validate(this) をPageクラスに含める(ValiatorをPageクラスへDI)ことになるわけですが、元々は Pageクラスは 限りなくPOJOにしたい、validateはControllerで行いたいと考えていました。
こじつけであれ、方式を変える際には 自分なりの論拠を整理しておきたいところです。

さて、そもそも 私の考えていたPOJOは 当初案の時点でも達成出来うる方式だったでしょうか?
改めて疑いの目でPageクラスの実装を見て、今更ながらに気が付いた事がありました。
JSFの実装に依存する記載があります。
これは、最終的には infrastructure層とDIで解決するつもりで 先送りにするつもりだった実装です。
そうなんです、DIしないようにしたい、という発想での設計は、そもそも実現できていなかったのです。。

他にも、設計として 中途半端というか 統一されていないと感じるところがありました。
Controllerで、Validateをするためだけに、Pageクラスの内部情報をわざわざ公開して、しかもやることは validateに渡すだけという DTOなんだか ViewのEntity的役割のものなんだか、どこを目指しているのか まとまりのない実装になっていることに気が付きました。 これは、動詞を現すクラスで 検証はした方が良い、という私の思い込みも原因の1つです。
Entityを生成(Page/Formから変換)をするのであれば、不変条件は満たしていることを保証するべきでしょう。
例えば、SpringBootだったらリクエストパラメータでValidateをするように。

つまり、PageクラスにEntity(DomainObject)への変換をする役割を与えるのであれば、その検証論理だけでなく、検証実行も 防御的にすれば良かったのです。。

結論としては、こじつけでもなんでもなく、Pageクラスにて Entity(DomainObject)への変換をするのであれば 不変条件検証も もれなく実施すべきであるので validateはPageクラスでする、というだけだったということです。

今 思えば、なんで考えが行き届かなかったのだろう?ということのような気もしますが、実際のところ Serviceクラスの実装において事前条件・事後条件の検証実行ロジックの置き場所を考えたから、今 改めて考えてみて気が付いたという気もしています。

実装

対応前

Page

@View
public class UserRegistrationPage implements Serializable {

    private static final long serialVersionUID = 1L;

    private UserId userId;

    private EmailForm userEmail;

    private NameForm name;

    private DateOfBirthForm dateOfBirth;

    private PhoneNumberForm phoneNumber;

    private GenderForm gender;

    public UserRegistrationPage() {
    }

    public UserRegistrationPage(UserRegistrationPage me) {
        this.userId = me.userId;
        this.userEmail = me.userEmail;
        this.name = me.name;
        this.dateOfBirth = me.dateOfBirth;
        this.phoneNumber = me.phoneNumber;
        this.gender = me.gender;
    }

    public void init() {
        this.userId = new UserId();
        this.userEmail = new EmailForm();
        this.name = new NameForm();
        this.dateOfBirth = new DateOfBirthForm();
        this.phoneNumber = new PhoneNumberForm();
        this.gender = new GenderForm();
    }

    public void update(User user) {
        this.userId = user.getUserId();
        this.userEmail = new EmailForm(user.getUserEmail().getValue());
        this.name = new NameForm(user.getName().getValue());
        this.dateOfBirth = new DateOfBirthForm(user.getDateOfBirth().getValue());
        this.phoneNumber = new PhoneNumberForm(user.getPhoneNumber().getValue());
        this.gender = new GenderForm(user.getGender().getValue());
    }

    public Map<String, String> checked(Integer index) {
        GenderType genderType = GenderType.find(index);
        Map<String, String> map = new HashMap<>();
        if (this.gender.isSameType(genderType)) {
            map.put("checked", "checked");
        }
        return map;
    }

    public String targetFor(UIComponent component, String targetName) {
        return component.getClientId() + "-" + targetName;
    }

    public void setGender(Integer index) {
        this.gender = new GenderForm(GenderType.find(index));
    }

    public User toUser() {
        return new User(this.userId, userEmail.getValue(), name.getValue(), dateOfBirth.getValue(), phoneNumber.getValue(), gender.getValue());
    }

   //検証用のインスタンスを生成するメソッド
    public Object getValidationForm() {
        ValidationForm obj = new ValidationForm();
        obj.userEmail = userEmail;
        obj.name = name;
        obj.dateOfBirth = dateOfBirth;
        obj.phoneNumber = phoneNumber;
        return obj;
    }

   //検証用のクラス定義。順序やValidを指定している。
    @SuppressFBWarnings("URF_UNREAD_FIELD")
    private static class ValidationForm {

        @Valid
        @FieldOrder(1)
        private EmailForm userEmail;

        @Valid
        @FieldOrder(2)
        private NameForm name;

        @Valid
        @FieldOrder(3)
        private DateOfBirthForm dateOfBirth;

        @Valid
        @FieldOrder(4)
        private PhoneNumberForm phoneNumber;

    }

//setter getter省略

Controller

@Controller
public class UserRegistrationAction {

    private UserRegistrationPage registrationPage;

    private UserService userService;

    private RegisterUser registerUser;

    private Validator validator;

    public UserRegistrationAction() {
    }

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

    public String fwPersist() {
        this.registrationPage.init();
        return "persistedit.xhtml";
    }

    public String confirm() {
       // 検証用インスタンスを生成・取得して、検証を実施。
        validator.validate(registrationPage.getValidationForm());
       // 検証不正が無いということでEntity(DomainObject)を生成
        User requestUser = this.registrationPage.toUser();
        registerUser.validatePreCondition(requestUser);
        return "persistconfirm.xhtml";
    }

    public String modify() {
        return "persistedit.xhtml";
    }

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

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

}

対応後

Page

@View
public class UserRegistrationPage implements Serializable {

    private static final long serialVersionUID = 1L;

    private Validator validator;

    private UserId userId;

    @Valid
    @FieldOrder(1)
    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;

    public UserRegistrationPage() {
    }

    @Inject
    public UserRegistrationPage(Validator validator) {
        this.validator = validator;
    }

    public void init() {
        this.userId = new UserId();
        this.userEmail = new EmailForm();
        this.name = new NameForm();
        this.dateOfBirth = new DateOfBirthForm();
        this.phoneNumber = new PhoneNumberForm();
        this.gender = new GenderForm();
    }

    public void update(User user) {
        this.userId = user.getUserId();
        this.userEmail = new EmailForm(user.getUserEmail().getValue());
        this.name = new NameForm(user.getName().getValue());
        this.dateOfBirth = new DateOfBirthForm(user.getDateOfBirth().getValue());
        this.phoneNumber = new PhoneNumberForm(user.getPhoneNumber().getValue());
        this.gender = new GenderForm(user.getGender().getValue());
    }

    public User toUser() {
        this.validator.validate(this);
        return new User(this.userId, userEmail.getValue(), name.getValue(), dateOfBirth.getValue(), phoneNumber.getValue(), gender.getValue());
    }

    public Map<String, String> checked(Integer index) {
        GenderType genderType = GenderType.find(index);
        Map<String, String> map = new HashMap<>();
        if (this.gender.isSameType(genderType)) {
            map.put("checked", "checked");
        }
        return map;
    }

    public String targetFor(UIComponent component, String targetName) {
        return component.getClientId() + "-" + targetName;
    }

//setter getter省略

Controller

@Controller
public class UserRegistrationAction {

    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 "persistedit.xhtml";
    }

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

    public String modify() {
        return "persistedit.xhtml";
    }

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

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

}

所感

Controllerがスッキリしました。
Pageクラスの変換にて、不変条件が充足されている事を保証することで、Controller側は 安全な実装が達成できているように思われます。
なにより「検証用に新たにインスタンスを生成する」実装が無くなったことで、分かりやすいコードになったと思います。

メモ

DIするValidatorについて、1つだけ想定外というか、言われてみればそうなのかもしれないけど… というようなことがありました。
それは、Validatorの具象クラスである BeanValidator が そのままでは PageクラスにDI出来なかったことです。

Pageクラスが ConversationScopedであるため、DIする具象クラスは Serializableじゃないということで実行時例外が発生しました。

BeanValidator は プロパティを持たないクラスなので Serializableは不要だろうと思っていましたが、そういう訳では無かったようです。

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

「じぶんのかんがえるさいきょうのふれーむわーく」の道のりは険しいですねぇ。
なにせ方式を考えている過去の自分が怪しいのですから。。