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

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

JSFのConversationScopedをフォルダでBegin・End

vermeer.hatenablog.jp

この記事で、やろうと思っていたことの お試し実装。

やりたいこと

  • あるページから遷移したときに、遷移前と遷移後のフォルダが異なる場合、ConversationをBeginする。
    フォルダから離脱するときにConversationをEndする。

  • Conversationが開始ページを必ずindex.xhtmlに強制する。

  • そして、その作法について極力意識しないような仕組みを設けたい。

使用方法

xhtml

会話スコープの制御対象となるページのxhtmlに以下の情報を追記します。

<f:metadata>
    <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" />
</f:metadata>

Controller

アクションに関する実装です。

ManagedBeanだと要素と含めて1つにすることが多いと思いますが、私はアクションとFormを分けています。

@Controller@Actionを記述します。

@Typed(CaseOneAction.class)
@Controller
@Action
public class CaseOneAction implements Serializable {

    private static final long serialVersionUID = 1L;

    private final CaseOneForm form;

    @Inject
    public CaseOneAction(CaseOneForm form) {
        this.form = form;
    }

    public void countUp() {
        this.form.countUp();
    }

    public String fwSecond() {
        return "second.xhtml";
    }

    public String fwIndex() {
        return "index.xhtml";
    }

    public String fwRootIndex() {
        return "/index.xhtml";
    }

    public String fwOtherCase() {
        return "/case2/index.xhtml";
    }

    public String fwOtherCaseNotIndex() {
        return "/case2/second.xhtml";
    }

    public String fwReadOnly() {
        return "/readonly/index.xhtml";
    }

}
@Stereotype
@Named
@RequestScoped
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface Controller {
}

スコープとNamedを まとめただけのアノテーションです。 @InterceptorBindingと記述していますが、今回は使用していません。
今回は使用していませんが、例えばログ出力などをAOPで実現したいときに使用するイメージです。
スコープはRequestScopedで、状態を持たないインスタンスとしています。

@Dependent
@Target(value = {ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface Action {
}

アクションに関するメソッドのインターセプターのためのアノテーションです。
このアノテーションをConversation制御に使用します。

Form

Formに関する実装です。

ManagedBeanだと要素と含めて1つにすることが多いと思いますが、私はアクションとFormを分けています。

@ViewForm
public class CaseOneForm implements Serializable {

    private static final long serialVersionUID = 1L;

    private Integer count;

    @PostConstruct
    void init() {
        this.count = 0;
        System.out.println(this.getClass().toString() + "::生成");
    }

    @PreDestroy
    void tearDown() {
        System.out.println(this.getClass().toString() + "::破棄");
    }

    void countUp() {
        this.count++;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }

}
@Stereotype
@Named
@ConversationScoped
@Target(value = {ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@InterceptorBinding
public @interface ViewForm {

}

スコープとNamedを まとめただけのアノテーションです。 @InterceptorBindingと記述していますが、今回は使用していません。
今回は使用していませんが、例えばログ出力などをAOPで実現したいときに使用するイメージです。
スコープはConversationScopedで、状態を持つインスタンスとしています。
会話スコープ内の異なる画面で共通して使用したい情報がある場合、状態を保持しているので DIするだけ使用できます。

実行結果

全てではありませんが、いくつかの実行結果を示すことで意図した実装になっていることを確認したいと思います。

会話スコープの開始と終わり

開始画面。
なにもインスタンス化されていません。

f:id:vermeer-1977:20180503083345p:plain

会話スコープありの画面に遷移したところで、Formがインスタンス化されています。

f:id:vermeer-1977:20180503084005p:plain

f:id:vermeer-1977:20180503084039p:plain

CountUpをしても、インスタンスは破棄されません。

f:id:vermeer-1977:20180503084201p:plain

f:id:vermeer-1977:20180503084039p:plain

CaseTwo画面に遷移すると、CaseOneのFormインスタンスが破棄されて CaseTwoのFormインスタンスが生成されます。
CaseOneの会話スコープも終了しています。

f:id:vermeer-1977:20180503101232p:plain

f:id:vermeer-1977:20180503084255p:plain

会話スコープで情報共有

CountUpを連打して、カウンターの数値が増えることを確認します。

f:id:vermeer-1977:20180503084616p:plain

「to Second」で次の画面に遷移すると、値を引き継いでいることが確認できます。

f:id:vermeer-1977:20180503084800p:plain

Second画面で、さらにCountUpを連打します。

f:id:vermeer-1977:20180503084944p:plain

始めの画面に戻っても値を保持していることが確認できます。

f:id:vermeer-1977:20180503085039p:plain

この間、会話スコープもFormも維持されたままです。

f:id:vermeer-1977:20180503085108p:plain

index.xhtmlへの強制

会話スコープが開始していないフォルダのxhtmlに遷移しようとして、しかも対象がindex.xhtml以外である場合は、遷移先のフォルダのindex.xhtmlへ強制的に遷移して会話スコープも開始します。

f:id:vermeer-1977:20180503092634p:plain

「to Case Two Not Index」を押下して、index.xhtml以外の画面に遷移しようとしても、Case2のindex.xhtmlに遷移します。

f:id:vermeer-1977:20180503093011p:plain

f:id:vermeer-1977:20180503093026p:plain

非会話スコープ(読み取り専用)

xhtmlに以下を追記していなければ、その画面は読み取り専用(RequestScope)になります。

<f:metadata>
    <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" />
</f:metadata>

遷移前

f:id:vermeer-1977:20180503090244p:plain

「to ReadOnly」を押下して、読み取り専用画面へ遷移します。
CaseOneのFormインスタンスは破棄されますし、ReadOnlyFormインスタンスも画面表示後には破棄されます。
会話スコープも開始されていないことも確認できます。

f:id:vermeer-1977:20180503090348p:plain

f:id:vermeer-1977:20180503090417p:plain

CountUpを連打すると、現在の画面情報として表示される「1」だけが残って、サーバー側のFormインスタンスはCountUp連打都度 生成と破棄を繰り返します。

f:id:vermeer-1977:20180503090641p:plain

f:id:vermeer-1977:20180503090713p:plain

実装と仕組み

Conversation Start

開始させるだけだったら

this.conversation.begin();

をロジック上で実行すれば可能です。

ただ会話スコープのアクション全てに それを実装するのは 正直面倒です。

できれば、@Transactionのように@Actionで全ての制御が出来れば良かったのですが、Conversationはクライアント側に保持している情報を使用するため どうしても xhtmlにトリガーを実装せざるを得ませんでした。

ということで、会話スコープ対象となるxhtmlに以下を追記しておくことで、会話スコープの開始制御を追加します。

<f:metadata>
    <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" />
</f:metadata>

f:viewActionが画面初期表示時に実行する処理を記述しています。

そして、実際の開始メソッドが以下です。

public String startAndForwardIndexPage() {
    String currentViewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();
    if (this.conversation.isTransient() == false) {
        return currentViewId;
    }

    System.out.println("Conversation :::::start:::::");

    this.conversation.begin();
    this.conversation.setTimeout(300000);

    if (currentViewId.contains("index.xhtml") == false) {
        return "index.xhtml";
    }
    return currentViewId;
}

戻り値は遷移先の画面IDだということを踏まえた制御と会話スコープを開始しているだけです。

会話スコープを開始させるだけだったら、voidでも良いと思います。

Conversation End

会話スコープを終わらせることで色々と面倒なのは どこで終了をするのか?というところです。

ということで、私は「xhtmlを会話スコープ毎に まとめて フォルダが変わったところで会話スコープを終わらせる」という整理をしました。

会話に関する視認性も良いと思いますし、ルールとして単純だと思います。

  • 終了メソッド
public void endConversation(String currentViewId, String resultViewId) {
    if (shouldConversationEnd(currentViewId, resultViewId)) {
        this.conversation.end();
        System.out.println("Conversation ::::end::::");
    }
}

public String conversationId() {
    return this.conversation.getId();
}

//
boolean shouldConversationEnd(String currentViewId, String resultViewId) {
    if (this.conversation.isTransient()) {
        return false;
    }
    String startViewFolder = uriFolderPath(currentViewId);
    String resultViewFolder = uriFolderPath(resultViewId);

    if (Objects.equals(startViewFolder, resultViewFolder)) {
        return false;
    }
    return Objects.equals(resultViewFolder, "") == false;
}

//
String uriFolderPath(String viewId) {
    String[] urlPaths = viewId.split("/");
    String viewItem = urlPaths[urlPaths.length - 1];
    int folderPathSize = viewId.length() - viewItem.length();
    return viewId.substring(0, folderPathSize);
}
  • 呼び元であるInterceptorの実装
@AroundInvoke
public Object invoke(InvocationContext ic) throws Exception {
    String currentViewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();
    Object resultViewId = ic.proceed();
    this.conversationLifecycleManager.endConversation(currentViewId, (String) resultViewId);
    return resultViewId;
}

処理の実行前後の画面IDを参照するところがポイントでしょうか。

試してみて上手く行かなかったメモ

同じようなことをしたり、未来の自分が「こういうやり方だったら いけるかもしれない」と思ったときのためのメモ

覚えている限り

InterceptorでBegin

会話が未開始の画面から遷移したときには、良い感じにBeginしてくれます。
問題は会話中の画面から別のフォルダに遷移したときです。
Interceptor内でEndして、Startすると、、Conversatinが同一IDになってしまいます。

おそらくですが、ConversationIdはクライアントとの往復をもって実現しているため、同一フェーズでOff・Onしても「終わったこと」になっていなくて、新しいConversationIdを採番しないためだと考えられます。

ということで、望むタイミングでConversationをBeginするためには、xhtmlの初期表示タイミングでクライアントから明示的に実行するしかないと思います。

Filterで実現

ちょっと忘れてしまったところもあるけれど、いずれにしても上述のInterceptorと同じように、OnとOffの制御が思ったように出来なかったです。

BeginかEndのどちらかだけだったら、出来なくは無かったけど*1、 結局、index.xhtmlへの開始ページ強制をやろうとしたらFacesContext.getCurrentInstance().getViewRoot().getViewId()を使用したいということもあってFilterだと都合が悪かったりしたので止めました。

諦めたこと

  • ConversationScopedの開始位置の制限
    index.xhtmlを基本として、できれば任意のページを開始位置として指定できるようにもしたいかも。
    ただ現状 フォルダ内の任意のページを、しかも複数のページを開始位置とするケースが思いつかないので、index.xhtmlだけにしています。

  • ブラウザの戻る または 閉じる*2
    これは不可能というか、サーバー側で検知させるための実装が独自に必要ということで諦めます。
    ブラウザのHistoryに対して サーバー側と同等*3の判定を行えば、ある程度 ハンドリングできそうな気もしなくは無いですが、現時点で その知見を収集もしていないので 少なくともConversationのTimeoutもしくは、セッション終了時に破棄してくれるということで諦めました。

Code

Bitbucket

さいごに

「これならできるかな?」「あれならどうだろう?」と色々とやっていたので、最終結果に至るまでに紆余曲折。

パッケージ構成については、見直しの余地があるけれど、とりあえず目的としている実装の目途はついた気がします。

Controllerについては、私の実装はアクション指向風にしています。 JSFのManagedBeanだとコンポーネント指向ってことで、要素と振る舞いを1つにしたクラスで実装する事例が多いと思いますが、Controller(Action)という振る舞いにステートフルスコープを持たせるのは どうかなぁと。
私の設計思想としては状態を保持する主体(ここだとForm)がステートフルかステートレスか を管理すべきかな、と そんな風に考えました。
なお、DIしているから実質同じじゃない?という指摘は ごもっともだと思います。

後から気が付いた事

会話終了を任意で指定*4

必ずフォルダを離脱しないとスコープが終わらない、という仕様だと 同じフローを繰り返し行いたいときに困ることが分かりました。例えば@EndConversationを付与したメソッドは実行時に会話を終了させる、というようなことをすると良さそうです。

会話開始の遷移先でリダイレクトをしていない*5

これはバグです。

リダイレクトで遷移をしていないと、初回表示時点で会話が始まっていません。サーバー側のログだけ見て開始されていると思い込んでいましたが、ブラウザのアドレスを見たらパラメータが出力されていないので、意図した挙動になっていませんでした。

出ていないけど大丈夫だろう、と勝手に思い込んでいましたが 偶然 F5(更新)をしたときに、ログを見ていて 新たにFormのインスタンスが生成されていることで気が付きました。

startAndForwardIndexPageの遷移先を指定しているところは、
index.xhtmlではなくてindex.xhtml?faces-redirect=true
return currentViewIdではなくてreturn currentViewId + "?faces-redirect=true";
にすべきでした。

*1:もうお試し実装を無くしてしまった

*2:2018/5/12 追記

*3:もしくは それ以上の

*4:2018/5/12 追記

*5:2018/5/12 追記