4.4. セッション管理¶
目次
4.4.1. Overview¶
本節では、Webアプリケーションのセッション管理について説明する。
項番 説明 (1) Webブラウザ(Client)は、セッションが確立していない状態で、Webアプリケーション(Server)にアクセスする。 (2) Webアプリケーションは、Webブラウザとのセッションを管理するために、HttpSession
オブジェクトを生成する。HttpSession
オブジェクトを生成したタイミングで、セッションIDが払い出される。 (3) Webアプリケーションは、Webブラウザから送信されたデータを、HttpSession
オブジェクトに格納する。 (4) Webアプリケーションは、Webブラウザにレスポンスを返却する。レスポンスの「Set-Cookie」ヘッダに、「JSESSIONID = 払い出されたセッションID」を設定することで、セッションIDをWebブラウザに連携する。連携したセッションIDはCookieに格納される。 (5) Webブラウザは、リクエストの「Cookie」ヘッダに、「JSESSIONID = 払い出されたセッションID」を設定することで、セッションIDをWebアプリケーションと連携する。Webアプリケーションがデプロイされているアプリケーションサーバは、Webブラウザから連携されたセッションIDに対応するHttpSession
オブジェクトを取得し、リクエストに関連づける。 (6) Webアプリケーションは、リクエストに関連付けられたHttpSession
オブジェクトから、(1)のリクエストで格納したデータを取得する。リクエストをまたいで、同じデータにアクセスすることができる。 (7) Webアプリケーションは、Webブラウザにレスポンスを返却する。Note
セッションIDを連携するためのパラメータ名について
Jakarta EEのSerlvetの仕様では、セッションIDを連携するためのパラメータ名のデフォルトは、「JSESSIONID」となっている。
4.4.1.1. セッションのライフサイクル¶
Note
以降の説明で登場する
セッション
は、Servlet APIより提供されているjakarta.servlet.http.HttpSession
オブジェクトの事である。
HttpSession
オブジェクトは、上記で説明した論理的なセッションを表現するJavaオブジェクトである。
4.4.1.1.1. セッションの生成¶
本ガイドラインで推奨している方法でWebアプリケーションを作成した場合、以下のいずれかの処理でセッションが生成される。
項番 説明
Spring Securityから提供されている認証・認可を行う処理。Spring Securityの設定により、セッションの生成有無や、生成タイミングを指定することができる。Spring Securityで行われるセッション管理についての詳細は、How to useを参照されたい。
Spring Securityから提供されているCSRFトークンチェックを行う処理。既にセッションが確立されている場合は、新たなセッションは生成されない。CSRFトークンチェックの詳細については、CSRF対策を参照されたい。
共通ライブラリから提供されているトランザクショントークンチェックを行う処理。既にセッションが確立されている場合は、新たなセッションは生成されない。トランザクショントークンチェックの詳細については、二重送信防止を参照されたい。
RedirectAttributes
インタフェースのaddFlashAttributeメソッドを使用して、リダイレクト先のリクエストにモデル(フォームオブジェクトやドメインオブジェクトなど)を引き渡す処理。既にセッションが確立されている場合は、新たなセッションは生成されない。RedirectAttributes
およびFlash scopeについての詳細は、リダイレクト先にデータを渡すを参照されたい。
@SessionAttributes
アノテーションを使用して、モデル(フォームオブジェクトや、ドメインオブジェクトなど)をセッションに格納する処理。指定したモデル(フォームオブジェクトや、ドメインオブジェクトなど)がセッションに格納される。既にセッションが確立されている場合は、新たなセッションは生成されない。@SessionAttributes
アノテーションの使用方法については、@SessionAttributesアノテーションの使用を参照されたい。
Spring Frameworkの、sessionスコープのBeanを使用する処理。既にセッションが確立されている場合は、新たなセッションは生成されない。sessionスコープのBeanの使用方法については、Spring FrameworkのsessionスコープのBeanの使用を参照されたい。Note
上記の項番4, 5, 6については、セッションの使用有無はControllerの実装によって指定するが、セッションの生成タイミングは、フレームワークによって制御される。 つまり、Controllerの処理として
HttpSession
のAPIを直接使用する必要はない。
4.4.1.1.2. セッションへの属性格納¶
本ガイドラインで推奨している方法でWebアプリケーションを作成した場合、以下のいずれかの処理でセッションに属性(オブジェクト)が格納される。
項番 説明
Spring Securityから提供されているCSRFトークンチェックを行う処理。払い出されたトークン値がセッションに格納される。CSRFトークンチェックの詳細については、CSRF対策を参照されたい。
共通ライブラリから提供されているトランザクショントークンチェックを行う処理。払い出されたトークン値がセッションに格納される。トランザクショントークンチェックの詳細については、二重送信防止を参照されたい。
RedirectAttributes
インタフェースのaddFlashAttributeメソッドを使用して、リダイレクト先のリクエストにモデル(フォームオブジェクトやドメインオブジェクトなど)を引き渡す処理。RedirectAttributes
インタフェースのaddFlashAttributeメソッドの引数に指定したオブジェクトが、セッション上に存在するFlash scopeという領域に格納される。RedirectAttributes
およびFlash scopeについての詳細は、リダイレクト先にデータを渡すを参照されたい。
@SessionAttributes
アノテーションを使用して、モデル(フォームオブジェクトや、ドメインオブジェクトなど)をセッションに格納する処理。指定したモデル(フォームオブジェクトや、ドメインオブジェクトなど)がセッションに格納される。@SessionAttributes
アノテーションの使用方法については、@SessionAttributesアノテーションの使用を参照されたい。
Spring Frameworkの、sessionスコープのBeanを使用する処理。sessionスコープのBeanがセッションに格納される。sessionスコープのBeanの使用方法については、Spring FrameworkのsessionスコープのBeanの使用を参照されたい。Note
オブジェクトをセッションに格納するタイミングはフレームワークによって制御されるため、Controllerの処理として
HttpSession
オブジェクトのsetAttributeメソッドを呼び出すことはない。
4.4.1.1.3. セッションからの属性削除¶
本ガイドラインで推奨している方法で、Webアプリケーションを作成した場合、以下のいずれかの処理でセッションから属性(オブジェクト)が削除される。
項番 説明
Spring Securityから提供されているログアウトを行う処理。認証されたユーザ情報がセッションから削除される。Spring Securityで行われるログアウト処理についての詳細は、認証を参照されたい。
共通ライブラリから提供されているトランザクショントークンチェックを行う処理。払い出されたトークン値が、ネームスペースに割り振られている上限値を超えた場合、使用されていないトークン値がセッションから削除される。トランザクショントークンチェックの詳細については、二重送信防止を参照されたい。
Flash scopeにオブジェクトを格納した後のリダイレクト処理。RedirectAttributes
インタフェースのaddFlashAttributeメソッドの引数に指定したオブジェクトが、セッション上に存在するFlash scopeという領域から削除される。
Controllerの処理として、SessionStatus
オブジェクトのsetCompleteメソッドを呼び出した後のフレームワークの処理。@SessionAttributes
アノテーションで指定したオブジェクトがセッションから削除される。Note
セッションからオブジェクトを削除するタイミングはフレームワークによって制御されるため、Controllerの処理として
HttpSession
オブジェクトのremoveAttributeメソッドを呼び出すことはない。
4.4.1.1.4. セッションの破棄¶
本ガイドラインで推奨している方法で、Webアプリケーションを作成した場合、以下のいずれかの処理でセッションが破棄される。
項番 説明
Spring Securityから提供されているログアウト処理。Spring Securityで行われるログアウト処理についての詳細は、認証を参照されたい。
アプリケーションサーバのセッションタイムアウト検知処理。
明示的に破棄する際のイメージを、以下に示す。
項番 説明 (1) Webブラウザからセッションを破棄する処理に、アクセスする。Spring Securityを使用する場合は、Spring Securityから提供されているログアウト処理が、セッションを破棄する処理を行っている。Spring Securityで行われるログアウト処理についての詳細は、認証を参照されたい。 (2) Webアプリケーションは、Webブラウザから連携されたセッションIDに対応するHttpSession
オブジェクトを破棄する。この時点でサーバ側には、SESSION01
というIDのHttpSession
オブジェクトが消滅する。 (3) Webブラウザから破棄されたセッションのセッションIDを使ってアクセスされた場合、セッションIDに対応するHttpSession
オブジェクトが存在しないため、別のセッションを生成する。上記例では、セッションIDが、SESSION02
のセッションを生成している。
タイムアウトによって、自動的に破棄される際のイメージを、以下に示す。
項番 説明 (1) 確立されたセッションに対して一定時間アクセスがない場合、アプリケーションサーバは、セッションタイムアウトを検知する。 (2) アプリケーションサーバは、セッションタイムアウトが検知されたセッションを破棄する。 (3) セッションタイムアウト発生後に、Webブラウザからアクセスされた場合、Webブラウザから送られてきたセッションIDに対応するHttpSession
オブジェクトが存在しないため、セッションタイムアウトエラーをWebブラウザに返却する。Note
セッションタイムアウトの設計
セッションにデータを格納する場合は、必ずセッションタイムアウトの設計を行うこと。特に、格納するデータのサイズが大きくなる場合は、タイムアウトは、可能な限り短く設定することを推奨する。
Note
デフォルトのセッションタイムアウト時間について
デフォルトのセッションタイムアウト時間は、アプリケーションサーバによって異なる。
- Tomcat : 1800 秒 (30分)
- WebLogic : 3600 秒 (60分)
- WebSphere : 1800 秒 (30分)
- JBoss : 1800 秒 (30分)
4.4.1.1.5. セッションタイムアウト後のリクエスト検知¶
本ガイドラインで推奨している方法でWebアプリケーションを作成した場合、以下のいずれかの処理で、セッションタイムアウト後のリクエストを検知する。
項番 説明
Spring Securityから提供されているセッションのタイムアウトチェック処理。Spring Securityのデフォルトの設定では、セッションのタイムアウトチェックは行われない。そのため、セッションにデータを格納する場合は、Spring Securityのセッションのタイムアウトチェック処理を有効化するための設定が、必要となる。Spring Securityで行われるセッションのタイムアウトチェック処理の詳細は、How to useを参照されたい。
Spring Securityを使用しない場合は、Servlet Filter、または、Spring MVCのHandlerInterceptor
にて、セッションのタイムアウトチェックを行う処理を実装する必要がある。
Spring Securityから提供されているセッションチェック処理を使用して、セッションタイムアウトを検知する際のイメージについて、以下に示す。
項番 説明 (1) 確立されたセッションに対して、一定時間アクセスがない場合、アプリケーションサーバは、セッションタイムアウトを検知し、セッションを破棄する。 (2) セッションタイムアウト発生後に、Webブラウザからアクセスが発生する。 (3) Spring Securityから提供されているセッションの存在チェック処理は、クライアントから連携されたセッションIDに対応するHttpSession
オブジェクトが存在しないため、セッションタイムアウトエラーとする。Spring Securityのデフォルト実装では、エラー画面を表示するための、URLへのリダイレクト要求が応答される。Note
セッションのタイムアウトチェックの必要性
「セッションにデータが格納されていること」が事前条件となる処理については、必ずセッションのタイムアウトチェックを行うこと。 セッションのタイムアウトチェックを行わないと、処理で必要なデータが取得できないため、予期しないシステムエラーの発生や、想定外の動作を引き起こす可能性がある。
4.4.1.2. セッションの利用について¶
Note
本ガイドラインでは、安易にセッションにデータを格納するのではなく、まずはセッションを使わない方針で検討し、本当に必要なデータのみセッションに格納することを推奨する。
Note
以下の条件にあてはまるデータについては、セッションにデータを格納した方がよい場合がある。
ユースケース間で連携はしないが、別のユースケースに移って戻った際に、状態を保持しておく必要があるデータ。例えば、一覧画面の検索条件が、このパターンに該当する。一覧画面の検索条件は、別のユースケース(例えば、「検索したデータを変更する」ユースケース)から戻った際に、別のユースケースに移る前の状態を保持することが機能要件となる事が多い。検索条件をhiddenで持ち回る方法もあるが、ユースケース間に余計な依存関係が生まれ、アプリケーションの実装も複雑になることが予想される。 ユースケース間で連携が必要なデータ。たとえば、ショッピングサイトのカートに格納するデータが、このパターンに該当する。ショッピングサイトのカートに格納するデータは、「商品をカートに追加する」ユースケース、「カートを表示する」ユースケース、「カートの状態を変更する」ユースケース、「カートにいれた商品を購入する」ユースケースでデータの連携が必要となるためである。ただし、スケラビリティを考慮する必要がある場合は、セッションではなくデータベースに格納した方がよいケースがある。
4.4.1.2.1. セッション利用時のメリットとデメリット¶
セッション利用時のメリットとデメリットは、以下の通りである。
メリット
- 複数の画面(複数のリクエスト)をまたいで、データを持ち回ることができるため、ウィザード画面のような複数の画面で、1つ処理を構成する場合に、簡単にデータが持ち回れる。
- 取得したデータをセッションに格納しておくことで、データの取得処理の実行回数を、減らすことができる。
デメリット
- 同一処理の画面を、複数のブラウザやタブで立ち上げた場合、互いの操作がセッション上に格納しているデータに干渉しあうため、データの整合性を保つことができなくなる。データの整合性を保つためには、同一処理の画面を複数立ち上げることができないように、制御する必要がある。データの整合性を保つための制御は、共通ライブラリから提供しているトランザクショントークンチェックを使用することで実現する事ができるが、ユーザビリティの低いアプリケーションとなってしまう。
- セッションは、通常アプリケーションサーバ上のメモリとして管理されるため、セッションに格納するデータの量に比例して、メモリの使用量も増大する。処理で使用されなくなったデータを残したままにすると、ガベージコレクションの対象外となり、メモリ枯渇の原因となるため、不要になった段階でセッションから削除する必要がある。セッションから不要となったデータを削除するタイミングについて、別途設計を行う必要がある。
- 処理で扱うデータをセッションに格納すると、APサーバのスケーラビリティを低下させる要因となりうる。
Note
APサーバをスケールアウトする場合、以下のいずれかの仕組みが必要となる。
- セッションをレプリケーションし、すべてのAPサーバでセッション情報を共有する。セッションをレプリケーションする場合は、セッションに格納されるデータの量とレプリケーション対象となるAPサーバの数に比例してレプリケーション処理にかかる負荷が高くなる。そのため、スケールアウトすることで、レスポンスタイムなどが劣化する可能性がある点に注意が必要となる。
- ロードバランサによって、同一セッション内で発生するリクエストを全て同じAPサーバに振り分ける。同じAPサーバに振り分ける場合は、APサーバがダウンした場合に別のAPサーバで処理を継続することができない。そのため、高い可用性(サービスレベル)が求められるアプリケーションでは使用できない可能性がある点に注意が必要となる。
それぞれの注意点を考慮した上で、スケールアウトする方法を判断すること。
4.4.1.2.2. セッションを利用しない時のメリットとデメリット¶
メリット
- サーバ側でデータを保持しないため、複数ブラウザや複数タブを使用しても、互いの操作が干渉することはない。そのため、同一処理の画面を複数立ち上げることもできるので、ユーザビリティが損なわれることはない。
- サーバ側でデータを保持しないため、持続的に使用するメモリの使用量を、抑えることができる。
- APサーバのスケーラビリティを低下させる要因が少なくなる。
デメリット
- サーバの処理で必要となるデータを、リクエストパラメータとして送信する必要があるため、画面表示に表示していない項目についても、hidden項目に指定する必要がある。そのため、ThymeleafのテンプレートHTMLの実装コードが増える。これは、独自のダイアレクトを作成することで、最小限に抑えることが可能である。
- サーバの処理で必要となるデータを、すべてのリクエストで送信する必要があるため、ネットワーク上に流れるデータ量が増える。
- 画面表示に必要なデータを、その都度取得する必要があるため、データの取得処理の実行回数が増える。
4.4.1.3. セッションに格納するデータについて¶
セッションに格納するデータは、以下の点を考慮する必要がある。
- シリアライズすることができるオブジェクト(
java.io.Serializable
を実装しているオブジェクト)であること。 - メモリ枯渇の原因となるような容量の大きいオブジェクトでないこと。
4.4.1.3.1. シリアライズ可能なオブジェクト¶
ディスクへの入出力が発生するケースは、以下の通りである。
- アクティブなセッションが存在する状態で、アプリケーションサーバが停止された場合、セッションおよびセッションに格納されていたデータは、ディスクに退避される。退避されたセッション、および格納されていたデータは、アプリケーションサーバ起動時に復元される。データの復元に関するこの動作は、アプリケーションサーバによってサポート状況が異なる。
- セッションの格納領域が溢れそうになった場合や、最終アクセスから一定時間アクセスがない場合、セッションのスワップアウトが発生する可能性がある。スワップアウトされたセッションは、アクセスが発生した際にスワップインされる。スワップアウトの発生条件などは、アプリケーションサーバによって異なる。
ネットワークへの入出力が発生するケースは、以下の通りである。
- セッションを、別のアプリケーションサーバにレプリケーションする場合、セッションに格納したデータが、ネットワークを経由して、別のアプリケーションサーバに送信される。
4.4.1.3.2. セッションに格納するデータの容量¶
セッションに格納するデータは、できる限りコンパクトにすることを推奨する。
セッションに格納されているデータの容量が大きい場合は、致命的なパフォーマンス低下を引き起こす原因となるので、容量の大きいデータは、セッションに格納しないように設計することを推奨する。
パフォーマンス低下を引き起こす主な原因は、以下の通り。
- セッションに容量の大きいデータを格納する場合、メモリ枯渇が発生しないようにするために、アプリケーションサーバの設定をスワップアウトが発生しやすい設定にしておく必要がある。スワップアウト処理は、「重い」処理であるため、スワップアウトが頻繁に発生すると、アプリケーション全体のパフォーマンスに影響を与える可能性がある。スワップアウトに関する動作や設定方法は、アプリケーションサーバによってサポート状況が異なる。
- セッションをレプリケーションする場合、オブジェクトのシリアライズとデシリアライズが行われる。容量の大きいオブジェクトのシリアライズとデシリアライズ処理は、「重い」処理であるため、レスポンスタイムなどのパフォーマンスに影響を与える可能性がある。
セッションに格納するデータをコンパクトにするために、以下の条件にあてはまるデータについては、セッションスコープではなく、リクエストスコープに格納することを検討すること。
- 画面操作で編集することができない読み取り専用のデータ。データが必要になったタイミングで最新のデータを取得し、取得したデータをリクエストスコープへ格納した上で画面へ表示するようにすれば、セッションへ格納する必要はない。
- 画面操作で編集できるが、生存期間がユースケース内の画面操作に閉じているデータ。HTMLフォームのhidden項目として、全ての画面遷移でデータを引き回せば、セッションに格納する必要はない。
4.4.1.4. APサーバ多重化時の考慮点について¶
- 高い可用性(サービスレベル)が求められるシステムの場合は、APサーバダウン時に別のAPサーバで処理が継続できるようにする必要がある。APサーバダウン時に別のAPサーバで処理を継続するためには、全てのAPサーバでセッション情報を共有しておく必要があるので、アプリケーションサーバをクラスタ構成としてセッションをレプリケーションする必要がある。セッション情報を共有する別の方法としては、セッションの保存先をOracle Coherenceのようなキャッシュサーバやデータベースにすることで実現することも可能である。APサーバの台数、セッションに格納されるデータの容量、同時に貼らせるセッション数が大量になる場合は、セッションの保存先をOracle Coherenceのようなキャッシュサーバやデータベースにすることを検討した方がよい。
- 高い可用性(サービスレベル)が求められないシステムの場合は、APサーバダウン時に別のAPサーバで処理を継続できるようにする必要はない。そのため、全てのAPサーバでセッション情報を共有する必要はないので、ロードバランサの機能を使って同一セッション内で発生するリクエストを全て同じAPサーバに振り分けるようにすればよい。
Warning
本ガイドラインで推奨している方法でWebアプリケーションを作成した場合、以下のデータがセッションに格納されるため、何れかの仕組みを適用する必要がある。
- Spring Securityの認証処理で認証されたユーザ情報。
- Spring SecurityのCSRFトークンチェックで払い出されたトークン値。
- 共通ライブラリから提供しているトランザクショントークンチェックで払い出されたトークン値。
4.4.1.5. セッションの保存先について¶
4.4.2. How to use¶
本ガイドラインでは、セッションにデータを格納する場合は、以下のいずれかの方法を使用して行うことを推奨している。
Warning
Controllerのハンドラメソッドの引数に HttpSession
オブジェクトを指定することで、 HttpSession
のAPIを直接呼び出すことができるが、
原則としてはHttpSessionのAPIを直接使用しないことを強く推奨する。
HttpSession
を直接使わないと実現できない処理については、 HttpSession
のAPIを直接使用してもよいが、
多くの業務処理において、HttpSessionのAPIを直接使用する必要はないため、原則Controllerのハンドラメソッドの引数として、 HttpSession
オブジェクトを指定しないようにすること。
4.4.2.1. @SessionAttributes
アノテーションの使用¶
@SessionAttributes
アノテーションは、Controller内で行われる画面遷移において、データを持ち回る場合に使用する。
@SessionAttributes
アノテーションを使用してフォームオブジェクトをセッションに格納する方法を採用すべきか検討すること。4.4.2.1.1. セッションに格納するオブジェクトの指定¶
@SessionAttributes
アノテーションをクラスに指定し、セッションに格納するオブジェクトを指定する。
@Controller @RequestMapping("wizard") @SessionAttributes(types = { WizardForm.class, Entity.class }) // (1) public class WizardController { // ... }
項番 説明 (1)@SessionAttributes
アノテーションのtypes属性に、セッションに格納するオブジェクトの型を指定する。@ModelAttribute
アノテーション、またはModel
のaddAttributeメソッドを使用して、Model
オブジェクトに追加されたオブジェクトのうち、types属性で指定した型に一致するオブジェクトが、セッションに格納される。上記例では、WizardForm
クラスとEntity
クラスのオブジェクトが、セッションに格納される。Note
ライフサイクルの管理単位
@SessionAttributes
アノテーションを使って、セッションに格納したオブジェクトは、Controller単位で、ライフサイクルが管理される。
SessionStatus
オブジェクトのsetCompleteメソッドを呼び出すと、@SessionAttributes
アノテーションで指定したオブジェクトが、すべてセッションから削除される。 そのため、ライフサイクルが異なるオブジェクトを、セッションに格納する場合は、Controllerを分割する必要がある。Warning
@SessionAttributesアノテーション使用時の注意点
Controller単位で、ライフサイクルが管理されると上で説明したが、複数のControllerで同じ属性名のオブジェクトを、
@SessionAttributes
アノテーションを使って、セッションに格納した場合は、 Controllerをまたいで、ライフサイクルが管理される。別ウィンドウやタブを開いて、同時に画面操作できる処理の場合は、同じオブジェクトに対してアクセスすることになるため、不具合を引き起こす原因になりうる。 そのため、複数のControllerで、同じフォームオブジェクトのクラスを使用する場合は、
@ModelAttribute
アノテーションのvalue属性に、それぞれ別の値(属性名)を指定した上で、@SessionAttributes
アノテーションの value属性に@ModelAttribute
アノテーションのvalue属性に指定した値と同じ値を指定すること。
@Controller @RequestMapping("wizard") @SessionAttributes(value = { "wizardCreateForm" }) // (2) public class WizardController { // ... @ModelAttribute(value = "wizardCreateForm") public WizardForm setUpWizardForm() { return new WizardForm(); } // ... }
項番 説明 (2)@SessionAttributes
アノテーションのvalue属性に、セッションに格納するオブジェクトの属性名を指定する。@ModelAttribute
アノテーション、またはModel
のaddAttributeメソッドを使用して、Model
オブジェクトに追加されたオブジェクトのうち、value属性で指定した属性名に一致するオブジェクトが、セッションに格納される。上記例では、属性名がwizardCreateForm
のオブジェクトが、セッションに格納される。
4.4.2.1.2. セッションにオブジェクトを追加¶
セッションにオブジェクトを追加する場合、以下2つの方法を使用する。
@ModelAttribute
アノテーションが付与されたメソッドにて、セッションに追加するオブジェクトを返却する。Model
オブジェクトのaddAttributeメソッドを使用して、セッションに格納するオブジェクトを追加する。
Model
オブジェクトに追加されたオブジェクトは、@SessionAttributes
アノテーションのtypesと、value属性の属性値にしたがって、
セッションに格納されるため、Controllerのハンドラメソッドで、セッションを意識した実装を行う必要はない。
@ModelAttribute
アノテーションが付与されたメソッドを使用して、セッションに格納するオブジェクトを返却する方法について、説明する。@ModelAttribute(value = "wizardForm") // (1) public WizardForm setUpWizardForm() { return new WizardForm(); }
項番 説明 (1)Model
オブジェクトに格納する属性名を、value属性に指定する。上記例では、返却したオブジェクトが、wizardForm
という属性名でセッションに格納される。value属性を指定した場合、セッションにオブジェクトを格納した後のリクエストで、@ModelAttribute
アノテーションの付与されたメソッドが呼び出されなくなるため、無駄なオブジェクトの生成が行われないというメリットがある。Warning
@ModelAttributeアノテーションのvalue属性を省略した場合の動作について
value属性を省略した場合、デフォルトの属性名を生成するために、すべてのリクエストで、
@ModelAttribute
アノテーションの付与されたメソッドが呼ばれる。 そのため、無駄なオブジェクトが生成されるというデメリットがあるので、 セッションに格納する場合は、この方法は原則使用しないこと。@ModelAttribute // (1) public WizardForm setUpWizardForm() { return new WizardForm(); }
項番 説明 (1)@ModelAttribute
アノテーションが付与されたメソッドにて、セッションに追加するオブジェクトを生成し、返却する。上記例では、wizardForm
という属性名で返却したオブジェクトが、セッションに格納される。
Model
オブジェクトのaddAttributeメソッドを使用し、セッションにオブジェクトを追加する方法について、説明する。@GetMapping(value = "update/{id}", params = "form1") public String updateForm1(@PathVariable("id") Integer id, WizardForm form, Model model) { Entity loadedEntity = entityService.getEntity(id); model.addAttribute(loadedEntity); // (3) beanMapper.map(loadedEntity, form); // (4) return "wizard/form1"; }
項番 説明 (3)Model
オブジェクトのaddAttributeメソッドを使用して、セッションに格納するオブジェクトを追加する。上記例では、entity
という属性名で、ドメイン層から取得したオブジェクトを、セッションに格納している。 (4) BeanのマッピングにはMapStructを用いて作成したマッパーインタフェースを使用する。Mapperインタフェースの定義方法についてはBeanマッピング(MapStruct)を参照されたい。以降の実装例でもBeanマッピングにはMapStructを使用する。
4.4.2.1.3. セッションに格納されているオブジェクトの取得¶
@SessionAttributes
アノテーションの属性値にしたがって、Model
オブジェクトに格納されるため、Controllerのハンドラメソッドでは、セッションを意識した実装を行う必要はない。@PostMapping(value = "save") public String save(@Validated({ Wizard1.class, Wizard2.class, Wizard3.class }) WizardForm form, // (1) BindingResult result, Entity entity, // (2) RedirectAttributes redirectAttributes) { // ... return "redirect:/wizard/save?complete"; }
項番 説明 (1)Model
オブジェクトに格納されているオブジェクトを取得する。上記例では、wizardForm
という属性名でセッションスコープに格納されているオブジェクトが、引数formに渡される。@Validated
アノテーションで指定しているWizard1.class
,Wizard2.class
,Wizard3.class
については、 Appendixの @SessionAttributesアノテーションを使ったウィザード形式の画面遷移の実装例 を参照されたい。 (2) 上記例では、entity
という属性名でセッションスコープに格納されているオブジェクトが、引数entityに渡される。Note
セッションスコープに格納しているオブジェクトを受け取る際にリクエストパラメータのバインドを防止する方法
セッションスコープに格納しているオブジェクトをハンドラメソッドの引数として受け取る際、その引数にリクエストパラメータがバインドされる可能性がある。
リクエストパラメータがバインドされない様にするためには、セッションスコープに格納しているオブジェクトをハンドラメソッドの引数から受け取らず、ハンドラメソッド内で
Model
オブジェクトから取得する方法があるが、 取得するオブジェクトの属性名を文字列で指定する必要があるためタイプセーフではない。これに対し、Spring Framework 4.3では
@ModelAttribute
アノテーションにbinding
属性が追加され、引数にリクエストパラメータをバインドするか否かを指定できる様になった。 引数に@ModelAttribute
アノテーションを付与し、binding
属性にfalse
を指定することで、 リクエストパラメータのバインドを防止しつつ、タイプセーフにセッションスコープに格納しているオブジェクトを取得することができる。下記の例は、
entity
という属性名でセッションスコープに格納しているオブジェクトをリクエストパラメータのバインドを防止して取得している。@PostMapping(value = "save") public String save(@Validated({ Wizard1.class, Wizard2.class, Wizard3.class }) WizardForm form, BindingResult result, @ModelAttribute(binding = false) Entity entity, RedirectAttributes redirectAttributes) { // ... return "redirect:/wizard/save?complete"; }
Controllerのハンドラメソッドの引数に渡すオブジェクトが、Model
オブジェクトに存在しない場合、@ModelAttribute
アノテーションの指定の有無で、動作が変わる。
@ModelAttribute
アノテーションを指定していない場合は、新しいオブジェクトが生成されて引数に渡される。 生成されたオブジェクトはModel
オブジェクトに格納されるため、セッションにも格納される。
Note
リダイレクト時の動作について
遷移先をリダイレクトにした場合は、生成されたオブジェクトは、セッションに格納されない。 そのため、生成されたオブジェクトを、リダイレクト先の処理で参照したい場合は、
RedirectAttributes
のaddFlashAttributeメソッドを使用して、Flashスコープにオブジェクトを格納する必要がある。
@ModelAttribute
アノテーションを指定している場合は、org.springframework.web.HttpSessionRequiredException
が発生する。
@PostMapping(value = "save") public String save(@Validated({ Wizard1.class, Wizard2.class, Wizard3.class }) WizardForm form, // (3) BindingResult result, @ModelAttribute Entity entity, // (4) RedirectAttributes redirectAttributes) { // ... return "redirect:/wizard/save?complete"; }
項番 説明 (3)@Validated
アノテーションで、特定の検証グループ(Wizard1.class
,Wizard2.class
,Wizard3.class
)を設定して入力チェックを行っている。入力チェックの詳細については、入力チェックを参照されたい。 (4) 引数に、@ModelAttribute
アノテーションを指定している場合、セッションに対象のオブジェクトが存在しない時に呼び出されると、HttpSessionRequiredException
が発生する。HttpSessionRequiredException
は、ブラウザバックや、URL直接指定のアクセスなどの、クライアントの操作に起因して発生する例外になるため、クライアントエラーとして、例外ハンドリングを行う必要がある。
HttpSessionRequiredException
をクライアントエラーとするための設定は、以下の通りである。
- spring-mvc.xml
<bean class="org.terasoluna.gfw.web.exception.SystemExceptionResolver"> <property name="exceptionCodeResolver" ref="exceptionCodeResolver" /> <!-- ... --> <property name="exceptionMappings"> <map> <!-- ... --> <entry key="HttpSessionRequiredException " value="common/error/operationError" /> <!-- (5) --> </map> </property> <property name="statusCodes"> <map> <!-- ... --> <entry key="common/error/operationError" value="400" /> <!-- (6) --> </map> </property> <!-- ... --> </bean>
項番 説明 (5) 共通ライブラリから提供しているSystemExceptionResolver
のexceptionMappings
に、HttpSessionRequiredException
の例外ハンドリングの定義を追加する。上記例では、 例外発生時の遷移先のリクエストパスとして、common/error/operationError
を指定している。 (6)SystemExceptionResolver
のstatusCodes
に、HttpSessionRequiredException
発生時の、HTTPレスポンスコードを指定する。上記例では、 例外発生時のHTTPレスポンスコードとして、 Bad Request(400
)を指定している。
- applicationContext.xml
<bean id="exceptionCodeResolver" class="org.terasoluna.gfw.common.exception.SimpleMappingExceptionCodeResolver"> <!-- Setting and Customization by project. --> <property name="exceptionMappings"> <map> <!-- ... --> <entry key="HttpSessionRequiredException" value="w.xx.0003" /> <!-- (7) --> </map> </property> <property name="defaultExceptionCode" value="e.xx.0001" /> <!-- (8) --> </bean>
項番 説明 (7) 共通ライブラリから提供しているSimpleMappingExceptionCodeResolver
のexceptionMappings
に、HttpSessionRequiredException
の例外ハンドリングの定義を追加する。上記例では、 例外発生時の例外コードとして、w.xx.0003
を指定している。この設定を追加しない場合は、デフォルトの例外コードが、ログに出力される。 (8) 例外発生時のデフォルトの例外コード。
4.4.2.1.4. セッションに格納したオブジェクトの削除¶
@SessionAttributes
を用いてセッションに格納したオブジェクトを削除する場合、 org.springframework.web.bind.support.SessionStatus
のsetCompleteメソッドを、Controllerのハンドラメソッドから呼び出す。SessionStatus
オブジェクトのsetCompleteメソッドを呼び出すと、 @SessionAttributes
アノテーションの属性値に指定されているオブジェクトが、セッションから削除される。Note
セッションから削除されるタイミングについて
SessionStatus
オブジェクトのsetCompleteメソッドを呼び出すことで、@SessionAttributes
アノテーションの属性値に指定されているオブジェクトが、セッションから削除される。 ただし、実際に削除されるタイミングは、setCompleteメソッドを呼び出したタイミングではない。
SessionStatus
オブジェクトのsetCompleteメソッド自体は、内部のフラグを変更しているだけなので、実際の削除は、Controllerのハンドラメソッドの処理が終了した後に、フレームワークによって行われる。Note
View(Thymeleaf)からのオブジェクトの参照について
SessionStatus
オブジェクトのsetCompleteメソッドを呼び出すことで、セッションから削除されるが、同じオブジェクトが、Model
オブジェクトに残っているため、View(Thymeleaf)から参照することができる。
セッションに格納したオブジェクトの削除は、以下3カ所で行う必要がある。
- 完了画面を表示するためのリクエスト。(必須)完了画面を表示した後に、セッションに格納したオブジェクトにアクセスすることはないため、不要になったオブジェクトを削除する。
Warning
削除が必要な理由
セッションに格納されているオブジェクトは、ガベージコレクションの対象とならないため、不要になったオブジェクトを削除しないと、メモリ枯渇の原因になりうる。 また、不要なオブジェクトがセッションに格納されていると、セッションのスワップアウトが発生した際の処理が重くなり、アプリケーション全体の性能に影響を与える可能性がある。
- 一連の画面操作を中止するためのリクエスト。(必須)「メニューへ戻る」や「中止」などの、一連の画面操作を中止するためのイベントについても、セッションに格納したオブジェクトにアクセスすることはないため、不要になったオブジェクトを削除すること。
- 入力画面を初期表示するためのリクエスト。(任意)
Warning
削除が必要な理由
画面操作の途中でブラウザやタブを閉じた場合、セッションに格納されているフォームオブジェクトに入力途中の情報が残るため、初期表示時に削除しないと、入力途中の情報が画面に表示されてしまう。 ただし、入力途中の情報が画面に表示されてもよい場合は、初期表示するためのリクエストで削除は必須ではない。
完了画面を表示するためのリクエストで削除する際の実装例は、以下の通りである。
// (1) @PostMapping(value = "save") public String save(@ModelAttribute @Validated({ Wizard1.class, Wizard2.class, Wizard3.class }) WizardForm form, BindingResult result, Entity entity, RedirectAttributes redirectAttributes) { // ... return "redirect:/wizard/save?complete"; // (2) } // (3) @GetMapping(value = "save", params = "complete") public String saveComplete(SessionStatus sessionStatus) { sessionStatus.setComplete(); // (4) return "wizard/complete"; }
項番 説明 (1) 更新処理を行うためのハンドラメソッド。 (2) 完了画面を表示するためのリクエスト(3)へ、リダイレクトする。 (3) 完了画面を表示するためのハンドラメソッド。 (4)SessionStatus
オブジェクトのsetCompleteメソッドを呼び出し、オブジェクトをセッションから削除する。Model
オブジェクトに同じオブジェクトが残っているため、直接、View(Thymeleaf)の表示処理に影響は与えない。
一連の画面操作を中止するためのリクエストで削除する際の実装例は、以下の通りである。
// (1) @PostMapping(value = "save", params = "cancel") public String saveCancel(SessionStatus sessionStatus) { sessionStatus.setComplete(); // (2) return "redirect:/wizard/menu"; // (3) }
項番 説明 (1) 一連の画面操作を中止するためのハンドラメソッド。 (2)SessionStatus
オブジェクトのsetCompleteメソッドを呼び出し、オブジェクトをセッションから削除する。 (3) 上記例では、メニュー画面へ、リダイレクトしている。
入力画面を、初期表示するためのリクエストで削除する際の実装例は、以下の通りである。
// (1) @GetMapping(value = "create") public String initializeCreateWizardForm(SessionStatus sessionStatus) { sessionStatus.setComplete(); // (2) return "redirect:/wizard/create?form1"; // (3) } // (4) @GetMapping(value = "create", params = "form1") public String createForm1() { return "wizard/form1"; }
項番 説明 (1) 入力画面を初期表示するためのハンドラメソッド。 (2)SessionStatus
オブジェクトのsetCompleteメソッドを呼び出す。 (3) 入力画面を表示するためのリクエスト(4)へ、リダイレクトする。SessionStatus
オブジェクトのsetCompleteメソッドを呼び出すことで、セッションからは削除されるが、Model
オブジェクトに同じオブジェクトが残っているため、直接View(Thymeleaf)を呼び出してしまうと、入力途中の情報が表示されてしまう。そのため、セッションから削除したうえで、入力画面を表示するためのリクエストへ、リダイレクトする必要がある。 (4) 入力画面を表示するためのハンドラメソッド。
4.4.2.1.5. @SessionAttributesを使った処理の実装例¶
より具体的な実装例については、Appendixの@SessionAttributesアノテーションを使ったウィザード形式の画面遷移の実装例を参照されたい。
4.4.2.2. Spring FrameworkのsessionスコープのBeanの使用¶
4.4.2.2.1. sessionスコープのBean定義¶
Spring FrameworkのsessionスコープのBeanを、定義する。
sessionスコープのBeanを定義する方法は、以下2種類の方法がある。
- component-scanを使用してbeanを定義する。
- Bean定義ファイル(XML)にbeanを定義する。
component-scanを使用する方法を、以下に示す。
- クラス
@Component @Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS) // (1) public class SessionCart implements Serializable { private static final long serialVersionUID = 1L; private Cart cart; public Cart getCart() { if (cart == null) { cart = new Cart(); } return cart; } public void setCart(Cart cart) { this.cart = cart; } public void clearCart() { // (2) cart.clearCart(); } }
項番 説明 (1) Beanのスコープをsession
にする。また、proxyMode 属性でScopedProxyMode.TARGET_CLASS
を指定し、scoped-proxyを有効にする。 (2) 注文が完了した際にカートの状態をクリア(カート内の商品を削除)するためのメソッドを用意する。Note
scoped-proxyを有効化する理由について
sessionスコープのBeanをsingletonスコープのControllerにInjectするために、scoped-proxyを有効化する必要がある。
- spring-mvc.xml
<context:component-scan base-package="xxx.yyy.zzz.app" /> // (2)
項番 説明 (2)<context:component-scan>
要素でベースとなるパッケージを指定する。
Bean定義ファイル(XML)に定義する方法を、以下に示す。
- JavaBean
<beans:bean id="sessionCart" class="xxx.yyy.zzz.app.SessionCart" scope="session"> <!-- (3) --> <aop:scoped-proxy /> <!-- (4) --> </beans:bean>
項番 説明 (3) Beanのスコープをsession
にする。 (4)<aop:scoped-proxy />
要素を指定し、scoped-proxyを有効にする。
4.4.2.2.2. sessionスコープのBeanの利用¶
@Inject SessionCart sessionCart; // (1) @PostMapping(value = "add") public String addCart(@Validated ItemForm form, BindingResult result) { if (result.hasErrors()) { return "item/item"; } CartItem cartItem = beanMapper.map(form); Cart addedCart = cartService.addCartItem(sessionCart.getCart(), // (2) cartItem); sessionCart.setCart(addedCart); // (3) return "redirect:/cart"; }
項番 説明 (1) sessionスコープのBeanを、ControllerにInjectする。 (2) sessionスコープのBeanのメソッド呼び出しを行うと、セッションに格納されているオブジェクトが返却される。セッションにオブジェクトが格納されていない場合は、新たに生成されたオブジェクトが返却され、セッションにも格納される。上記例では、カートに追加する前に在庫数などのチェックを行うため、Serviceのメソッドを呼び出している。 (3) 上記例では、CartService
のaddCartItemメソッドの引数に渡したCart
オブジェクトと、返り値で返却されるCart
オブジェクトが、別のインスタンスになる可能性があるため、返却されたCart
オブジェクトをsessionスコープのBeanに設定している。Note
View(Thymeleaf)からsessionスコープのBeanを参照する方法
Thymeleafでは標準でSpEL(Spring Expression Language)式を利用することができ、SpEL式を用いることでControllerにおいて
Model
オブジェクトへBeanを追加しなくても、ThymeleafからsessionスコープのBeanを参照することができる。<table th:with="cart=${@sessionCart.cart}"> <!-- (1) --> <!--/* omitted */--> <tr th:each="item : ${cart.cartItems}"> <!-- (2) --> <td th:text="${item.id}"></td> <td th:text="${item.itemCode}"></td> <td th:text="${item.quantity}"></td> </tr> <!--/* omitted */--> </table>
項番 説明 (1) SpEL式を用いてsessionスコープのBeanを参照する。参照したBeanは、th:with
属性を利用してcart
変数に代入する。th:with
属性の詳細については、ローカル変数を定義するを参照されたい。 (2) sessionスコープのBeanを表示する。th:each
属性の詳細については、コレクションの要素に対して表示処理を繰り返すを参照されたい。
4.4.2.2.3. セッションに格納したオブジェクトの削除¶
Note
sessionスコープのBeanは、セッションが切れる時にDIコンテナによって破棄される。
DIコンテナがsessionスコープのBeanのライフサイクルを管理しているので、Bean自体の破棄はDIコンテナにまかせる。
@Controller @RequestMapping("order") public class OrderController { @Inject SessionCart sessionCart; // (1) // ... @PostMapping public String order() { // ... return "redirect:/order?complete"; } @GetMapping(params = "complete") public String complete(Model model, SessionStatus sessionStatus) { sessionCart.clearCart(); // (2) return "order/complete"; } }
項番 説明 (1) sessionスコープのBeanをインジェクションする。 (2) sessionスコープのBeanの状態をクリアし、注文済みの商品をカートから削除する
4.4.2.2.4. sessionスコープのBeanを使った処理の実装例¶
より具体的な実装例については、AppendixのsessionスコープのBeanを使った複数のControllerを跨いだ画面遷移の実装例を参照されたい。
4.4.2.3. セッション操作のデバッグログ出力¶
共通ライブラリの詳細は、HttpSessionEventLoggingListenerを参照されたい。
4.4.2.4. ThymeleafのWeb Context Object #session
を使用する¶
Thymeleafでは標準で HttpSession
オブジェクトを参照することが可能であり、ThymeleafのWeb Context Object #session
を使用すると、HttpSessionオブジェクトの情報を取得することができる。
<span th:text="${#session.getAttribute('testKey')"></span> <!-- (1) -->
項番 説明 (1)HttpSession
からtestKey
というidの属性値を取得する。
4.4.3. How to extend¶
4.4.3.1. 同一セッション内のリクエストの同期化¶
@SessionAttributes
アノテーション、またはsessionスコープのBeanを使用する場合は、同一セッション内のリクエストを同期化することを推奨する。org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter
の、synchronizeOnSessionをtrueにして、同一セッション内のリクエストを同期化することを、強く推奨する。以下のようなBeanPostProcessorを作成し、Bean定義することで実現できる。
- コンポーネント
package com.example.app.config; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.BeansException; import org.springframework.beans.factory.config.BeanPostProcessor; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; public class EnableSynchronizeOnSessionPostProcessor implements BeanPostProcessor { private static final Logger logger = LoggerFactory .getLogger(EnableSynchronizeOnSessionPostProcessor.class); @Override public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException { // NO-OP return bean; } @Override public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { if (bean instanceof RequestMappingHandlerAdapter) { RequestMappingHandlerAdapter adapter = (RequestMappingHandlerAdapter) bean; logger.info("enable synchronizeOnSession => {}", adapter); adapter.setSynchronizeOnSession(true); // (1) } return bean; } }
項番 説明 (1)org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter
のsetSynchronizeOnSessionメソッドの引数に、true
を指定すると、同一セッション内でのリクエストが同期化される。
- spring-mvc.xml
<bean class="com.example.app.config.EnableSynchronizeOnSessionPostProcessor" /> <!-- (2) -->
項番 説明 (2) (1)で作成した、BeanPostProcessor
をBean定義する。
Warning
synchronizeOnSessionにより同期化が行われるのはControllerの範囲であるため、 Viewでセッションスコープのフォームオブジェクトから値を取得している場合は、別スレッドから書き換えられた不正な値が得られる可能性がある。
4.4.4. Appendix¶
4.4.4.1. @SessionAttributes
アノテーションを使ったウィザード形式の画面遷移の実装例¶
ウィザード形式の画面遷移を行う処理を例に、@SessionAttributes
アノテーションを使った実装の説明を行う。
処理の仕様は、以下の通りとする。
- Entityの登録と、更新を行うための画面を提供する。
- 入力画面は、3画面で構成され、各画面で1項目ずつ入力を行う。
- 入力した値は、保存(登録/更新)する前に、確認画面で確認できる。
- 入力チェックは、画面遷移するタイミングで行い、エラーがある場合は、入力画面に戻る。
- 保存(登録/更新)する前に、すべての入力値に対する入力チェックを再度行い、エラーがある場合は、不正操作を通知するエラー画面を表示する。
- すべての入力値に対するチェックが妥当な場合は、入力データをデータベースに保存する。
基本的な画面遷移は、以下の通りとする。
実装例は、以下の通りである。
- フォームオブジェクト
public class WizardForm implements Serializable { private static final long serialVersionUID = 1L; // (1) @NotEmpty(groups = { Wizard1.class }) private String field1; // (2) @NotEmpty(groups = { Wizard2.class }) private String field2; // (3) @NotEmpty(groups = { Wizard3.class }) private String field3; // ... // (4) public static interface Wizard1 { } // (5) public static interface Wizard2 { } // (6) public static interface Wizard3 { } }
項番 説明 (1) 1ページ目の入力画面で入力するフィールド。 (2) 2ページ目の入力画面で入力するフィールド。 (3) 3ページ目の入力画面で入力するフィールド。 (4) 1ページ目の入力画面で入力されるフィールドであることを示すための、検証グループインタフェース。 (5) 2ページ目の入力画面で入力されるフィールドであることを示すための、検証グループインタフェース。 (6) 3ページ目の入力画面で入力されるフィールドであることを示すための、検証グループインタフェース。Note
検証グループについて
画面遷移時の入力チェックでは、該当ページのフィールドのみチェックする必要がある。 Bean Validationでは、検証グループを表すクラス、またはインタフェースを設けることで、検証するルールをグループ化することができる。 今回の実装例のケースでは、画面毎に検証グループを用意することで、画面毎の入力チェックを実現している。
- Controller
@Controller @RequestMapping("wizard") @SessionAttributes(types = { WizardForm.class, Entity.class }) // (7) public class WizardController { @Inject WizardService wizardService; @Inject BeanMapper beanMapper;
項番 説明 (7) 上記例では、フォームオブジェクト(WizardForm.class
)と、エンティティ(Entity.class
)のオブジェクトを、セッションに格納する。@ModelAttribute("wizardForm") // (8) public WizardForm setUpWizardForm() { return new WizardForm(); }
項番 説明 (8) 上記例では、セッションに格納するフォームオブジェクト(WizardForm
)を生成している。 無駄なオブジェクトの生成をなくすために、@ModelAttribute
アノテーションのvalue属性を指定している。// (9) @GetMapping(value = "create") public String initializeCreateWizardForm(SessionStatus sessionStatus) { sessionStatus.setComplete(); return "redirect:/wizard/create?form1"; } // (10) @GetMapping(value = "create", params = "form1") public String createForm1() { return "wizard/form1"; }
項番 説明 (9) 登録用入力画面を、初期表示するためのハンドラメソッド。操作途中のオブジェクトが、セッションに格納されている可能性があるため、このハンドラメソッドで、セッションに格納されているオブジェクトを削除しておく。 (10) 1ページ目の登録用入力画面を、表示するためのハンドラメソッド。// (11) @GetMapping(value = "{id}/update") public String initializeUpdateWizardForm(@PathVariable("id") Integer id, RedirectAttributes redirectAttributes, SessionStatus sessionStatus) { sessionStatus.setComplete(); return "redirect:/wizard/{id}/update?form1"; } // (12) @GetMapping(value = "{id}/update", params = "form1") public String updateForm1(@PathVariable("id") Integer id, WizardForm form, Model model) { Entity loadedEntity = wizardService.getEntity(id); beanMapper.map(loadedEntity, form); model.addAttribute(loadedEntity); // (13) return "wizard/form1"; }
項番 説明 (11) 更新用入力画面を、初期表示するためのハンドラメソッド。 (12) 1ページ目の更新用入力画面を、表示するためのハンドラメソッド。 (13) 取得したエンティティをModel
オブジェクトに追加し、セッションに格納する。上記例では、entity
という属性名で、セッションに格納される。// (14) @PostMapping(value = "save", params = "form2") public String saveForm2(@Validated(Wizard1.class) WizardForm form, // (15) BindingResult result) { if (result.hasErrors()) { return saveRedoForm1(); } return "wizard/form2"; } // (16) @PostMapping(value = "save", params = "form3") public String saveForm3(@Validated(Wizard2.class) WizardForm form, // (17) BindingResult result) { if (result.hasErrors()) { return saveRedoForm2(); } return "wizard/form3"; } // (18) @PostMapping(value = "save", params = "confirm") public String saveConfirm(@Validated(Wizard3.class) WizardForm form, // (19) BindingResult result) { if (result.hasErrors()) { return saveRedoForm3(); } return "wizard/confirm"; }
項番 説明 (14) 2ページ目の入力画面を、表示するためのハンドラメソッド。 (15) 1ページ目の入力画面で入力された値のみ、入力チェックするために、@Validated
アノテーションのvalue属性に、1ページ目の入力画面の検証グループ(Wizard1.class
)を指定する。 (16) 3ページ目の入力画面を、表示するためのハンドラメソッド。 (17) 2ページ目の入力画面で入力された値のみ、入力チェックするために、@Validated
アノテーションのvalue属性に、2ページ目の入力画面の検証グループ(Wizard2.class
)を指定する。 (18) 確認画面を表示するためのハンドラメソッド。 (19) 3ページ目の入力画面で入力された値のみ、入力チェックするために、@Validated
アノテーションのvalue属性に、3ページ目の入力画面の検証グループ(Wizard3.class
)を指定する。// (20) @PostMapping(value = "save") public String save(@ModelAttribute @Validated({ Wizard1.class, Wizard2.class, Wizard3.class }) WizardForm form, // (21) BindingResult result, Entity entity, // (22) RedirectAttributes redirectAttributes) { if (result.hasErrors()) { throw new InvalidRequestException(result); // (23) } beanMapper.map(form, entity); entity = wizardService.saveEntity(entity); // (24) redirectAttributes.addFlashAttribute(entity); // (25) return "redirect:/wizard/save?complete"; } // (26) @GetMapping(value = "save", params = "complete") public String saveComplete(SessionStatus sessionStatus) { sessionStatus.setComplete(); return "wizard/complete"; }
項番 説明 (20) 保存処理を実行するためのハンドラメソッド。 (21) 入力画面で入力された値を全てチェックするために、@Validated
アノテーションのvalue属性に、各入力画面の検証グループインタフェース(Wizard1.class
,Wizard2.class
,Wizard3.class
)を指定する。 (22) 保存するEntity.class
のオブジェクトを取得する。登録処理の場合は、新たに生成されたオブジェクト、更新処理の場合は、(14)の処理でセッションに格納したオブジェクトが取得される。 (23) アプリケーションが提供しているボタンを使って、画面遷移を行っていれば、このタイミングでエラーは発生しないので、不正な操作が行われた場合にInvalidRequestException
がthrowされる。なお、InvalidRequestException
は共通ライブラリから提供している例外クラスではないため、別途作成する必要がある。 (24) 入力値が反映されたEntity.class
のオブジェクトを保存する。 (25) リダイレクト先のハンドラメソッドで保存したEntity.class
のオブジェクトを参照できるようにするために、Flashスコープに格納する。 (26) 完了画面を表示するためのハンドラメソッド。// (27) @GetMapping(value = "save", params = "redoForm1") public String saveRedoForm1() { return "wizard/form1"; } // (28) @GetMapping(value = "save", params = "redoForm2") public String saveRedoForm2() { return "wizard/form2"; } // (29) @GetMapping(value = "save", params = "redoForm3") public String saveRedoForm3() { return "wizard/form3"; }
項番 説明 (27) 1ページ目の入力画面を、再表示するためのハンドラメソッド。 (28) 2ページ目の入力画面を、再表示するためのハンドラメソッド。 (29) 3ページ目の入力画面を、再表示するためのハンドラメソッド。
- Controllerの全ソース
@Controller @RequestMapping("wizard") @SessionAttributes(types = { WizardForm.class, Entity.class }) // (7) public class WizardController { @Inject EntityService wizardService; @Inject BeanMapper beanMapper; @ModelAttribute("wizardForm") // (8) public WizardForm setUpWizardForm() { return new WizardForm(); } // (9) @GetMapping(value = "create") public String initializeCreateWizardForm(SessionStatus sessionStatus) { sessionStatus.setComplete(); return "redirect:/wizard/create?form1"; } // (10) @GetMapping(value = "create", params = "form1") public String createForm1() { return "wizard/form1"; } // (11) @GetMapping(value = "{id}/update") public String initializeUpdateWizardForm(@PathVariable("id") Integer id, RedirectAttributes redirectAttributes, SessionStatus sessionStatus) { sessionStatus.setComplete(); return "redirect:/wizard/{id}/update?form1"; } // (12) @GetMapping(value = "{id}/update", params = "form1") public String updateForm1(@PathVariable("id") Integer id, WizardForm form, Model model) { Entity loadedEntity = wizardService.getEntity(id); beanMapper.map(loadedEntity, form); model.addAttribute(loadedEntity); // (13) return "wizard/form1"; } // (14) @PostMapping(value = "save", params = "form2") public String saveForm2(@Validated(Wizard1.class) WizardForm form, // (15) BindingResult result) { if (result.hasErrors()) { return saveRedoForm1(); } return "wizard/form2"; } // (16) @PostMapping(value = "save", params = "form3") public String saveForm3(@Validated(Wizard2.class) WizardForm form, // (17) BindingResult result) { if (result.hasErrors()) { return saveRedoForm2(); } return "wizard/form3"; } // (18) @PostMapping(value = "save", params = "confirm") public String saveConfirm(@Validated(Wizard3.class) WizardForm form, // (19) BindingResult result) { if (result.hasErrors()) { return saveRedoForm3(); } return "wizard/confirm"; } // (20) @PostMapping(value = "save") public String save(@ModelAttribute @Validated({ Wizard1.class, Wizard2.class, Wizard3.class }) WizardForm form, // (21) BindingResult result, Entity entity, // (22) RedirectAttributes redirectAttributes) { if (result.hasErrors()) { throw new InvalidRequestException(result); // (23) } beanMapper.map(form, entity); entity = wizardService.saveEntity(entity); // (24) redirectAttributes.addFlashAttribute(entity); // (25) return "redirect:/wizard/save?complete"; } // (26) @GetMapping(value = "save", params = "complete") public String saveComplete(SessionStatus sessionStatus) { sessionStatus.setComplete(); return "wizard/complete"; } // (27) @GetMapping(value = "save", params = "redoForm1") public String saveRedoForm1() { return "wizard/form1"; } // (28) @GetMapping(value = "save", params = "redoForm2") public String saveRedoForm2() { return "wizard/form2"; } // (29) @GetMapping(value = "save", params = "redoForm3") public String saveRedoForm3() { return "wizard/form3"; } }
- 1ページ目の入力画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Wizard Form(1/3)</title> </head> <body> <h1>Wizard Form(1/3)</h1> <form th:action="@{/wizard/save}" th:object="${wizardForm}" method="post"> <label for="field1">Field1</label> <input th:field="*{field1}"> <span th:errors="*{field1}"></span> <div> <button name="form2">Next</button> </div> </form> </body> </html>
- 2ページ目の入力画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Wizard Form(2/3)</title> </head> <body> <h1>Wizard Form(2/3)</h1> <!-- (31) --> <form th:action="@{/wizard/save}" th:object="${wizardForm}" method="post"> <label for="field2">Field2</label> <input th:field="*{field2}"> <span th:errors="*{field2}"></span> <div> <button name="redoForm1">Back</button> <button name="form3">Next</button> </div> </form> </body> </html>
項番 説明 (31) フォームオブジェクトをセッションに格納しているため、1ページ目の入力画面のフィールドを、hidden項目にする必要はない。
- 3ページ目の入力画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Wizard Form(3/3)</title> </head> <body> <h1>Wizard Form(3/3)</h1> <!-- (32) --> <form th:action="@{/wizard/save}" th:object="${wizardForm}" method="post"> <label for="field3">Field3</label> : <input th:field="*{field3}"> <span th:errors="*{field3}"></span> <div> <button name="redoForm2">Back</button> <button name="confirm">Confirm</button> </div> </form> </body> </html>
項番 説明 (32) フォームオブジェクトをセッションに格納しているため、1ページ目と2ページ目の入力画面のフィールドを、hidden項目にする必要はない。
- 確認画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Confirm</title> </head> <body> <h1>Confirm</h1> <!-- (33) --> <form th:action="@{/wizard/save}" th:object="${wizardForm}" method="post"> <div th:text="|Field1 : *{field1}|"></div> <div th:text="|Field2 : *{field2}|"></div> <div th:text="|Field3 : *{field3}|"></div> <div> <button name="redoForm3">Back</button> <button>OK</button> </div> </form> </body> </html>
項番 説明 (33) フォームオブジェクトをセッションに格納しているため、入力画面のフィールドを、hidden項目にする必要はない。
- 完了画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Complete</title> </head> <body> <h1>Complete</h1> <div> <div th:text="|ID : ${entity.id}|"></div> <div th:text="|Field1 : ${entity.field1}|"></div> <div th:text="|Field2 : ${entity.field2}|"></div> <div th:text="|Field3 : ${entity.field3}|"></div> </div> <div> <a th:href="@{/wizard/create}"> Continue operation of Create </a> </div> <div> <a th:href="@{/wizard/{id}/update(id=${entity.id})}"> Continue operation of Update </a> </div> </body> </html>
- spring-mvc.xml
<bean class="org.terasoluna.gfw.web.exception.SystemExceptionResolver"> <property name="exceptionCodeResolver" ref="exceptionCodeResolver" /> <!-- ... --> <property name="exceptionMappings"> <map> <!-- ... --> <entry key="InvalidRequestException" value="common/error/operationError" /> <!-- (34) --> </map> </property> <property name="statusCodes"> <map> <!-- ... --> <entry key="common/error/operationError" value="400" /> <!-- (35) --> </map> </property> <!-- ... --> </bean>
項番 説明 (34) 共通ライブラリから提供しているSystemExceptionResolver
のexceptionMappings
に、保存処理実行時に不正なリクエストを検知したことを、通知する例外InvalidRequestException
の、例外ハンドリングの定義を追加する。上記例では、 例外発生時の遷移先のリクエストパスとして、common/error/operationError
を指定している。 (35)SystemExceptionResolver
のstatusCodes
に、HttpSessionRequiredException
発生時のHTTPレスポンスコードを指定する。上記例では、 例外発生時のHTTPレスポンスコードとして、Bad Request(400
)を指定している。
- applicationContext.xml
<bean id="exceptionCodeResolver" class="org.terasoluna.gfw.common.exception.SimpleMappingExceptionCodeResolver"> <!-- Setting and Customization by project. --> <property name="exceptionMappings"> <map> <!-- ... --> <entry key="InvalidRequestException" value="w.xx.0004" /> <!-- (36) --> </map> </property> <property name="defaultExceptionCode" value="e.xx.0001" /> <!-- (37) --> </bean>
項番 説明 (36) 共通ライブラリから提供しているSimpleMappingExceptionCodeResolver
のexceptionMappings
に、InvalidRequestException
の例外ハンドリングの定義を追加する。上記例では、 例外発生時の例外コードとして、w.xx.0004
を指定している。この設定を追加しない場合は、デフォルトの例外コードが、ログに出力される。 (37) 例外発生時のデフォルトの例外コード。
4.4.4.2. sessionスコープのBeanを使った複数のControllerを跨いだ画面遷移の実装例¶
複数のControllerをまたいで画面遷移を行う処理を例に、sessionスコープのBeanを使った実装の説明を行う。
処理の仕様は、以下の通りとする。
- 商品をカートに追加する処理を提供する。
- カートに追加されている商品の、数量変更を行う処理を提供する。
- カートに格納されている商品を、注文する処理を提供する。
- 上記3つの処理は、それぞれ独立した機能として提供するため、別Controller(ItemController, CartController, OrderController)とする。
- カートは、上記3つの処理で共有するため、セッションに格納する。
- 商品をカートに追加した場合は、カート画面に遷移する。
画面遷移は、以下の通りとする。
実装例は、以下の通りである。
- sessionスコープのBeanとして定義するJavaBean
@Component @Scope(value = "session", proxyMode = ScopedProxyMode.TARGET_CLASS) public class SessionCart implements Serializable { private static final long serialVersionUID = 1L; private Cart cart; // (1) public Cart getCart() { if (cart == null) { cart = new Cart(); } return cart; } public void setCart(Cart cart) { this.cart = cart; } public void clearCart() { // (2) cart.clearCart(); } }
項番 説明 (1)Cart
というEntity(Domainオブジェクト)をラップしている。 (2) カートに追加された商品のオブジェクトをcart
から削除し,カートが空の状態にする。
- ItemController
@Controller @RequestMapping("item") public class ItemController { @Inject SessionCart sessionCart; @Inject CartService cartService; @Inject BeanMapper beanMapper; @ModelAttribute public ItemForm setUpItemForm() { return new ItemForm(); } // (3) @GetMapping public String view(Model model) { return "item/item"; } // (4) @GetMapping(value = "add") public String addCart(@Validated ItemForm form, BindingResult result) { if (result.hasErrors()) { return "item/item"; } CartItem cartItem = beanMapper.map(form); Cart cart = cartService.addCartItem(sessionCart.getCart(), // (5) cartItem); sessionCart.setCart(cart); // (6) return "redirect:/cart"; // (7) } }
項番 説明 (3) 商品画面を、表示するためのハンドラメソッド。 (4) 指定された商品を、カートに追加するためのハンドラメソッド。 (5) セッションに格納されているCart
オブジェクトを、Serviceのメソッドに渡す。 (6) Serviceのメソッドから返却されたCart
オブジェクトを、sessionスコープのBeanに反映する。sessionスコープのBeanに反映することで、Cart
オブジェクトがセッションに格納される。 (7) 商品をカートに追加した後に、カート画面を表示するためのリクエストに、リダイレクトする。別Controllerの画面に遷移する場合は、直接別Controllerにフォワードするのではなく、画面を表示するためのリクエストにリダイレクトすることを推奨する。
- CartController
@Controller @RequestMapping("cart") public class CartController { @Inject SessionCart sessionCart; @Inject CartService cartService; @Inject BeanMapper beanMapper; @ModelAttribute public CartForm setUpCartForm() { return new CartForm(); } // (8) @GetMapping public String cart(CartForm form) { beanMapper.map(sessionCart.getCart(), form); return "cart/cart"; } // (9) @PostMapping(params = "edit") public String edit(@Validated CartForm form, BindingResult result, Model model) { if (result.hasErrors()) { return "cart/cart"; } Cart cart = sessionCart.getCart(); Iterator<CartItemForm> itemForm = form.getCartItems().iterator(); for (CartItem item : cart.getCartItems()) { beanMapper.map(itemForm.next(), item); } cart = cartService.saveCart(cart); sessionCart.setCart(cart); // (10) return "redirect:/cart"; // (11) } }
項番 説明 (8) カート画面(数量変更画面)を表示するためのハンドラメソッド。 (9) 数量変更を、行うためのハンドラメソッド。 (10) Serviceのメソッドから返却されたCart
オブジェクトをsessionスコープのBeanに反映する。sessionスコープのBeanに反映することで、セッションに反映される。 (11) 数量変更を行った後に、カート画面(数量変更画面)を表示するためのリクエストに、リダイレクトする。更新処理を行った場合は、直接別Controllerにフォワードするのではなく、画面を表示するためのリクエストにリダイレクトしなければならない。
- OrderController
@Controller @RequestMapping("order") public class OrderController { @Inject SessionCart sessionCart; @ModelAttribute public OrderForm setUpOrderForm() { return new OrderForm(); } // (12) @GetMapping public String view() { return "order/order"; } // (13) @PostMapping public String order() { // ... return "redirect:/order?complete"; } // (14) @GetMapping(params = "complete") public String complete(Model model, SessionStatus sessionStatus) { sessionCart.clearCart(); return "order/complete"; } }
項番 説明 (12) 注文画面を、表示するためのハンドラメソッド。 (13) 注文処理を行うためのハンドラメソッド。 (14) 注文完了画面を表示するためのハンドラメソッド。
- 商品画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Item</title> </head> <body> <h1>Item</h1> <form th:action="@{/item/add}" th:object="${itemForm}" method="post"> <label for="itemCode">Item Code</label> : <input th:field="*{itemCode}"> <span th:errors="*{itemCode}"></span> <br> <label for="quantity">Quantity</label> : <input th:field="*{quantity}"> <span th:errors="*{quantity}"></span> <div> <!-- (15) --> <button>Add</button> </div> </form> <div> <a th:href="@{/cart}">Go to Cart</a> </div> </body> </html>
項番 説明 (15) 商品を追加するためのボタン。
- カート画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Cart</title> </head> <body th:with="cart=${@sessionCart.cart}"> <!-- (16) --> <h1>Cart</h1> <div th:switch="${!#lists.isEmpty(cart.cartItems)}"> <div th:case="true"> CART ID : <span th:text="${cart.id}"></span> <form th:object="${cartForm}" method="post"> <table border="1"> <thead> <tr> <th>ID</th> <th>ITEM CODE</th> <th>QUANTITY</th> </tr> </thead> <tbody> <tr th:each="item, rowStatus : ${cart.cartItems}"> <td th:text="${item.id}"></td> <td th:text="${item.itemCode}"></td> <td> <input th:field="*{cartItems[__${rowStatus.index}__].quantity}"> <span th:errors="*{cartItems[__${rowStatus.index}__].quantity}"></span> </td> </tr> </tbody> </table> <!-- (17) --> <button name="edit">Save</button> </form> <div> <!-- (18) --> <a th:href="@{/order}">Go to Order</a> </div> </div> <div th:case="*" th:text="Cart is empty."></div> </div> <div> <a th:href="@{/item}">Back to Item</a> </div> </body> </html>
項番 説明 (16) SpEL式を用いてsessionスコープのBeanを参照する。 (17) 数量を更新するためのボタン。 (18) 注文画面を表示するためのリンク。
- 注文画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Order</title> </head> <body> <h1>Order</h1> <table border="1" th:with="cart=${@sessionCart.cart}"> <thead> <tr> <th>ID</th> <th>ITEM CODE</th> <th>QUANTITY</th> </tr> </thead> <tbody> <span th:each="item, rowStatus : ${cart.cartItems}"> <tr> <td th:text="${item.id}"></td> <td th:text="${item.itemCode}"></td> <td th:text="${item.quantity}"></td> </tr> </span> </tbody> </table> <form th:object="${orderForm}" method="post"> <!-- (19) --> <button>Order</button> </form> <div> <a th:href="@{/cart}">Back to Cart</a> </div> <div> <a th:href="@{/item}">Back to Item</a> </div> </body> </html>
項番 説明 (19) 注文するためのボタン。
- 注文完了画面(テンプレートHTML)
<html xmlns:th="http://www.thymeleaf.org"> <head> <title>Order Complete</title> </head> <body> <h1>Order Complete</h1> <span th:text="|ORDER ID : ${order.id}|"></span> <table border="1"> <thead> <tr> <th>ID</th> <th>ITEM CODE</th> <th>QUANTITY</th> </tr> </thead> <tbody> <span th:each="item, rowStatus : ${cart.cartItems}"> <tr> <td th:text="${item.id}"></td> <td th:text="${item.itemCode}"></td> <td th:text="${item.quantity}"></td> </tr> </span> </tbody> </table> <br> <div> <a th:href="@{/item}">Back to Item</a> </div> </body> </html>