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

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

BusyConversationExceptionからの画面遷移

vermeer.hatenablog.jp

の続きです。

前回は、強制的に会話スコープの開始ページに遷移するという仕様としましたが、正直 これは 私の求めている機能ではありません。
それぞれの例外に対して、目指す振る舞い(できる処理)が異なるので まずは BusyConversationExceptionから考えてみます。

やりたいこと

BusyConversationExceptionのトリガーとなった画面へ遷移する(つまり自画面へ遷移させる)

BusyConversationExceptionは、元となるConversationScopeのインスタンスが破棄されていません。 したがって、そのまま cidをつけたリクエストを送信すれば、前の状態を取得する事が出来ます。

ただ、2回目の操作が無効になっただけです。

状態管理は不要

2重Submitのような、操作による誤ったデータ登録への考慮ですが、この制御で扱うものではありません
データの重複チェックや、2重Submit対策として 別に行います。
そして、そうあるべきであると考えます。

メッセージはデフォルトだけでなく独自指定も

デフォルトのメッセージは、Message.propertiesにキーを javax.enterprise.context.BusyConversationExceptionとして記述しておきます。
基本的に「処理中に 操作してはいけませんよ」ということを伝えるだけなので、個々のユースケースで出力を変えることはないかもしれませんが、「登録」と言う表現が妥当な場合もあれば、「保存」や「処理」が必要なケースは容易に想像できます。
したがって、ユースケースに応じたメッセージも出力できるようにします。
基本的には会話中の全ての操作に対して適用する文言であれば良いと思いますので、会話の開始ページである index.xhtml のアクションを実装したクラスに アノテーションを付与すれば十分でしょう。 といいつつ、メソッド毎にも変更できるようにもしておきます。

メッセージ実装はアノテーション

メッセージは 業務の主たる関心事ではないので宣言的なアノテーションによる表現が適当であると考えます。

実装

例外補足

ServletFilerで例外を捕捉するのは前回の同じです。
例外種類によって処理が違うところが前回との違いです。

public class ConversationExceptionFilter implements Filter {

     (略)

    /**
     * {@inheritDoc }
     */
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } catch (NonexistentConversationException ex) {

     (略)

        } catch (BusyConversationException ex) {
            HttpServletRequest httpServletRequest = (HttpServletRequest) request;

            String servletPath = httpServletRequest.getServletPath();
            request.setAttribute(ConversationExceptionKey.FORWARD_PAGE, servletPath);

            String cid = httpServletRequest.getParameter("cid");
            request.setAttribute(ConversationExceptionKey.CONVERSATION_ID, cid);

            request.setAttribute(ConversationExceptionKey.EXCEPTION, ConversationExceptionValue.BUSY);

            httpServletRequest.getRequestDispatcher("/parts/conversation/busy-conversation.jsp").forward(request, response);

        }
    }

     (略)

操作をした画面への遷移

遷移制御をするJSP
JSPを使う理由は前回のブログの通りです。
ポイントは、cidを次の遷移のために持ちまわるというところです。

こうすることで、未終了のConversationScopedのインスタンスを使用できます。

busy-conversation.jsp

<%@page contentType="text/html" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<%@ page import="spec.scope.conversation.ConversationExceptionKey"%>

<%
    String forwardPage = (String) request.getAttribute(ConversationExceptionKey.FORWARD_PAGE);
    String cid = (String) request.getAttribute(ConversationExceptionKey.CONVERSATION_ID);
    String ex = (String) request.getAttribute(ConversationExceptionKey.EXCEPTION);
%>
<c:redirect url="<%=forwardPage%>">
    <c:param name="cid" value="<%=cid%>"/>
    <c:param name="<%=ConversationExceptionKey.EXCEPTION%>" value="<%=ex%>"/>
</c:redirect>

メッセージ出力

画面共通のテンプレートに、メッセージ出力ための リスナーを指定しておきます。
viewActionが使えない理由は以下のブログの通りです。

ConversationScopedを扱うにあたっての課題 - システム開発で思うところ

BusyConversationExceptionのルートで関係あるのは
変数requestParameterExceptionです。
例外の分類ですが、Flashによるデータ共有方式では f:viewParamによって情報を持ちまわる必要があるため使えません。 共通テンプレートでは リスナーしか使えないので、直接 URLパラメータから取得することにしました。
逆に言えば、例外種類の状態把握としては、Flashに遷移先を指定しないルートともいえます。この違いを利用してどちらの例外処理(BusyConversationなのか NonexistentConversationなのか)の判定にも使っています。

共通テンプレート(baseLayer.xhtml

<?xml version='1.0' encoding='UTF-8' ?>
<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:ui="http://java.sun.com/jsf/facelets"
      xmlns:h="http://java.sun.com/jsf/html"
      xmlns:f="http://xmlns.jcp.org/jsf/core"
      >

    <h:head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
        <h:outputStylesheet library="css" name="base.css" />
        <h:outputStylesheet library="css" name="validation.css" />
        <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/semantic-ui/2.2.10/semantic.min.css" />

        <f:event type="postAddToView" listener="#{conversationLifecycleManager.startConversation()}" />
        <f:event type="preRenderView" listener="#{conversationExceptionHandler.writeMessage()}" />
    </h:head>

    <h:body>
        <div id="page-content">
            <ui:insert name="content">Content</ui:insert>
        </div>
        <div id="page-bottom">
            <ui:include src="./bottom.xhtml"/>
        </div>
    </h:body>

</html>
@Named
@RequestScoped
public class ConversationExceptionHandler {

  (略)

    @PostConstruct
    public void init() {
        externalContext = FacesContext.getCurrentInstance().getExternalContext();
    }

    public void writeMessage() {
        String flashException = (String) externalContext.getFlash().get(ConversationExceptionKey.EXCEPTION);
        String requestParameterException = externalContext.getRequestParameterMap().get(ConversationExceptionKey.EXCEPTION);

        if (busyConversationMessageHandler.isBusyConversationException(flashException, requestParameterException) == false
            && nonExistentConversationMessageHandler.isNonExistentConversation(flashException) == false) {
            return;
        }

        if (busyConversationMessageHandler.isBusyConversationException(flashException, requestParameterException)) {
            this.busyConversationMessageHandler.write();
        }

        if (nonExistentConversationMessageHandler.isNonExistentConversation(flashException)) {
            this.nonExistentConversationMessageHandler
                    .write((String) externalContext.getFlash().get(ConversationExceptionKey.FROM_PATH));
        }

        // 一度だけメッセージ出力をするために 共通メソッドで trueにしている設定を falseで上書きします
        externalContext.getFlash().setKeepMessages(false);
    }

  (略)

}

実際の判定や 出力をしているクラス。
先のクラスのメソッドとして実装しても良かったのですが、クラスとして切り出した方が 役割が分かりやすいと思って 別クラスにしました。

@RequestScoped
public class BusyConversationMessageHandler {

    private BusyConversationMessageManager busyConversationMessageManager;

    private MessageConverter messageConverter;
    private MessageWriter messageWriter;
    private Conversation conversation;

    public BusyConversationMessageHandler() {
    }

    @Inject
    public BusyConversationMessageHandler(BusyConversationMessageManager busyConversationMessageManager,
                                          MessageConverter messageConverter, MessageWriter messageWriter, Conversation conversation) {
        this.busyConversationMessageManager = busyConversationMessageManager;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.conversation = conversation;
    }

    public boolean isBusyConversationException(String flashException, String requestParameterException) {
        return (flashException == null || flashException.equals(""))
               && (requestParameterException != null && requestParameterException.equals(ConversationExceptionValue.BUSY))
               && this.conversation.isTransient() == false;
    }

    public void write() {
        String message = messageConverter.toMessage(this.busyConversationMessageManager.getMessage());
        messageWriter.appendErrorMessage(message);
    }

}

独自メッセージ

独自メッセージは、@BusyConversationMessageをアクションクラスに実装します。
メッセージは、事前に保持しておいて、BusyConversationExceptionが捕捉されたら使います。

インターセプター

@BusyConversationMessage
@Interceptor
@Priority(Interceptor.Priority.APPLICATION + 10)
public class BusyConversationMessageInterceptor {

    private final BusyConversationMessageManager busyConversationManager;

    @Inject
    public BusyConversationMessageInterceptor(BusyConversationMessageManager busyConversationManager) {
        this.busyConversationManager = busyConversationManager;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {
        BusyConversationMessage classMessage = ic.getTarget().getClass().getSuperclass().getAnnotation(BusyConversationMessage.class);
        BusyConversationMessage methodMessage = ic.getMethod().getAnnotation(BusyConversationMessage.class);

        this.busyConversationManager.message(classMessage, methodMessage);
        return ic.proceed();
    }
}

実際にメッセージを保持しておくクラス。

@ConversationScoped
public class BusyConversationMessageManager implements Serializable {

    private static final long serialVersionUID = 1L;

    private String message;
    private String defaultMessage;

    @PostConstruct
    void init() {
        defaultMessage = BusyConversationException.class.getName();
        this.message = defaultMessage;
    }

    public String getMessage() {
        return message;
    }

    public void message(BusyConversationMessage classMessage, BusyConversationMessage methodMessage) {
        String _message = methodMessage != null
                          ? methodMessage.value()
                          : classMessage != null
                            ? classMessage.value()
                            : "";

        if (_message.equals("")) {
            return;
        }
        this.message = _message;
    }
}


メッセージを指定する場合の実装例は、以下。

クラス全体に適用する場合は

@Controller
@BusyConversationMessage("処理中に操作をしないでください。(クラス全体のアクションに適用)")
public class UserRegistrationAction {


特定のメソッドだけに適用したい場合は

@Controller
public class UserRegistrationAction {

    @BusyConversationMessage("登録画面への処理中に操作をしたので中断しました。(対象メソッドのアクションに適用)")
    public String fwPersist() {
        this.registrationPage.init();
        return "persist-edit.xhtml";
    }

    @BusyConversationMessage("登録確認への処理中に操作をしたので中断しました。(対象メソッドのアクションに適用)")
    public String confirm() {
        User requestUser = this.registrationPage.toUser();
        registerUser.validatePreCondition(requestUser);
        return "persist-confirm.xhtml";
    }



両方に実装した場合は、メソッドで適用したメッセージを優先するようにしています。

@Controller
@BusyConversationMessage("処理中に操作をしないでください。(クラスの全アクションに適用)")
public class UserRegistrationAction {

    @BusyConversationMessage("登録画面への処理中に操作をしたので中断しました。(メソッド指定の方を優先します)")
    public String fwPersist() {
        this.registrationPage.init();
        return "persist-edit.xhtml";
    }

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

トリッキーな実装も一部あるように思いますが、対応途中は もっとひどかったです。
2重サブミットも併せて考慮しないといけないんじゃないか?とか思って 色々な状態管理をして…、みたいな。
最終的に この例外管理で 主にやるべき責務って なんだろう? と考え直す工程を 一旦設けたことで、それまでの考えすぎと言うか 盛り込みすぎだったところが 見えてきて、個人的には 満足しています。
最終的に作り切ったもの振り返ると、大したことをしていないように見えますが、正直 ここに至るまでに とても時間を使ってしまいました。
次は、コードは全く同じで、NonexistentConversationExceptionからの画面遷移について書こうと思います。