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

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

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

さいごに

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