この記事で、やろうと思っていたことの お試し実装。
やりたいこと
あるページから遷移したときに、遷移前と遷移後のフォルダが異なる場合、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するだけ使用できます。
実行結果
全てではありませんが、いくつかの実行結果を示すことで意図した実装になっていることを確認したいと思います。
会話スコープの開始と終わり
開始画面。
なにもインスタンス化されていません。
会話スコープありの画面に遷移したところで、Formがインスタンス化されています。
CountUpをしても、インスタンスは破棄されません。
CaseTwo画面に遷移すると、CaseOneのFormインスタンスが破棄されて CaseTwoのFormインスタンスが生成されます。
CaseOneの会話スコープも終了しています。
会話スコープで情報共有
CountUpを連打して、カウンターの数値が増えることを確認します。
「to Second」で次の画面に遷移すると、値を引き継いでいることが確認できます。
Second画面で、さらにCountUpを連打します。
始めの画面に戻っても値を保持していることが確認できます。
この間、会話スコープもFormも維持されたままです。
index.xhtmlへの強制
会話スコープが開始していないフォルダのxhtmlに遷移しようとして、しかも対象がindex.xhtml
以外である場合は、遷移先のフォルダのindex.xhtml
へ強制的に遷移して会話スコープも開始します。
「to Case Two Not Index」を押下して、index.xhtml
以外の画面に遷移しようとしても、Case2のindex.xhtml
に遷移します。
非会話スコープ(読み取り専用)
xhtmlに以下を追記していなければ、その画面は読み取り専用(RequestScope)になります。
<f:metadata> <f:viewAction action="#{conversationLifecycleManager.startAndForwardIndexPage()}" /> </f:metadata>
遷移前
「to ReadOnly」を押下して、読み取り専用画面へ遷移します。
CaseOneのFormインスタンスは破棄されますし、ReadOnlyFormインスタンスも画面表示後には破棄されます。
会話スコープも開始されていないことも確認できます。
CountUpを連打すると、現在の画面情報として表示される「1」だけが残って、サーバー側のFormインスタンスはCountUp連打都度 生成と破棄を繰り返します。
実装と仕組み
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
さいごに
「これならできるかな?」「あれならどうだろう?」と色々とやっていたので、最終結果に至るまでに紆余曲折。
パッケージ構成については、見直しの余地があるけれど、とりあえず目的としている実装の目途はついた気がします。
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";
にすべきでした。