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

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

プログラムから文字列指定を無くす

メッセージをEnumにすることでタイプセーフにする仕組みを以前作りました。

vermeer.hatenablog.jp

これと同じように、色々な要素から「文字列で直接指定している」というところを、ちょっとずつ無くしていきたいと思っていて そのためのネタを整理です。

できるか、できないか、どこまで必要そうか、については 実際に取り組む際に改めて検討するので、とにかく「文字列」を直接使っているものを 洗い出してみました。

入力補完する、検証だけして直すのは自分でやる、など具体的なやり方は全く決めていないです。

気が付いたときに、追加したり削除したりしようかぁ、という感じです。

xhtmlのID

xhtml などViewで使用するID

xhtmlのラベル

表示項目。
国際化対応を鑑みてプロパティ化はしたい。

xhtmlのパス

遷移先のパスとなるxhtml

Validation用のメッセージ

EL式をベースにしたBeanValidation用のもの。

JPA Entityのプロパティ

setParameterのKey値

JPAのNamedのQuery名

NamedのQuery名。

NetBeansだったら 自動補完するから要らないかもしれないけど、明確にコンパイルエラーにするのなら準備した方がいいかな。

JPAのプロパティ名

Queryに使用するプロパティ名

SQLファイルパス

NativeQueryのSQLとして使用しているSQLファイルのパス。 (これは、独自に参照用のライブラリを作成したもののファイルパス)

環境変数

これは別扱いで整理

JSFで2重Submit対策(続)

前回やっていないこととした「入力画面まで戻ったFormの更新抑止」について対応をしてみました。

vermeer.hatenablog.jp

やっていないことで2つ目に挙げた案である

immediate を使うやり方です。

出来れば xhtmlに手を加えないやり方の方が良いかなぁ、と思っていたのですが 実際に記述してみて

  • まぁ、これくらいなら そこまで手が込んでいるわけではない

  • immediateの挙動は 基本的なJSFの仕様の範疇なので何が起こるのか想像しやすい

ということで、このやり方で良いだろう、と落ち着きました。

使用方法

情報を反映したくないアクションのトリガーに

immediate="#{doubleSubmitLifecycle.submitted}"

を記述します。

<p><input type="submit" value="確認"  jsf:action="#{doubleSubmitAction.fwConfirm()}" jsf:immediate="#{doubleSubmitLifecycle.submitted}"/></p>

実装

前回の時点で、すでに当該メソッドがありました。

public boolean isSubmitted() {
    return this.doubleSubmitState.isSubmitted();
}

実行結果

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

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

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

「戻る」→「戻る」で、入力画面まで戻ってユーザー名を更新します。

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

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

確認画面で、ユーザー名が更新されていないことが確認できました。

Code

Bitbucket

さいごに

これくらいなら、前回の記事にまとめても良かったかも。。

JSFで2重Submit対策

一般的な2重Submitは、hiddenで保持したトークン と サーバのセッションで保持しているトークンを比較する、というやり方だと思います。

さいきょうの二重サブミット対策 - Qiita

これは、これで正しいと思いますし、多くの人が知っているであろう やり方なので検索性も良いと思います。

ですが、Jakarta EE には ConversationScoped があります。

画面単位(タブ単位)でイイ感じに情報を管理してくれるスコープがありますので、それを前提としたやり方で試してみたいと思います。

「そもそもConversation自体が面倒なんですが」ということについては、以前の記事の「フォルダ単位で会話スコープを制御する」を使用する前提です。

vermeer.hatenablog.jp

やりたいこと

  • 2重Submit不可のトリガーとなるアクションを操作したら、以降 同アクションを操作したらエラーにする
  • エラー判定後の操作として、メッセージ出力 または 画面遷移先指定(もしくは 2つの組み合わせ)を指定できるようにする
  • デフォルトは「何もしない」
  • アノテーションによって宣言的にする

仕様の補足

エラー判定後の操作

エラー画面への遷移だけでなく、メッセージ出力とした理由は、確認画面をそのまま流用できるようにして実装負担を減らせる仕組みを設けた方が良いだろうと思ったためです。

もちろん、エラーではなく「登録は完了しています」というようなメッセージにしてユーザー自身による2重登録防止にする、というやり方もあるでしょう。

画面遷移は、単純にエラー画面への遷移というだけでなくて f:viewActionで初期表示イベントによる操作が出来るので複雑なことも可能です。

具体的には、f:viewActionでアクションと紐づけをして、ケース分けから自画面とそれ以外の画面への振り分けや、ログ出力やエラー通知などをするイメージです。

アノテーションで宣言的

上述のように、複雑なロジックが必要なことは「遷移先画面の指定」で実現すると割り切って、あくまでアプリケーションの実装としてはアノテーションによる宣言のみです。

当初、細かい操作ができるようにした方が良いかなぁ、と考えて HandlerClassを設けて、それをInjetする方法も考えたのですが 遷移先画面を任意で指定できるようにする 上述のやり方であれば、結果として 好きなように実装できると考えて このやり方で落ち着きました。

また2重Submit対策はドメイン知識とは別の話なので、そういうものは極力「ロジック」に紛れ込まないようにする、つまり宣言的にしておく方が最適だと考えました。

デフォルトの挙動

2回目の操作時に「何もしない」をデフォルトとした理由は、プロダクトによって指針が大きく異なると考えたためです。

つまり、お試し実装の範疇では、固定的な実装はしない、としました。

もし、エラーメッセージを出力することをデフォルトにしたければ、@ExcuteOncemessageのデフォルト値でメッセージIDを指定するなり、ExecuteOnceInterceptorのメッセージ編集で定数値をしていするなりすれば良いでしょう。

エラー画面IDの指定も同様の対応で対処可能です。

方式の補足

状態保持と更新

ExecuteOnceLifecycleでは、内部で状態を保持しています。
その場合のマルチスレッドによる本方式の安全性ですが これは本クラスをConversationScopedとすることで考慮しています。

後発のリクエスト時にjavax.enterprise.context.BusyConversationExceptionがスローされる仕様になっています。(略)スレッドセーフの考慮は不要です。
パーフェクト Java EE P69

とあるように、EEの仕組みとして状態を安全に保持できる仕組みが設けられています。*1

したがって 過度ではないSubmit と ブラウザによる 戻る で発生しうる2重Submitへの対応とは別に、過度なSubmit(極端な連打)による異常は、BusyConversationExceptionとスローするため 不正な登録の実行は発生しません。 *2

会話スコープを任意のタイミングで終了する

上述の過去の記事の実装では、xhtmlのフォルダが変わった時「だけ」がConversationScopeの終了条件でした。

今回のように、同一フローを繰り返したい場合に困ってしまうということで @EndConversationというアノテーションで、任意のタイミングで終了できるようにしました。

これで、入力→確認→完了 をサイクリックに行えるようになります。

ExceptionHandlerWrapper を使わないでも出来た

当初、メソッドに複数のInterceptorが適用される場合、ExecuteOnceInterceptorで対象メソッドを実行しなくても、別のInterceptorによって実行されるかもしれないと考えて、実行時例外によるハンドリングを検討しました。

結論としては、それは杞憂でした。InvocationContext#proceed()によって、Interceptorがchainされるので、優先度上位のInterceptorで InvocationContext#proceed()をcallしなければ、それ以降のInterceptorが適用されることはありませんでした。 *3

使用方法

2重Submitのトリガーとなるアクションに@ExecuteOnceを付与。

以降、@ExecuteOnceが付与されているメソッドは 会話スコープが終了するまで 実行できなくなります。

できることは @ExecuteOnceによるメッセージ制御か 画面遷移のみ です。

各パラメータを使用したパターンについては、実行結果で示します。

@ExecuteOnce
public String fwComplete() {
    return "complete.xhtml";
}

実装と実行画面とログ

2回目の操作を無視

何もしません。

デフォルト時の挙動です。このままだとユーザーは困るので、通常は遷移先画面かメッセージ出力をして、何をしたらよいのか誘導した方が良いでしょう。

@ExecuteOnce
public String fwCompleteWithDefault() {
    System.out.println(this.getClass().toString() + "::登録処理を実施");
    return "complete.xhtml";
}

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

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

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

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

1回目の「登録(何もしない)」では、問題なく 完了画面へ遷移します。

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

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

「戻る」で前の画面に戻ります。

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

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

2回目の「登録(何もしない)」をクリックすると 登録ログは更新されていきますが、画面は一切変化しないことが確認できます。

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

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

2回目の操作で完了画面へ遷移

これが一番オーソドックスだと思います。

2度目以降の入力は無視して、とにかく完了画面へ遷移します。

特にエラーメッセージも出力しません。

@ExecuteOnce(forwardPage = "complete.xhtml")
public String fwComplete() {
    System.out.println(this.getClass().toString() + "::登録処理を実施");
    return "complete.xhtml";
}

<1回目の登録までは省略します>

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

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

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

エラーメッセージあり

エラーメッセージのみを指定した場合、自分画面へ遷移してメッセージを出力します。

画面上に、継続して処理したい場合のルートの情報をメッセージに出力しないとユーザーは不安になるでしょう。

@ExecuteOnce(message = "メッセージ出力:既に登録済みです。重複登録を抑止しました。トップページへ戻ってください。")
public String fwCompleteWithMessage() {
    System.out.println(this.getClass().toString() + "::登録処理を実施");
    return "complete.xhtml";
}

<1回目の登録までは省略します>

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

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

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

エラー画面遷移

本例だと、完了画面とエラー画面の違いは少ないですが、メッセージ出力タグがあったりして少しだけ違いがあります。

完了画面とは別のシーケンスだったことを示したい場合は、メッセージで通知するよりも エラー画面を設けておいた方が 1画面に複数仕様が混在しないので良いかもしれません。

デメリットは、画面レイアウトの変更をした際、完了画面とエラー画面の両方をメンテナンスしないといけないところでしょうか?

この辺りはJSFコンポーネントなり、テンプレートなりを使えば手間は減らせるかもしれません。

@ExecuteOnce(forwardPage = "error.xhtml", message = "エラー画面へ遷移:既に登録済みです。重複登録を抑止しました。新たに別の入力するか、トップページへ戻ってください。")
public String fwError() {
    System.out.println(this.getClass().toString() + "::登録処理を実施");
    return "complete.xhtml";
}

<1回目の登録までは省略します>

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

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

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

会話スコープが終わったらSubmit可能

本記事のテーマからは少しずれていますが、同一フォルダ内の遷移でも メソッドに@EndConversationを付与する事で会話スコープを終了できます。

通常フローの完了画面で「さらに入力」で、入力画面に戻ったら会話スコープが終了した後、新たに開始されます。

この時、同一フォルダの index.xhtmlですが @EndConversation の制御により、処理終了時に会話スコープは終了し、index.xhtmlの初期表示時に新たに会話スコープが開始されています。

これで、入力→確認→完了 のシーケンスをサイクリックに行えます。

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

<完了画面の登録までは省略します>

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

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

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

実装と仕組み

最優先のInterceptorで処理を行うことで、後続のInterceotorによって対象メソッドがcallされないようにします。

Annotation

デフォルト値は、空白とします。

意味としてはNullの方が良いのですが、Nullは設定できないので空白です。

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

    @Nonbinding
    public String message() default "";

    @Nonbinding
    public String forwardPage() default "";
}

Interceptor

2回目以降の状態の場合は、通常フローの画面遷移をしないようにします。

ポイントになりそうなところには コメントを入れました。

@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@ExecuteOnce
public class ExecuteOnceInterceptor {

    private final DoubleSubmitLifecycle doubleSubmitLifecycle;

    @Inject
    public ExecuteOnceInterceptor(DoubleSubmitLifecycle doubleSubmitLifecycle) {
        this.doubleSubmitLifecycle = doubleSubmitLifecycle;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {
        System.out.println(this.getClass().toString() + "::開始");

        try {
            if (this.doubleSubmitLifecycle.isSubmitted()) {
                ExecuteOnce annotation = ic.getMethod().getAnnotation(ExecuteOnce.class);
                return this.toErrorPage(annotation);
            }
            Object result = ic.proceed();
            return result;
        } finally {
            this.doubleSubmitLifecycle.nextState();
            System.out.println(this.getClass().toString() + "::終了");
        }

    }

    //
    Object toErrorPage(ExecuteOnce annotation) {
        System.out.println(this.getClass().toString() + "::2重Submit処理");
        String message = annotation.message();
        if (Objects.equals(message, "") == false) {
            System.out.println("::2重Submit処理のメッセージ::" + message);

            //<h:messages /> にメッセージを出力
            FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(message));

            //リダイレクトしたときにメッセージがクリアされないようにする
            FacesContext.getCurrentInstance().getExternalContext().getFlash().setKeepMessages(true);
        }

        // エラーページを指定していない場合は自画面へ遷移
        String result = Objects.equals(annotation.forwardPage(), "")
                        ? FacesContext.getCurrentInstance().getViewRoot().getViewId()
                        : annotation.forwardPage();

        System.out.println("::2重Submit処理の遷移先::" + result);
        return result + "?faces-redirect=true";
    }

}

今回やっていないこと

入力画面まで戻ったFormの更新抑止

2重Submitであって、入力抑止ではないので 完了画面からブラウザの戻るなどを使って 入力画面まで戻って 入力をすると 確認画面の表示内容も更新されてしまいます。

もし完了画面でも その情報を使って「〇〇で処理しました」というような表示をすると どうでしょうか?

最終的な更新は抑止できていますが、ユーザーからすると「あれ?結局どっちで登録されたの?」という状況になってしまいます。もちろん、確認画面であっても 同じ印象を受けるでしょう。

ここまでを含めて対処しようとすると、例えば 入力画面で使用したFormと確認画面以降で使用するFormを別にして、DoubleSubmitLifecycleの状態を使って複製する、しないという制御をする、ということが案として考えられます。

もしくは、入力から確認への遷移アクションをimmediate=trueにするような仕組みを実装する、という案でも良いかもしれません。

重要な制御であるとは思っていますが、いったん、本記事では「2回登録されないようにする」という意味での2重Submit対策に留めておき 今後 検討したいと思います。

BusyConversationException対策

BusyConversationException(ConversationScopedにおける重複処理時で発生するException)に関する処理は考慮していません。

これについては、また別記事で やりたいと思っています。

理由はBusyConversationExceptionに限らず、例外発生時に行う画面遷移制御について、もう少し自分なりに考えたいと思っているためです。

クライアントのボタン抑止

JavaScriptによる操作抑止はやっていません。

まず、EEの範疇外かなぁというのと、サーバー側の実装がクライアント側の制御について 過度に干渉するのは好ましくないと考えたためです。

勿論、やった方がユーザーには親切です。

いずれにしても、クライアント側の制御はクライアント側で採用したフレームワークに則って対応すると良いと思います。

Ajaxでの2重Submit対策

Ajaxでの2重Submit対策はやっていません。

そもそもAjaxにおける2重Submit対策は、通常のWebアプリの2重Submitとは少し状況が違うように考えています。

少なくともユーザーに不利益を生み出しかねない「注文」や「登録」のようなメインとなるユースケースAjaxでSubmitする方式を適用するのは適切ではないと考えます。

この前提を踏まえつつも、実施する理由があるとしたら どうしても通信量を減らしたい、という要求があった場合でしょうか?

それであっても、確認画面では すでに入力情報はサーバー側で保持しており、クライアントから送るのは「これで良いですよ」という通知となるSubmitだけです。

Formの範囲を狭くさえすれば なんら問題はないと考えます。

いずれにせよ、Ajaxを絡めた制御については、今回のテーマからは一旦除外しておき Ajaxもしくは JSFでクライアントと密に連携をとる事例として必要性を感じたときに試してみたいと思います。

参照情報

2重Submit

JSF2.0でボタンの2度押しチェックをする - 見習いプログラミング日記

JSF2.0のエラーハンドリング - 見習いプログラミング日記

Struts2 - タグ - tokenタグ -2重送信防止 - liguofeng29’s blog

[Java]struts - 機能リファレンス - 2度押し防止トークン【PGBox】

SAStrutsで二重サブミット防止 - メモ

2007-09-05 - 出羽ブログ

Kumu Html Disabled

doOnceメソッドによる二重登録防止 - 出羽ブログ

[TEEDA-393] doOnce~() で二重サブミットされた場合,遷移前の画面に戻って新しいトランザクショントークンが発行されるため,結果的に二重登録が可能となる問題を修正しました. - The Seasar Foundation Issues (Deprecated)

TeedaのdoOnce関連

Teedaの考え方は命名で規約を設けていて、全く同じということはないのですが 沢山の参考知見がありました。

TeedaDoubleSubmittedExceptionをもって色々とハンドリングをしているようで、それを見たので私もExceptionHandlerWrapperを使わないと出来ないのかも、、と思いましたが、とりあえず そこまでしなくて実現はできました。

[Seasar-user:12783] [Teeda]doOnceボタンでDoubleSubmittedExceptionが発生

[Seasar-user:19342] [Teeda] DoubleSubmittedException をキャッチした後の遷移先を振り分けるには?

Code

Bitbucket

さいごに

何が嬉しいの?というところですが、トークンのためのタグをクライアントに実装しなくて良いというところでしょうか?

そもそもとしては 会話スコープのためにタグを追記しているのでは?というツッコミは容易に想像できますが、これは基本的に全てのページに共通して使用するテンプレートに実装するものであって、2重Submit防止制御をするページに都度適用するものとは違うと考えています。

とはいえ、結局のところ コードによる制御であるため 場合によっては見通しは良いかなぁ、とか トークンタグの実装をしなくても良い、くらいかもしれません。

一般的なやり方(タグでクライアントにトークンをhiddenで保持する)であっても、基本ルールを設けてしまえば「そういうものか」ということで特に困ることは無い気もします。

コード内で直接制御するという方法*4のだって別に悪い手だとは思っていません。

まぁ、ConversationScopeのある EE ならではの実装例 という意義くらいかもしれませんが、こういう やり方もありますよ、というところでしょうか。

とりあえず、いつものように右往左往しつつ 出来上がったものをみたら「あれ?こんなもん?」という感じですが、個人的にはアプリケーションコードとして、アノテーションによる宣言的な実装で 目的を達成できる方式が試せたので満足です。

*1:正しくは状態不正を発生させない仕組み、ですが

*2:ただし、BusyConversationException に関する対応は 本記事の対象外です

*3:あやうく、ExceptionHandlerWrapperによる制御をしなくてはいけないかと思って「やばい、想像以上に面倒な方式を選択したかも・・」と思ったのですが、とりあえずInterceptorで実装してみて確認しようと思ってよかったです

*4:Strutsのような

Rest(WebAPI)のメモ

API 設計ガイダンス - Best practices for cloud applications | Microsoft Docs

Zalando RESTful API と イベントスキーマのガイドライン

翻訳: WebAPI 設計のベストプラクティス - Qiita

RFC 7239 - Forwarded HTTP Extension

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";
にすべきでした。

viewActionを使うやり方は良くない*6

vermeer.hatenablog.jp

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

*2:2018/5/12 追記

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

*4:2018/5/12 追記

*5:2018/5/12 追記

*6:2018/12/9 追記

JSFで自動Redirect

JSFをちょっと便利にする仕組み

はじめに

JSFで遷移先を指定するときに、return xxx.xhtml と記述します。

リダイレクトしたい場合は

return xxx.xhtml?faces-redirect=true

と記述します。

というか、リダイレクトしないことは皆無に近しいので、必ず記述します。

毎回追記するのも面倒だし、typoした場合、実行時例外になるので 正直面倒です。

定数にして、必ず追記するように、というのも方法としてありますが、とにかく「約束事の記述」は そもそも減らしたいです。

対応方法

Interceptorを使って、アクションの戻り値(つまり遷移先のViewID)に強制的に付与します。

Annotation

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

}

Interceptor

@Priorityの値を大きく(つまり優先度を低く)しているのは、各Interceptorの実行後で一番初めに処理してほしいからです。
つまり遷移後の状態を、他のInterceptorの実行前にリダイレクトとなるように処置しておくことで 画面遷移周りの挙動の安定を図ろうという算段です。 *1

工夫したところとしては、遷移後の画面IDに単純に?faces-redirect=trueを付与するだけだと、 自画面遷移として戻り値をvoidにしたときにNullPointerExceptionになってしまうので 遷移前の画面IDを取得するようにしているところです。

@Interceptor
@Dependent
@Priority(Interceptor.Priority.APPLICATION + 100)
@Action
public class ForceRedirectInterceptor {

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {
        String currentViewId = FacesContext.getCurrentInstance().getViewRoot().getViewId();

        Object resultViewId = ic.proceed();

        if (resultViewId == null) {
            resultViewId = currentViewId;
        }
        resultViewId += "?faces-redirect=true";
        return resultViewId;
    }
}

使用実装

リダイレクトの記述をせずとも@Actionがあれば自動でリダイレクトされます。

ちょっと、手を抜きますが、実際に動かすと きちんとURLがアクションにあわせて
「今 有効となっている画面のURL」になっていることが確認できると思います。

@Action
public class IndexAction {

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

Code

今回は提示なしです。 Conversationとの組み合わせと合わせて公開します。

さいごに

多くのWebフレームワークではリダイレクトをしないことがデフォルトで、「リダイレクトするときには、こうします」みたいな実装例が示されるように感じています。

でも 個人的に不思議なのが、Webシステムでの URLの表記や、2度押しなどのリスクを鑑みた場合に「リダイレクトをしない」ケースの方が稀な気がするのです。

少なくともWebアプリレベルで リダイレクトを する か しない か は決まると思うので、起動パラメータやConfigでON・OFFできるようにしたら良い気がするのですが、実際そんなフレームワークを知らないので需要は無いのかな?

リダイレクトするとき と しないとき の場合分けを仕組みとして持たせたい要望があれば、System Property とかで指定できるようにしようかなぁと思いますが、 自分がそのケースに遭遇する気がしないので、私は「リダイレクトは常にする」を強制するという、今回のInterceptorを標準的に適用しようと思っています。

*1:これは、今後書く予定のConversationScopedの自動endに関連するので、その時に生きてきますが、それはまた後日ということで

Payara と Java EE 8 で Hello

EEなんだから、やっぱりPayaraだよね。

ということで、この記事のPayara版

vermeer.hatenablog.jp

Hello しかしていないから、本当にこれが正しいのか全く分からないけれど。。

PayaraはEサーバーなので、pomでの依存解決や、web.xmlなどの記述量が減りますね。

依存関係

pom.xml

    <dependencies>
        <dependency>
            <groupId>javax</groupId>
            <artifactId>javaee-web-api</artifactId>
            <version>8.0</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

これだけ。

設定

WEB-INF/beans.xml

CDIのバージョンを2.0にしておく。
しないといけないか、分からないけど理屈上はしておくべきだと思うので。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_2_0.xsd"
       bean-discovery-mode="annotated">
</beans>

WEB-INF/web.xml

Servletのバージョンを4.0にしておく。
しないといけないか、分からないけど理屈上はしておくべきだと思うので。

<?xml version="1.0" encoding="UTF-8"?>
<web-app
    xmlns="http://xmlns.jcp.org/xml/ns/javaee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
    version="4.0">

    <servlet>
        <servlet-name>Faces Servlet</servlet-name>
        <servlet-class>javax.faces.webapp.FacesServlet</servlet-class>
        <load-on-startup>1</load-on-startup>
    </servlet>
    <servlet-mapping>
        <servlet-name>Faces Servlet</servlet-name>
        <url-pattern>*.xhtml</url-pattern>
    </servlet-mapping>

    <context-param>
        <param-name>javax.faces.validator.DISABLE_DEFAULT_BEAN_VALIDATOR</param-name>
        <param-value>true</param-value>
    </context-param>

    <context-param>
        <param-name>javax.faces.SEPARATOR_CHAR</param-name>
        <param-value>-</param-value>
    </context-param>

    <welcome-file-list>
        <welcome-file>index.xhtml</welcome-file>
    </welcome-file-list>

    <session-config>
        <session-timeout>
            30
        </session-timeout>
    </session-config>

</web-app>

Code

Bitbucket

さいごに

まだ手探り。

とりあえずEEで色々するのは Tomcatじゃなくて、ちゃんとEEサーバーで色々と試していこうかな。

というわけで Hello Java EE 8

2018/4/25 追記
NetBeans8.2で開発をするんだったら、とりあえずJava EE 7までにしておいた方が良さそうなので、この雛型は使わない。