JSFで2重Submit対策
一般的な2重Submitは、hiddenで保持したトークン と サーバのセッションで保持しているトークンを比較する、というやり方だと思います。
これは、これで正しいと思いますし、多くの人が知っているであろう やり方なので検索性も良いと思います。
ですが、Jakarta EE には ConversationScoped があります。
画面単位(タブ単位)でイイ感じに情報を管理してくれるスコープがありますので、それを前提としたやり方で試してみたいと思います。
「そもそもConversation自体が面倒なんですが」ということについては、以前の記事の「フォルダ単位で会話スコープを制御する」を使用する前提です。
やりたいこと
- 2重Submit不可のトリガーとなるアクションを操作したら、以降 同アクションを操作したらエラーにする
- エラー判定後の操作として、メッセージ出力 または 画面遷移先指定(もしくは 2つの組み合わせ)を指定できるようにする
- デフォルトは「何もしない」
- アノテーションによって宣言的にする
仕様の補足
エラー判定後の操作
エラー画面への遷移だけでなく、メッセージ出力とした理由は、確認画面をそのまま流用できるようにして実装負担を減らせる仕組みを設けた方が良いだろうと思ったためです。
もちろん、エラーではなく「登録は完了しています」というようなメッセージにしてユーザー自身による2重登録防止にする、というやり方もあるでしょう。
画面遷移は、単純にエラー画面への遷移というだけでなくて f:viewAction
で初期表示イベントによる操作が出来るので複雑なことも可能です。
具体的には、f:viewAction
でアクションと紐づけをして、ケース分けから自画面とそれ以外の画面への振り分けや、ログ出力やエラー通知などをするイメージです。
アノテーションで宣言的
上述のように、複雑なロジックが必要なことは「遷移先画面の指定」で実現すると割り切って、あくまでアプリケーションの実装としてはアノテーションによる宣言のみです。
当初、細かい操作ができるようにした方が良いかなぁ、と考えて HandlerClassを設けて、それをInjetする方法も考えたのですが 遷移先画面を任意で指定できるようにする 上述のやり方であれば、結果として 好きなように実装できると考えて このやり方で落ち着きました。
また2重Submit対策はドメイン知識とは別の話なので、そういうものは極力「ロジック」に紛れ込まないようにする、つまり宣言的にしておく方が最適だと考えました。
デフォルトの挙動
2回目の操作時に「何もしない」をデフォルトとした理由は、プロダクトによって指針が大きく異なると考えたためです。
つまり、お試し実装の範疇では、固定的な実装はしない、としました。
もし、エラーメッセージを出力することをデフォルトにしたければ、@ExcuteOnce
のmessage
のデフォルト値でメッセージ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"; }
1回目の「登録(何もしない)」では、問題なく 完了画面へ遷移します。
「戻る」で前の画面に戻ります。
2回目の「登録(何もしない)」をクリックすると 登録ログは更新されていきますが、画面は一切変化しないことが確認できます。
2回目の操作で完了画面へ遷移
これが一番オーソドックスだと思います。
2度目以降の入力は無視して、とにかく完了画面へ遷移します。
特にエラーメッセージも出力しません。
@ExecuteOnce(forwardPage = "complete.xhtml") public String fwComplete() { System.out.println(this.getClass().toString() + "::登録処理を実施"); return "complete.xhtml"; }
<1回目の登録までは省略します>
エラーメッセージあり
エラーメッセージのみを指定した場合、自分画面へ遷移してメッセージを出力します。
画面上に、継続して処理したい場合のルートの情報をメッセージに出力しないとユーザーは不安になるでしょう。
@ExecuteOnce(message = "メッセージ出力:既に登録済みです。重複登録を抑止しました。トップページへ戻ってください。") public String fwCompleteWithMessage() { System.out.println(this.getClass().toString() + "::登録処理を実施"); return "complete.xhtml"; }
<1回目の登録までは省略します>
エラー画面遷移
本例だと、完了画面とエラー画面の違いは少ないですが、メッセージ出力タグがあったりして少しだけ違いがあります。
完了画面とは別のシーケンスだったことを示したい場合は、メッセージで通知するよりも エラー画面を設けておいた方が 1画面に複数仕様が混在しないので良いかもしれません。
デメリットは、画面レイアウトの変更をした際、完了画面とエラー画面の両方をメンテナンスしないといけないところでしょうか?
この辺りはJSFのコンポーネントなり、テンプレートなりを使えば手間は減らせるかもしれません。
@ExecuteOnce(forwardPage = "error.xhtml", message = "エラー画面へ遷移:既に登録済みです。重複登録を抑止しました。新たに別の入力するか、トップページへ戻ってください。") public String fwError() { System.out.println(this.getClass().toString() + "::登録処理を実施"); return "complete.xhtml"; }
<1回目の登録までは省略します>
会話スコープが終わったらSubmit可能
本記事のテーマからは少しずれていますが、同一フォルダ内の遷移でも メソッドに@EndConversation
を付与する事で会話スコープを終了できます。
通常フローの完了画面で「さらに入力」で、入力画面に戻ったら会話スコープが終了した後、新たに開始されます。
この時、同一フォルダの index.xhtml
ですが @EndConversation
の制御により、処理終了時に会話スコープは終了し、index.xhtml
の初期表示時に新たに会話スコープが開始されています。
これで、入力→確認→完了 のシーケンスをサイクリックに行えます。
@EndConversation public String fwIndex() { return "index.xhtml"; }
<完了画面の登録までは省略します>
実装と仕組み
最優先の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】
TeedaのdoOnce関連
Teedaの考え方は命名で規約を設けていて、全く同じということはないのですが 沢山の参考知見がありました。
TeedaはDoubleSubmittedException
をもって色々とハンドリングをしているようで、それを見たので私もExceptionHandlerWrapper
を使わないと出来ないのかも、、と思いましたが、とりあえず そこまでしなくて実現はできました。
[Seasar-user:12783] [Teeda]doOnceボタンでDoubleSubmittedExceptionが発生
[Seasar-user:19342] [Teeda] DoubleSubmittedException をキャッチした後の遷移先を振り分けるには?
Code
さいごに
何が嬉しいの?というところですが、トークンのためのタグをクライアントに実装しなくて良いというところでしょうか?
そもそもとしては 会話スコープのためにタグを追記しているのでは?というツッコミは容易に想像できますが、これは基本的に全てのページに共通して使用するテンプレートに実装するものであって、2重Submit防止制御をするページに都度適用するものとは違うと考えています。
とはいえ、結局のところ コードによる制御であるため 場合によっては見通しは良いかなぁ、とか トークンタグの実装をしなくても良い、くらいかもしれません。
一般的なやり方(タグでクライアントにトークンをhiddenで保持する)であっても、基本ルールを設けてしまえば「そういうものか」ということで特に困ることは無い気もします。
コード内で直接制御するという方法*4のだって別に悪い手だとは思っていません。
まぁ、ConversationScopeのある EE ならではの実装例 という意義くらいかもしれませんが、こういう やり方もありますよ、というところでしょうか。
とりあえず、いつものように右往左往しつつ 出来上がったものをみたら「あれ?こんなもん?」という感じですが、個人的にはアプリケーションコードとして、アノテーションによる宣言的な実装で 目的を達成できる方式が試せたので満足です。