vermeer.hatenablog.jp
の続き。
出力順序とは別モノですが メッセージを指定の場所に出力するというのをやりたいと思います。
おそらく、ですが 一般的な JSFにおける Valisationと 対象項目に対してメッセージを出力するための流れとしては
- 入力項目のidと
h:message
のfor
を一致させて関連をつけておく
- xhtmlでvalidateを実行するか、Ajaxでvalidateして結果を返却する
という感じだと思います。
具体的には、以下の記事のような感じです。
JSFのカスタムバリデータでメッセージを表示する時は、メッセージにSEVERITY_ERRORを設定しないとh:messageのerrorClassは適用されません。 - Qiita
JSFのメッセージのレンダリング
メッセージとは違いますが、validationと項目との関連付けと言う意味では
JSFでエラーのある項目の背景色を変える - じゃばらの手記
JSFでエラー項目の背景色を変える - 中年プログラマーの息抜き
というのも近しいことかもしれません。
ツールチップについては、今後 改めて考えるつもりですが、ここでは xhtmlがポイントになるんだなというのが 何となく伝われば十分です。
さて、今回の目的である
メッセージを指定の場所に出力する ですが、そもそも私のこれまでの実装方式では発想が全く異なります。*1
また、Serviceにおける検証不正の結果を、Viewに反映させようとした場合、トリガーを xhtmlにするというのは難しいですし、あまりスッキリとした感じもしません。
実際にできるかどうかも分かりませんが 例えば
- Serviceの検証不正を捕捉する
- 補足した結果を xhtmlに反映させる
- xhtmlから検証不正のvalidateに相当するアクションを行う
- 最終的な結果の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;
}
@Override
public String display() {
return this.getValue().getValue();
}
@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
= ViewContextScanner
.of(ic.getTarget().getClass().getSuperclass())
.messageMappingInfosNotYetReplaceClientId();
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
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>
@return
public MessageMappingInfos messageMappingInfosNotYetReplaceClientId() {
Field[] fields = actionClass.getDeclaredFields();
for (Field field : fields) {
ViewContext viewContext = field.getAnnotation(ViewContext.class);
if (viewContext == null) {
continue;
}
resursiveAppendField(field.getType(), field.getType().getCanonicalName());
}
return messageMappingInfos;
}
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) {
TargetClientIds targetClientIds = this.scanTargetClientIds(
FacesContext.getCurrentInstance().getViewRoot().getChildren(), 0, new TargetClientIds());
MessageMappingInfos messageMappingInfos
= messageMappingInfosNotYetReplaceClientId.replacedClientIds(targetClientIds);
ConstraintViolationForMessages constraintViolationForMessages = PresentationConstraintViolationForMessages
.of(constraintViolationSet, targetClientIds)
.toConstraintViolationForMessages();
return constraintViolationForMessages
.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" jsfid="email" jsfvalue="#{userRegistrationPage.email}"/>
<hmessage 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 {
(略)
項目名であるIDからクライアントID(フルパス)に置き換えた、新たなインスタンスを返却します.
<p>
TODO
{@link TargetClientIds}<br>
@param targetClientIds
@return
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;
}
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>{@code for}</li>
</ul>
@author
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>
@param clazz
@param path
@return{@code null}
private String toId(Class<?> clazz, String path) {
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
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())
);
}
クライアントIDとメッセージの組み合わせた情報に変換した情報を返却します.
<p>
@param function
@return
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
@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);
facesContext.getExternalContext().getFlash().setKeepMessages(true);
}
}
実行結果
トップページ
下の「JSF DDD 入力項目の横にメッセージ」が今回新しく追加したVIewです。
「利用者の新規登録」を選択して
何も入力しないで「確認する」を押したら、入力必須のメッセージが 項目の横に出力されます。
入力必須のメッセージはViewで定義しているValidationです。
一旦、一覧に戻って 今後は 一番上の行の「変更」を押して 既に存在するメールアドレスである「bbbbbb@example.com」を入力すると、メールアドレスが既に登録されている旨のメッセージが項目の横に出力されます。
このエラーは、変更における事前条件不正として定義しているものです。
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:message
もh:messages
も xhtmlにない場合の制御が不足しています。
これはブログに残すか分かりませんが、対応としては 実行時例外からエラー画面へ遷移する というような実装をしようと考えています。
JSF面倒だねって思われると辛いのですが、クライアントIDの編集が必要なのはJSFならではかもしれませんが Serviceの情報を踏まえてクライアントの項目に関連付けるというのは、多分ですけど JSF以外のフレームワークでも そこそこ手間はかかると思っていたりします。実際は どうなんでしょうね。