3.3. セッション外部管理¶
3.3.1. Overview¶
クラウド環境でオートスケーリングを利用した場合に、スケールイン発生時にセッションなどのアプリケーションサーバインスタンス固有の情報は失われてしまう。 その為、ロードバランサによるロードバランシングにおいてスティッキーセッションはOFFとし、アプリケーションの構造としてどのアプリケーションサーバインスタンスに リクエストが割り振られた場合でも業務継続可能とする為のセッション外部管理方式を示す。
3.3.1.1. セッション外部管理方式¶
セッション外部管理を行うために、Spring Session with Redisを利用した方式を以下に示す。 Redis構成は、ユーザ数(同時セッション数)が後々スケールできるようシャーディングを用いた構成で紹介している。
項番 | 説明 |
---|---|
(1)
|
ユーザは、同一のセッションIDでアクセスを行う。
|
(2)
|
SessionRepositoryFilter はセッションをラップする。 |
(3)
|
アプリケーションで作成したコントローラからgetSessionメソッドなどで、セッションへのアクセスを行った場合に、ラップ済みのセッションを通じてセッションを取得する。
|
(4)
|
ラップ済みのセッションは、ローカルサーバのキャッシュにセッション情報が存在しない時はRedisから取得する。一度アクセスを行うと、そのリクエストの間はローカルにセッション情報をキャッシュする。シャーディングされたRedisからの取得は、データに対してkeyのhashを計算して、該当するhash slotにアクセスして取得する。
|
(5)
|
レスポンスがコミットされたタイミングでRedisにセッション情報を格納する。
|
3.3.1.2. セッション外部管理構成¶
セッション外部管理を行う為の基本的な構成を以下に示す。
項番 | 説明 |
---|---|
(1)
|
ロードバランサのリクエスト振り分けはスティッキーセッションを使用せず、動的なスケーリンググループ内のAPサーバに対して均等に振り分ける。
|
(2)
|
アプリケーションではSpring Session with Redisを介してセッションへのアクセスを行う。
|
(3)
|
Spring Session with Redisは、Sharding Redis Clusterのいずれかのシャードに対してセッションの保存を行う。各シャードでは、可用性向上のための非同期のレプリケーションが行われる。
|
3.3.1.3. セッション同期タイミング¶
リクエスト中に、一度取得したセッション情報はキャッシュされていて、以降はキャッシュからセッション情報を取得する為、他のリクエストでのセッション情報への変更は反映されない。 Redisへ永続化を行うタイミングで各リクエストで行ったセッション情報の変更は上書きで保存されるため、後から永続化が行われたリクエストのセッション情報が反映される。
また、Redisへのセッションの永続化のタイミングは、デフォルトがレスポンスのコミット時となっている。
3.3.1.4. 制約事項¶
セッションの外部管理を行った場合は、「同一セッション内のリクエストの同期化」のような方法でリクエストを同期化することができないため、セッション情報の完全な同期が必要なケースは、セッションで情報を管理しないこと。
Note
二重送信防止で、セッションを利用したトランザクショントークンチェックは、トランザクショントークンの変更が即座に同期されないため、リクエストのタイミングに因っては、意図した動作をしないケースが存在する。 そのため、セッションの外部管理を行う場合は、セッションを利用したトランザクショントークンチェックの機能面で制限が発生する点に注意する。 代替手段としては、トランザクショントークンの永続化先をデータベースに変更してロックを使用した排他制御を行うか、アプリケーションを冪等に実装して二重送信が発生しても問題がないようにするとよい(後者の場合は二重送防止処理自体が不要になる)。
本ガイドラインでは、トランザクショントークンの永続化先をデータベースに変更する拡張方法について説明している。拡張方法については、 TransactionTokenの拡張方法 を参照。
Spring Session with Redisは、Keyspace Notificationsを使用してセッション生成・破棄イベントをアプリケーションに通知することが出来る。 イベント通知は全てのアプリケーションサーバに対して行われ、各サーバにおいて
HttpSessionListener
が実行されるため、HttpSessionListener
は冪等に実装する必要がある。 また、RedisはKeyspace NotificationsがOFFになっているので、破棄イベントを実装する場合はKeyspace NotificationsをONに設定する必要がある。 詳細は、SessionDeletedEvent and SessionExpiredEventを参照されたい。Servlet仕様では、セッションIDを示すHTTP Cookieの名称は、「JSESSIONID」だが、Spring Sessionを使用した場合のデフォルトは「SESSION」となる。 変更方法は、Spring Session - Custom Cookieを参照されたい。
3.3.1.5. Redis Clusterの一貫性保証¶
セッション外部管理構成 で説明したとおり、Redis Clusterにおける各シャードでは、マスタノードからスレーブノードへの非同期のレプリケーションが行われている。 以下の条件を満たす場合、データの書き込み完了をクライアントに通知したにもかかわらず、データを失う可能性がある。
- クライアントがマスタノードへの書き込み要求を行う。
- マスタノードは書き込み処理を行い、書き込み完了をクライアントに通知する。
- マスタノードからスレーブノードへのレプリケーションが完了する前にマスタノードがダウンする。
- スレーブノードがマスタノードへ昇格する。
この時、レプリケーションされなかったデータについては消失することになる。 また、クライアントが書き込み要求を行っているマスタノードがシャードから分断された場合についても書き込み要求を行っていたノードのダウンが発生するため、レプリケーションが行われなかったデータは消失する。
Redis Clusterを使用したセッションの外部管理を行う場合は、データ消失の可能性がある点に留意すること。
より詳しい情報は、Redis Cluster consistency guaranteesを参照されたい。
3.3.2. How to use¶
Spring Session with Redisの利用方法を示す。
3.3.2.1. 依存ライブラリの追加¶
セッション外部管理では、Spring Session with Redisを使用するための依存ライブラリを追加する必要がある。 定義方法は、以下を参照されたい。
pom.xml
<dependencies> <!-- (1) --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <!-- (2) --> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency> </dependencies>
項番 説明 (1) 依存ライブラリにspring-session-data-redis
を追加する。 (2) 依存ライブラリにjedis
を追加する。
3.3.2.2. Spring Session with Redisの設定¶
セッション外部管理を行うために、Spring Session with Redisを利用する。
application.yml
spring: session: # (1) store-type: redis # (2) timeoutSecond: 1800 # (3) redis: listener: concurrencyLimit: 2
項番 説明 (1)spring.session.store-type
にredisを指定する。 (2) セッションタイムアウトまでの時間を秒で設定する。ここでは、セッションタイムアウトまでの時間を1800秒(30分)に設定している。 (3)spring.redis.listener.concurrencyLimit
にSubscribe処理の際に使用するスレッドの上限を設定する。
application-context.xml
<!-- (1) --> <context:annotation-config/> <bean class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration"> <!-- (2) --> <property name="maxInactiveIntervalInSeconds" value="${spring.session.timeoutSecond}"/> </bean>
項番 説明 (1)<context:annotation-config />
とRedisHttpSessionConfiguration
の組み合わせで、springSessionRepositoryFilter
のという名前のSpring Beanを作成する。 (2)RedisHttpSessionConfiguration
のmaxInactiveIntervalInSeconds
にapplication.yml
で設定したセッションタイムアウトまでの時間を設定する。
xxx-env.xml
<!-- (1) --> <bean id="springSessionRedisTaskExecutor" class="org.springframework.core.task.SimpleAsyncTaskExecutor"> <property name="concurrencyLimit" value="${spring.redis.listener.concurrencyLimit}" /> </bean>
項番 説明 (1)RedisMessageListenerContainer
が使用するTaskExecutor
のBean定義を行う。Note
RedisMessageListenerContainer
は、Subscribe処理の際にspringSessionRedisTaskExecutor
のBean名で定義されたTaskExecutor
を使用し、Redis上のデータへアクセスを行う。 デフォルトで使用されるSimpleAsyncTaskExecutor
はSubscribeの都度、無制限に新規にスレッドを作成し、Redisのコネクションを取得するため、作成されるスレッド数を制限しておくことを推奨する。 上記の例では、デフォルトで使用されるSimpleAsyncTaskExecutor
に対してconcurrencyLimit
を設定することで、作成されるスレッド数に上限を設定している。
web.xml
<!-- (1) --> <filter> <filter-name>springSessionRepositoryFilter</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSessionRepositoryFilter</filter-name> <url-pattern>/*</url-pattern> <dispatcher>REQUEST</dispatcher> <dispatcher>ERROR</dispatcher> </filter-mapping> ・・・ <session-config> ... <!-- (2) --> <session-timeout>30</session-timeout> ... </session-config>
項番 説明 (1)DelegatingFilterProxy
を使用してspringSessionRepositoryFilter
を登録する。また、セッションが存在しない状態でフィルタを通過する前にエラーが発生した場合にもspringSessionRepositoryFilter
が適用されるよう、dispatcher
にERROR
も設定する。設定については、XML Servlet Container Initializationを参照されたい。 (2) セッションタイムアウトの時間は、RedisHttpSessionConfiguration
で設定しているので、web.xml
にsession-timeout
項目があれば、削除する。Note
dispatcher
に指定する値はシステム要件に応じて全てのリクエストに対してspringSessionRepositoryFilter
が適用されるよう設定すること。 例えば、JSPのincludeを行っている場合はdispatcher
にINCLUDE
を追加する必要がある。Note
DelegatingFilterProxy
はfilter-name
で指定した名前(上記の例ではspringSessionRepositoryFilter
)でDIコンテナからBeanを取得して、処理を委譲する。対象のBeanはFilter
を実装する必要がある。Springの下で統一的にFilter
が管理でき、コンテナ上の各種Beanを利用してFilter
が実装できるなどのメリットがある。Warning
springSessionRepositoryFilter
の登録順序は、HttpSession
を使用する他のFilter
より前に登録する必要がある。
3.3.2.2.1. エンドポイントの設定¶
エンドポイント設定は、Spring Data Redisの設定にて定義する。 詳細は、Redis Clusterを参照されたい。
application.yml
spring: redis: cluster: # (1) nodes: - 127.0.0.1:30001 - 127.0.0.1:30002 - 127.0.0.1:30003
項番 説明 (1)spring.redis.cluster.nodes
にすべてのノードを追加する。 詳細は、Enabling Redis Clusterを参照されたい。
3.3.2.3. クラウドベンダの利用¶
クラウドベンダ提供の環境を利用する場合のガイドラインについて記載箇所を示しておく。
3.3.2.3.1. Amazon Web Service¶
クラウドベンダとしてAWSを使用する場合のセッション外部管理については、 セッション外部管理 を参照されたい。
3.3.3. How to extend¶
本ガイドラインでは、拡張方法や応用的な使用方法を示す。
3.3.3.1. セッション永続化タイミングの変更¶
セッション永続化のタイミングは、デフォルトでレスポンスのコミット時になっているが、以下の様に定義することでsetAttribute
およびremoveAttribute
メソッド呼び出し時に変更することができる。
application.yml
spring: session: redis: flush-mode: immediate #(1)
項番 説明 (1)spring.session.redis.flush-mode
にimmediate
を設定する。デフォルトは、on-save
となっている。Warning
immediate
を設定する際の注意事項を以下に示す。
setAttrubute
の実行回数が多い場合は、頻繁にIOが発生するため性能に影響が出る。setAttrubute
が複数実行される処理に並行し、readしている人が別にいた場合に、変更途中のセッションが読まれてしまう可能性が高まる。getAttribute
を使用して取得したオブジェクトに対する変更を行っても永続化は行われない。ただし、他の属性に対するsetAttribute
およびremoveAttribute
メソッド実行時に全てのセッション情報が永続化される。Note
immediate
を設定している場合でも、レスポンスのコミット時の永続化は行われる。
3.3.3.2. HttpSessionListenerを利用する場合の設定方法¶
HttpSessionListenerを使用する場合の設定方法を以下に示す。詳細は、HttpSessionListenerを参照されたい。
applicationContext.xml
<!-- (1) --> <bean class="org.terasoluna.gfw.web.logging.HttpSessionEventLoggingListener" />
項番 説明 (1) 使用するHttpSessionListener
をBean定義する。
3.3.3.3. TransactionTokenの拡張方法¶
Macchinetta Server Framework for Java (1.x) Development Guideline 4.5. 二重送信防止 にて説明しているトランザクショントークンチェックについて、共通ライブラリから提供しているトランザクショントークンチェック機能はトークン情報の格納先をセッションとしている。 そのため、Spring Sessionによるセッションの外部管理を行う場合、セッションの同期化を行うことができないことにより二重送信を防止できないケースがある。 本ガイドラインでは、MyBatis3を使用してトークン情報の格納先をデータベースへ変更する拡張方法について説明する。
実装が必要な要素は以下のとおり。
- トランザクショントークン情報を格納するテーブル
- DBアクセスを行うRepositoryインタフェースおよびマッピングファイル
- トランザクショントークン情報の生成およびテーブルへの格納を行う
TransactionTokenStore
インタフェースの実装クラス - セッション破棄時にトランザクショントークン情報の削除を行う
EventListener
クラス - アプリケーションから利用するためのBean定義
3.3.3.3.1. テーブル構成例¶
本ガイドラインで紹介する拡張方法では、以下のようなテーブルにトランザクショントークン情報を格納する実装を行う。
createtable.sql
create table transaction_token ( token_name varchar(256) not null, token_key varchar(32) not null, token_value varchar(32) not null, session_id varchar(256) not null, sequence bigint, constraint pk_transaction_token primary key (token_name, token_key, session_id) ); create index transaction_token_index_delete_older on transaction_token(token_name, session_id); create index transaction_token_index_delete_older_sequence on transaction_token(sequence); create index transaction_token_index_clean on transaction_token(session_id); create sequence transaction_token_sequence;
3.3.3.3.2. Repositoryインタフェースおよびマッピングファイル¶
DBアクセスを行うRepositoryインタフェースおよびマッピングファイルを作成する。
StoredTransactionTokenRepository.java
public interface StoredTransactionTokenRepository { // (1) StoredTransactionToken findOneForUpdate(@Param("tokenName") String tokenName, @Param("tokenKey") String tokenKey, @Param("sessionId") String sessionId); // (2) void delete(@Param("tokenName") String tokenName, @Param("tokenKey") String tokenKey, @Param("sessionId") String sessionId); // (3) void insert(StoredTransactionToken token); // (4) void deleteOlderThanLatest(@Param("tokenName") String tokenName, @Param("sessionId") String sessionId, @Param("num") int num); // (5) void deleteBySessionId(@Param("sessionId") String sessionId); }
項番 説明 (1) トークン名およびトークンキーを元にレコードを取得するメソッド。StoredTransactionToken
は、テーブル構成に対応するEntityクラスである。 (2) トークン名およびトークンキーを元にレコードを削除するメソッド。 (3) レコードを1件挿入するメソッド。 (4) トークン名およびセッションIDを元に、タイムスタンプ降順で指定件数以降のレコードを削除するメソッド。 (5) セッション破棄時に、セッションIDに紐づくレコードを削除するメソッド。
StoredTransactionTokenRepository.xml
<mapper namespace="com.example.domain.repository.StoredTransactionTokenRepository"> <resultMap id="storedTransactionTokenresultMap" type="StoredTransactionToken"> <id property="tokenName" column="token_name" /> <id property="tokenKey" column="token_key" /> <result property="tokenValue" column="token_value" /> <result property="sessionId" column="session_id" /> <result property="sequence" column="sequence" /> </resultMap> <!-- (1) --> <select id="findOneForUpdate" resultMap="storedTransactionTokenresultMap"> <![CDATA[ SELECT token_name, token_key, token_value, session_id, sequence FROM transaction_token WHERE token_name = #{tokenName} AND token_key = #{tokenKey} AND session_id = #{sessionId} FOR UPDATE ]]> </select> <!-- (2) --> <delete id="delete"> <![CDATA[ DELETE FROM transaction_token WHERE token_name = #{tokenName} AND token_key = #{tokenKey} AND session_id = #{sessionId} ]]> </delete> <!-- (3) --> <insert id="insert" parameterType="StoredTransactionToken"> <![CDATA[ INSERT INTO transaction_token ( token_name, token_key, token_value, session_id, sequence ) VALUES ( #{tokenName}, #{tokenKey}, #{tokenValue}, #{sessionId}, nextval('transaction_token_sequence') ) ]]> </insert> <!-- (4) --> <delete id="deleteOlderThanLatest"> <![CDATA[ DELETE FROM transaction_token WHERE sequence IN ( SELECT sequence FROM transaction_token WHERE token_name = #{tokenName} AND session_id = #{sessionId} ORDER BY sequence DESC OFFSET #{num} ) ]]> </delete> <!-- (5) --> <delete id="deleteBySessionId"> <![CDATA[ DELETE FROM transaction_token WHERE session_id = #{sessionId} ]]> </delete> </mapper>
項番 説明 (1)findOneForUpdate
メソッドに対応するSQL。SELECT FOR UPDATEを使用し、ロックを取得することでトランザクショントークンのチェック処理に対して排他制御を行う。 (2)delete
メソッドに対応するSQL。 (3)insert
メソッドに対応するSQL。 (4)deleteOlderThanLatest
メソッドに対応するSQL。 (5)deleteBySessionId
メソッドに対応するSQL。
3.3.3.3.3. TransactionTokenStoreの実装¶
トークン情報の格納を行うTransactionTokenStore
インターフェースの実装クラスを作成する。
実装する各メソッドの役割については、TransactionTokenStoreインターフェースを参照のこと。
MyBatisTransactionTokenStore.java
public class MyBatisTransactionTokenStore implements TransactionTokenStore { @Inject StoredTransactionTokenRepository tokenRepository; @Inject JodaTimeDateFactory dateFactory; private final int transactionTokenSizePerTokenName; private final TokenStringGenerator generator; public MyBatisTransactionTokenStore(int transactionTokenSizePerTokenName, TokenStringGenerator generator) { this.transactionTokenSizePerTokenName = transactionTokenSizePerTokenName; this.generator = generator; } public MyBatisTransactionTokenStore(int transactionTokenSizePerTokenName) { this(transactionTokenSizePerTokenName, new TokenStringGenerator()); } public MyBatisTransactionTokenStore() { this(10, new TokenStringGenerator()); } @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public String getAndClear(TransactionToken transactionToken) { // (1) String name = transactionToken.getTokenName(); String key = transactionToken.getTokenKey(); String sessionId = getSession().getId(); try { StoredTransactionToken token = tokenRepository.findOneForUpdate(name, key, sessionId); if (token == null) { return null; } tokenRepository.delete(name, key, sessionId); return token.getTokenValue(); } catch (PessimisticLockingFailureException e) { } return null; } @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void remove(TransactionToken transactionToken) { // (2) String name = transactionToken.getTokenName(); String key = transactionToken.getTokenKey(); String sessionId = getSession().getId(); tokenRepository.delete(name, key, sessionId); } @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public String createAndReserveTokenKey(String tokenName) { // (3) String sessionId = getSession().getId(); tokenRepository.deleteOlderThanLatest(tokenName, sessionId, transactionTokenSizePerTokenName - 1); return generator.generate(UUID.randomUUID().toString()); } @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void store(TransactionToken transactionToken) { // (4) StoredTransactionToken token = new StoredTransactionToken(); token.setTokenName(transactionToken.getTokenName()); token.setTokenKey(transactionToken.getTokenKey()); token.setTokenValue(transactionToken.getTokenValue()); token.setSessionId(getSession().getId()); tokenRepository.insert(token); getSession(); } HttpSession getSession() { return getRequest().getSession(true); } HttpServletRequest getRequest() { return ((ServletRequestAttributes) RequestContextHolder .currentRequestAttributes()).getRequest(); } }
項番 説明 (1)getAndClear
メソッドを実装する。データベースに格納したトランザクショントークン情報のレコードをロックした上で取得し、トランザクショントークン情報をデータベースから削除する。当該メソッドはトランザクショントークンチェック時に動作し、チェックに使用するレコードをロックして排他制御を行うことで、同一のトランザクショントークン情報が複数回使用されないことを保証する。 (2)remove
メソッドを実装する。当該メソッドは@TransactionTokenCheck
を付与したメソッド終了後に動作し、トランザクショントークン情報の削除を行う。 (3)createAndReserveTokenKey
メソッドを実装する。当該メソッドは@TransactionTokenCheck
を付与したメソッド終了後に動作し、次回チェック用のトランザクショントークン情報の生成を行うとともに、トークン名およびセッションIDに紐づく古い世代のレコードを削除する。 (4)store
メソッドを実装する。当該メソッドはトランザクショントークン情報のデータベースへの格納を行う。セッションが無効になった際に格納されたトランザクショントークン情報の削除を行うため、INSERTを行った後にセッションを取得し、ApplicationEvent
による通知を行う。
3.3.3.3.4. HttpSessionListenerの実装¶
セッション破棄時のHttpSessionDestroyedEvent
を検知してトランザクショントークン情報の削除を行うEventListener
クラスを作成する。
TransactionTokenCleaningListener.java
public class TransactionTokenCleaningListener { private static final Logger logger = LoggerFactory.getLogger(TransactionTokenCleaningListener.class); @Inject StoredTransactionTokenRepository tokenRepository; @EventListener // (1) @Transactional public void sessionDestroyed(HttpSessionDestroyedEvent event) { String sessionId = event.getSession().getId(); try { tokenRepository.deleteBySessionId(sessionId); logger.info("Transaction tokens created by sessionId={} have been cleaned.", sessionId); } catch (DataAccessException e) { logger.warn("Failed to clean abandoned transaction tokens created by sessionId={}.", sessionId, e); // ignore } } }
項番 説明 (1)@EventListener
アノテーションを付与し、セッション破棄時にPublishされるHttpSessionDestroyedEvent
を検知してセッションIDによるトランザクショントークン情報の削除を行うメソッドを実装する。
3.3.3.3.5. アプリケーションでの利用方法¶
本ガイドラインで紹介する拡張方法を使用した場合においても、ControllerやJSPからの利用方法は同一である。詳細は、トランザクショントークンチェックのControllerでの利用方法 および トランザクショントークンチェックのView(JSP)での利用方法 を参照されたい。
本ガイドラインでは、アプリケーションから利用するためのBean定義方法について説明する。
Macchinetta Server Framework for Java (1.x) Development Guideline 4.5.2.3.5. トランザクショントークンチェックを使用するための設定 にて説明している、HandlerInterceptor
の設定について、TransactionTokenInterceptor
で使用されるTransactionTokenStore
の実装クラスが作成したMyBatisTransactionTokenStore
となるようBean定義を行う。
spring-mvc.xml
<mvc:interceptor> <mvc:mapping path="/**" /> <mvc:exclude-mapping path="/resources/**" /> <mvc:exclude-mapping path="/**/*.html" /> <bean class="org.terasoluna.gfw.web.token.transaction.TransactionTokenInterceptor"> <!-- (1) --> <constructor-arg index="0"> <bean class="org.terasoluna.gfw.web.token.TokenStringGenerator" /> </constructor-arg> <constructor-arg index="1"> <bean class="org.terasoluna.gfw.web.token.transaction.TransactionTokenInfoStore" /> </constructor-arg> <constructor-arg index="2"> <bean class="com.example.token.MyBatisTransactionTokenStore" /> </constructor-arg> </bean> </mvc:interceptor>
項番 説明 (1)TransactionTokenInterceptor
のコンストラクタとして、TokenStringGenerator
、TransactionTokenInfoStore
および作成したMyBatisTransactionTokenStore
を指定する。
HttpSessionListenerによるトークン削除を有効化するため、以下の設定を行う。
applicationContext.xml
<!-- (1) --> <bean id="transactionTokenCleaningListener" class="com.example.token.TransactionTokenCleaningListener" />
項番 説明 (1) 作成したTransactionTokenCleaningListener
のBean定義を行う。