の続き。
出力順序とは別モノですが メッセージを指定の場所に出力するというのをやりたいと思います。
おそらく、ですが 一般的な JSFにおける Valisationと 対象項目に対してメッセージを出力するための流れとしては
という感じだと思います。
具体的には、以下の記事のような感じです。
JSFのカスタムバリデータでメッセージを表示する時は、メッセージにSEVERITY_ERRORを設定しないとh:messageのerrorClassは適用されません。 - Qiita
メッセージとは違いますが、validationと項目との関連付けと言う意味では
JSFでエラーのある項目の背景色を変える - じゃばらの手記
JSFでエラー項目の背景色を変える - 中年プログラマーの息抜き
というのも近しいことかもしれません。
ツールチップについては、今後 改めて考えるつもりですが、ここでは xhtmlがポイントになるんだなというのが 何となく伝われば十分です。
さて、今回の目的である
メッセージを指定の場所に出力する ですが、そもそも私のこれまでの実装方式では発想が全く異なります。*1
また、Serviceにおける検証不正の結果を、Viewに反映させようとした場合、トリガーを 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
を出力先としてリスト出力をデフォルトの挙動にしておく
と言う感じです。
改めて、詳細の流れは以下です。
- 検証不正を発行
- 検証不正をインターセプターで捕捉
- Viewに使用するクラスを特定(a)
Controlellrの
@ViewContext
で対応するViewクラスを関連付けしておきます - メッセージとViewプロパティのペアを取得・作成(b)
(a)のクラスでメッセージとフィールドを@InvalidMessageMapping
で関連付けしておきます。 ((ちなみに @InvalidMessageMapping
は ソートキーの取得でも使用した関連付け用のアノテーションです)) - 属性値とクライアントIDのペアを作成(c)
HtmlMessage
のコンポーネントの 属性for
が 出力先となる IDです。
あわせてクライアントIDも取得・編集しておきます。 - プロパティ名をクライアントIDに変換(d)
(b)のプロパティ名を、(c)の情報を元に JSFで評価できる クライアントIDに変換します。 - 検証結果の情報から宛先を加工
h:messages
を出力先とする場合はnull
、h:message
を出力先とする場合はfor
に該当するクライアントID(forの属性値ではありません)となるように (c)を使用して編集します。
Serviceの検証結果は、(d)を使って関連付けをします。 - 出力メッセージ情報として編集
実装(ポイントの抜粋)
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
を出力先とする場合は null
、h: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です。
「利用者の新規登録」を選択して
何も入力しないで「確認する」を押したら、入力必須のメッセージが 項目の横に出力されます。
入力必須のメッセージはViewで定義しているValidationです。
一旦、一覧に戻って 今後は 一番上の行の「変更」を押して 既に存在するメールアドレスである「bbbbbb@example.com」を入力すると、メールアドレスが既に登録されている旨のメッセージが項目の横に出力されます。 このエラーは、変更における事前条件不正として定義しているものです。
Code
vermeer_etc / jsf-ddd / source / — Bitbucket
参考情報
デザインの参考として以下のプロジェクトを参照させていただきました。
さいごに
結構、面倒なことをしたとは思いますが ドメインに関する実装に対して、必要最低限の追記のみで実現できたので及第点かな と思っています。
ただし、このやり方では まだ ui:repeat
のような繰り返しがあった場合については対応が出来ていません。
この手の繰り返し関連は、具体的な要件をもって検討したいため、現状 全般的に 保留にしています。
大体は これで良いのですが、h:message
もh:messages
も xhtmlにない場合の制御が不足しています。
これはブログに残すか分かりませんが、対応としては 実行時例外からエラー画面へ遷移する というような実装をしようと考えています。
JSF面倒だねって思われると辛いのですが、クライアントIDの編集が必要なのはJSFならではかもしれませんが Serviceの情報を踏まえてクライアントの項目に関連付けるというのは、多分ですけど JSF以外のフレームワークでも そこそこ手間はかかると思っていたりします。実際は どうなんでしょうね。