Spring Session のストレージとして Google Datastore を使う

# 1. はじめに

この度、業務で必要になりそうだったので、[Spring Session](https://spring.io/projects/spring-session) のストレージとして Google Datastore を使う『Spring Session for Google Datastore』を作成しました。
完成直後に残念ながら諸事情によりお蔵入りしましたが（泣）、せっかく作ったものを捨てるのは勿体ないので、Qiitaに記事を投稿することにしました。

ちなみに開発言語は Kotlin です。
はじめての Kotlin なので、多少おかしなところがあるかもしれません。
また、コードには改善の余地が残っている部分があります。これについては記事中に改善の方向性を示しますので、ご了承ください。

なお、『Spring Session for Google Datastore』は以下情報を参考にしており、MongoDB 用の実装を Datastore 用に書き換えています。
（この記事のタイトルも~~パクらせて~~参考にさせていただきました。）

https://qiita.com/tmurakam99/items/2569037d7f1f6612006c

とても参考になりました。ありがとうございます。＞ @tmurakam99 さん

なお、諸事情によりソースコードは GitHub 等に配置できないため、この記事にコードをペタペタ貼り付けます。


# 2. 免責事項
当サイトに掲載されたソースコードによって万が一損害等が生じたとしても、**私や当社は一切の責任を負いかねます**ので、その点をご理解いただいたうえで、ご利用ください。


# 3. 環境

Kotlin バージョン: 1.9.25
Java バージョン: 21
spring-cloud バージョン: 2024.0.0
spring-cloud-gcp バージョン: 6.0.0

# 4. pom.xml

```xml:pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.4.2</version>
		<relativePath/> <!-- lookup parent from repository -->
	</parent>
	<groupId>jp.co.cs</groupId><!-- (仮) 適当に変更してください -->
	<artifactId>spring-session-ext-datastore</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>spring-session-ext-datastore</name>
	<description>Spring Session with Google Datastore backend</description>

    <repositories>
		<repository>
			<id>central</id>
			<url>https://repo1.maven.org/maven2</url>
		</repository>
	</repositories>
	<pluginRepositories>
		<pluginRepository>
			<id>central</id>
			<url>https://repo1.maven.org/maven2/</url>
			<releases>
				<enabled>true</enabled>
			</releases>
		</pluginRepository>
	</pluginRepositories>

	<properties>
		<java.version>21</java.version>
		<kotlin.version>1.9.25</kotlin.version>
		<spring-cloud.version>2024.0.0</spring-cloud.version>
		<spring-cloud-gcp.version>6.0.0</spring-cloud-gcp.version>
	</properties>
	<dependencyManagement>
		<dependencies>
			<dependency>
				<groupId>com.google.cloud</groupId>
				<artifactId>spring-cloud-gcp-dependencies</artifactId>
				<version>${spring-cloud-gcp.version}</version>
				<type>pom</type>
				<scope>import</scope>
			</dependency>
		</dependencies>
	</dependencyManagement>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
			<exclusions>
				<exclusion>
					<groupId>org.apache.logging.log4j</groupId>
					<artifactId>log4j-to-slf4j</artifactId>
				</exclusion>
			</exclusions>
		</dependency>
		<dependency>
			<groupId>org.springframework.session</groupId>
			<artifactId>spring-session-core</artifactId>
		</dependency>
		<dependency>
			<groupId>com.google.cloud</groupId>
			<artifactId>spring-cloud-gcp-starter</artifactId>
		</dependency>
		<dependency>
			<groupId>com.google.cloud</groupId>
			<artifactId>spring-cloud-gcp-starter-logging</artifactId>
		</dependency>
		<dependency>
			<groupId>com.google.cloud</groupId>
			<artifactId>spring-cloud-gcp-starter-data-datastore</artifactId>
		</dependency>
		<dependency>
			<groupId>com.fasterxml.jackson.module</groupId>
			<artifactId>jackson-module-kotlin</artifactId>
		</dependency>
		<dependency>
			<groupId>org.jetbrains.kotlin</groupId>
			<artifactId>kotlin-reflect</artifactId>
		</dependency>
		<dependency>
			<groupId>org.jetbrains.kotlin</groupId>
			<artifactId>kotlin-stdlib</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>
		<dependency>
			<groupId>org.jetbrains.kotlin</groupId>
			<artifactId>kotlin-test-junit5</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<sourceDirectory>${project.basedir}/src/main/kotlin</sourceDirectory>
		<testSourceDirectory>${project.basedir}/src/test/kotlin</testSourceDirectory>
		<plugins>
			<plugin>
				<groupId>org.jetbrains.kotlin</groupId>
				<artifactId>kotlin-maven-plugin</artifactId>
				<configuration>
					<args>
						<arg>-Xjsr305=strict</arg>
					</args>
					<compilerPlugins>
						<plugin>spring</plugin>
					</compilerPlugins>
				</configuration>
				<dependencies>
					<dependency>
						<groupId>org.jetbrains.kotlin</groupId>
						<artifactId>kotlin-maven-allopen</artifactId>
						<version>${kotlin.version}</version>
					</dependency>
				</dependencies>
			</plugin>
			<plugin>
				<groupId>org.apache.maven.plugins</groupId>
				<artifactId>maven-source-plugin</artifactId>
				<executions>
					<execution>
						<id>attach-sources</id>
						<goals>
							<goal>jar</goal>
						</goals>
					</execution>
				</executions>
			</plugin>
		</plugins>
	</build>
</project>
```

ライブラリの管理は Maven を使いました。
Datastore へのアクセスは Spring Cloud GCP の機能である「spring-cloud-gcp-starter-data-datastore」を使います。

# 5. Kotlin コード

以下、6つのコード(のみ)で構成されています。
※掲載ソースコードはパッケージ宣言(`package ～`)を削除しています。

* CustomNamespaceProvider.kt
* CustomNamespaceProviderFilter.kt
* DatastoreSession.kt
* DatastoreSessionConfiguration.kt
* DatastoreSessionRepository.kt
* EnableDatastoreHttpSession.kt

## 5.1. CustomNamespaceProvider.kt

```kotlin:CustomNamespaceProvider.kt
import com.google.cloud.spring.autoconfigure.datastore.DatastoreNamespaceProvider
import org.springframework.stereotype.Component
import java.util.function.Consumer

/**
 * Custom Namespace Provider
 */
@Component
class CustomNamespaceProvider : DatastoreNamespaceProvider, Consumer<String?> {
    companion object {
        val CUSTOM_NAMESPACE: ThreadLocal<String?> = ThreadLocal<String?>()
    }

    /**
     * Gets a result.
     *
     * @return a result
     */
    override fun get(): String? {
        return CUSTOM_NAMESPACE.get()
    }

    /**
     * Performs this operation on the given argument.
     *
     * @param ns namespace
     */
    override fun accept(ns: String?) {
        CUSTOM_NAMESPACE.set(ns)
    }

    /**
     * Removes the current thread's value
     */
    fun remove() {
        CUSTOM_NAMESPACE.remove()
    }
}
```

Datastore のネームスペース(名前空間)の切り替えを行うプロバイダです。
ネームスペースの値は ThreadLocal で管理しています。

なお、使用するネームスペースがシステム全体で一意なのであれば、当クラスは不要です。
その場合は、アプリケーション設定ファイル(application.properties)に
`spring.cloud.gcp.datastore.namespace=＜ネームスペース＞`
を定義してください。
※パラメータの詳細については以下をご確認ください。

https://googlecloudplatform.github.io/spring-cloud-gcp/reference/html/datastore.html

## 5.2. CustomNamespaceProviderFilter.kt

```kotlin:CustomNamespaceProviderFilter.kt
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.web.filter.OncePerRequestFilter

class CustomNamespaceProviderFilter(private val nsProvider: CustomNamespaceProvider) : OncePerRequestFilter() {

    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain
    ) {
        try {
            filterChain.doFilter(request, response)
        } finally {
            // 後処理
            nsProvider.remove() // ThreadLocal変数の値を削除
        }
    }
}
```

[ネームスペース切り替えプロバイダ(CustomNamespaceProvider)](#51-customnamespaceproviderkt) の ThreadLocal 変数の値をクリアするために作成したフィルタクラスです。
ThreadLocal 変数の値を放置するとメモリリークの要因になるため、処理の最後でクリアするようにしています。

:::note info
ここではThreadLocal 変数の値のクリアのみを行っていますが、例えばこの処理の先頭でネームスペースの初期値をセットするようにしてもよいかもしれません。
:::

## 5.3. DatastoreSession.kt

```kotlin:CustomNamespaceProviderFilter.kt
import com.google.cloud.datastore.Key
import com.google.cloud.spring.data.datastore.core.mapping.Entity
import com.google.cloud.spring.data.datastore.core.mapping.Unindexed
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.Transient
import org.springframework.session.Session
import java.io.*
import java.time.Duration
import java.time.Instant
import java.util.*
import java.util.concurrent.TimeUnit

/**
 * Datastore Session
 */
@Entity(name = "DatastoreSession")
open class DatastoreSession() : Session {
    companion object {
        const val DEFAULT_MAX_INACTIVE_INTERVAL_IN_SEC: Long = 30 * 60
        const val KEY_SESSION_ID: String = "sessionId"
        const val KEY_EXPIRE_TIME: String = "expireTime"
    }

    @Id
    var key: Key? = null

    /**
     * Session ID
     */
    var sessionId: String

    /**
     * Serialized session attributes
     */
    @Unindexed
    var serializedAttributes: ByteArray? = null

    /**
     * Session attributes (not saved to Datastore)
     */
    @Transient
    var attributes: MutableMap<String, Any?>

    /**
     * Creation time
     */
    var creationTime: Long

    /**
     * Last accessed time
     */
    var lastAccessedTime: Long

    /**
     * Max inactive interval (sec)
     */
    var maxInactiveIntervalInSeconds: Long

    /**
     * Expire time (epoch in ms)
     */
    var expireTime: Long = 0

    /**
     * Primary constructor
     */
    init {
        // keyのセット(生成)は外部から行う(project-idは@Autowiredしないと取得できないので)
        this.sessionId = generateId();
        this.attributes = HashMap<String, Any?>()
        this.creationTime = Instant.now().toEpochMilli()
        this.lastAccessedTime = creationTime
        this.maxInactiveIntervalInSeconds = DEFAULT_MAX_INACTIVE_INTERVAL_IN_SEC
        updateExpireTime()
    }

    private fun generateId(): String {
        return UUID.randomUUID().toString()
    }

    override fun getId(): String {
        return this.sessionId
    }

    override fun changeSessionId(): String {
        this.sessionId = generateId()
        return this.sessionId
    }

    override fun <T : Any?> getAttribute(attributeName: String): T {
        @Suppress("UNCHECKED_CAST")
        return this.attributes[attributeName] as T
    }

    override fun getAttributeNames(): MutableSet<String> {
        return this.attributes.keys.toMutableSet()
    }

    override fun setAttribute(attributeName: String, attributeValue: Any?) {
        if (attributeValue !is Serializable) {
            throw IllegalArgumentException("Not serializable: $attributeName");
        }
        this.attributes[attributeName] = attributeValue;
    }

    override fun removeAttribute(attributeName: String) {
        this.attributes.remove(attributeName)
    }

    override fun getCreationTime(): Instant {
        return Instant.ofEpochMilli(this.creationTime);
    }

    override fun setLastAccessedTime(lastAccessedTime: Instant) {
        this.lastAccessedTime = lastAccessedTime.toEpochMilli()
        updateExpireTime()
    }

    override fun getLastAccessedTime(): Instant {
        return Instant.ofEpochMilli(this.lastAccessedTime)
    }

    override fun setMaxInactiveInterval(interval: Duration) {
        this.maxInactiveIntervalInSeconds = TimeUnit.MILLISECONDS.toSeconds(interval.toMillis())
        updateExpireTime()
    }

    override fun getMaxInactiveInterval(): Duration {
        return Duration.ofSeconds(this.maxInactiveIntervalInSeconds)
    }

    fun getExpireTime(): Instant {
        return Instant.ofEpochMilli(this.expireTime)
    }

    private fun updateExpireTime() {
        this.expireTime = this.lastAccessedTime + TimeUnit.SECONDS.toMillis(this.maxInactiveIntervalInSeconds)
    }

    override fun isExpired(): Boolean {
        val now = Instant.now().toEpochMilli()
        return this.expireTime <= now
    }

    /**
     * Serialize session attributes
     */
    fun serializeAttributes() {
        try {
            ByteArrayOutputStream().use { bos ->
                ObjectOutputStream(bos).use { oos ->
                    oos.writeObject(this.attributes)
                    oos.flush()
                    this.serializedAttributes = bos.toByteArray()
                }
            }
        } catch (e: IOException) {
            this.serializedAttributes = ByteArray(0)
        }
    }

    /**
     * Deserialize session attributes
     */
    fun deserializeAttributes() {
        try {
            ByteArrayInputStream(this.serializedAttributes).use { bis ->
                ObjectInputStream(bis).use { ois ->
                    @Suppress("UNCHECKED_CAST")
                    this.attributes = ois.readObject() as MutableMap<String, Any?>
                }
            }
        } catch (e: Exception) {    // Java の catch (IOException | ClassNotFoundException e)
            when(e) {
                is IOException, is ClassNotFoundException -> {
                    this.attributes = HashMap<String, Any?>()
                }
                else -> throw e
            }
        }
    }

    override fun toString(): String {
        return "DatastoreSession(key=$key, sessionId='$sessionId', serializedAttributes=${serializedAttributes?.contentToString()}, attributes=$attributes, creationTime=$creationTime, lastAccessedTime=$lastAccessedTime, maxInactiveIntervalInSeconds=$maxInactiveIntervalInSeconds, expireTime=$expireTime)"
    }

}
```

Datastore に保存されるセッション情報エンティティのクラスです。
プロパティ変数 `serializedAttributes` に格納される値は、`HttpSession` の `setAttribute(～)` メソッドでセッションスコープに保存されるデータです（正確にはデータを格納したマップの実体をシリアライズしたもの）。

:::note info
`com.google.cloud.datastore.Key` 型のプロパティ変数 `key` に `@Id` を設定していますが、これは私の開発ルール上の都合です。
たとえばこの `key` 変数の型を `String` 型や `Long` 型に変更してもよいですし（巷のサンプルコードはこのパターンが多いですね）、`key` 変数を削除して `sessionId` に `@Id` を設定してもよいと思います。
:::

## 5.4. DatastoreSessionConfiguration.kt

```kotlin:DatastoreSessionConfiguration.kt
import com.fasterxml.jackson.core.ErrorReportConfiguration
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration
import org.springframework.session.web.http.SessionRepositoryFilter

/**
 * DatastoreSession Configuration
 */
@Configuration
@SpringBootApplication  // これがないと @Autowired できない
class DatastoreSessionConfiguration {
    @Bean
    fun springSessionRepositoryFilter(repository: DatastoreSessionRepository?): SessionRepositoryFilter<DatastoreSession> {
        val filter = SessionRepositoryFilter(repository)
        return filter
    }

    @Bean
    fun datastoreSessionRepository(): DatastoreSessionRepository {
        return DatastoreSessionRepository()
    }

    @Bean
    fun customNamespaceProviderFilter(nsProvider: CustomNamespaceProvider): FilterRegistrationBean<CustomNamespaceProviderFilter> {
        val bean = FilterRegistrationBean(CustomNamespaceProviderFilter(nsProvider))
        bean.addUrlPatterns("/*")
        bean.order = Integer.MIN_VALUE
        return bean
    }
}
```

セッション・リポジトリ・フィルタ (SessionRepositoryFilter) 、Datastore 用の[セッション・リポジトリ (DatastoreSessionRepository)](#55-datastoresessionrepositorykt) 、[ネームスペース・プロバイダ・フィルタ (CustomNamespaceProviderFilter)](#52-customnamespaceproviderfilterkt) を Bean 定義しているクラスです。

ちなみにセッション・リポジトリ・フィルタ (SessionRepositoryFilter) は、デフォルトのセッションを Spring Session に置き換える(ラップする)ためのフィルタです。これを Bean 定義することで、アプリケーションに Spring Session が適用されます。


## 5.5. DatastoreSessionRepository.kt

```kotlin:DatastoreSessionRepository.kt
import com.google.cloud.datastore.EntityQuery
import com.google.cloud.datastore.Key
import com.google.cloud.datastore.Query
import com.google.cloud.datastore.StructuredQuery.PropertyFilter
import com.google.cloud.spring.core.GcpProjectIdProvider
import com.google.cloud.spring.data.datastore.core.DatastoreTemplate
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.session.SessionRepository
import org.springframework.stereotype.Service
import org.springframework.web.context.request.RequestContextHolder
import org.springframework.web.context.request.ServletRequestAttributes
import java.time.Duration
import java.time.Instant
import java.util.concurrent.locks.ReentrantLock

/**
 * DatastoreSession Repository
 */
@Service
class DatastoreSessionRepository: SessionRepository<DatastoreSession> {
    @Autowired
    private lateinit var datastoreTemplate: DatastoreTemplate

    @Autowired
    private lateinit var nsProvider: CustomNamespaceProvider

    @Autowired
    private lateinit var gcpProjectIdProvider: GcpProjectIdProvider

    @Value("\${datastore.session.sessionTimeoutInSeconds}")
    private var sessionTimeoutInSeconds: Long? = null

    var serverNameCache: String? = null

    companion object {
        const val FLUSH_INTERVAL_SECONDS: Long = 600
        var lastFlushTime: Instant = Instant.EPOCH
        val logger: Logger = LoggerFactory.getLogger(DatastoreSessionRepository::class.java)
        private val lock = ReentrantLock()
    }

    override fun createSession(): DatastoreSession {
        val session = DatastoreSession()
        logger.info("sessionTimeoutInSeconds -> $sessionTimeoutInSeconds")
        if (sessionTimeoutInSeconds != null) {
            session.setMaxInactiveInterval(Duration.ofSeconds(sessionTimeoutInSeconds!!))
        }
        val projectId = gcpProjectIdProvider.projectId
        val saveNs = nsProvider.get()
        val ns = getNamespaceForDatastoreSession()
        nsProvider.accept(ns)

        try {
            // Keyのセット
            session.key = newKey(projectId, DatastoreSession::class.java.simpleName, session.id, ns)
            session.serializeAttributes()
            datastoreTemplate.save(session)

            //TODO 無効なセッション情報の削除(別の仕組みで実行した方がよいかも)
            val now = Instant.now()
            if (lastFlushTime.plusSeconds(FLUSH_INTERVAL_SECONDS).isBefore(now)) {
                lastFlushTime = now
                flushExpiredSessions()
            }

            return session
        } finally {
            nsProvider.accept(saveNs)
        }
    }

    override fun findById(id: String?): DatastoreSession? {
        if (id == null) return null
        val session = _getSession(id) ?: return null

        if (session.isExpired) {
            deleteById(session.id)
            return null
        }

        session.setLastAccessedTime(Instant.now())
        return session
    }

    fun _getSession(id: String): DatastoreSession? {
        val saveNs = nsProvider.get()
        val ns = getNamespaceForDatastoreSession()
        nsProvider.accept(ns)

        try {
            val results = datastoreTemplate.query(createQueryById(id), DatastoreSession::class.java)
            val resultsList = results.toList()
            if (resultsList.isEmpty()) return null

            val session = resultsList[0]
            session.deserializeAttributes();
            return session;
        } finally {
            nsProvider.accept(saveNs)
        }
    }

    override fun deleteById(id: String?) {
        if (id == null) return
        val result = _getSession(id)
        logger.info("session: deleteById -> $result")
        if (result == null) return

        val saveNs = nsProvider.get()
        val ns = getNamespaceForDatastoreSession()
        nsProvider.accept(ns)

        try {
            datastoreTemplate.delete(result)
        } finally {
            nsProvider.accept(saveNs)
        }
    }

    override fun save(session: DatastoreSession?) {
        if (session != null) {
            val saveNs = nsProvider.get()
            val ns = getNamespaceForDatastoreSession()
            nsProvider.accept(ns)

            try {
                session.serializeAttributes()
                datastoreTemplate.save(session)
            } finally {
                nsProvider.accept(saveNs)
            }
        }
    }

    fun createQueryById(id: String): EntityQuery? {
        return Query.newEntityQueryBuilder()
            .setKind(DatastoreSession::class.java.simpleName)
            .setFilter(PropertyFilter.eq(DatastoreSession.KEY_SESSION_ID, id))
            .build()
    }

    /**
     * Flush all expired sessions
     */
    fun flushExpiredSessions() {
        val now = System.currentTimeMillis()
        val query = Query.newEntityQueryBuilder()
            .setKind(DatastoreSession::class.java.simpleName)
            .setFilter(PropertyFilter.le(DatastoreSession.KEY_EXPIRE_TIME, now))
//            .setLimit(10) // x件制限
            .build()
        val saveNs = nsProvider.get()
        val ns = getNamespaceForDatastoreSession()
        nsProvider.accept(ns)

        try {
            val sessions = datastoreTemplate.query(query, DatastoreSession::class.java) //TODO 取得件数制限を行った方がよいかも
            if (!sessions.none()) {
                datastoreTemplate.deleteAll(sessions)   //TODO 仮実装(件数が多い場合はマズいだろう)
            }
        } finally {
            nsProvider.accept(saveNs)
        }
    }

    /**
     * Namespace For DatastoreSession
     */
    fun getNamespaceForDatastoreSession(): String {
        // ドメイン名をネームスペースとする(TODO 先々は採用する値を選択できるようにしたい)
        return getServerName()
    }

    private fun getServerName(): String {
        var serverName: String? = serverNameCache
        if (serverName.isNullOrBlank()) {
            lock.lock()
            try {
                val attrs = RequestContextHolder.currentRequestAttributes() as ServletRequestAttributes?
                if (attrs != null) {
                    val req = attrs.request
                    serverName = req.serverName
                    if ("127.0.0.1" == serverName
                        || "0.0.0.0" == serverName
                    ) {
                        serverName = "localhost"
                    }

                    /*
                    サーバー名をキャッシュしておく。
                    RequestContextHolder クリア後に
                    SessionRepositoryFilter#commitSessionメソッドから save() が呼ばれ、
                    RequestContextHolder.currentRequestAttributes() の呼び出しで
                    IllegalStateException 例外になるため
                    */
                    serverNameCache = serverName
                }
            } finally {
                lock.unlock()
            }
        }

        return serverName ?: ""
    }

    private fun newKey(projectId: String, kind: String, id: String, ns: String): Key {
        val builder = Key.newBuilder(projectId, kind, id)
        builder.setNamespace(ns)    // Keyを使う場合はネームスペースをセットしないとデフォルトネームスペースに保存されてしまう
        return builder.build()
    }
}
```

Spring Data Cloud Datastore を使ったセッションリポジトリ実装です。
このクラスが今回最も実装に苦労した部分で、ソースコードにも TODO コメントを入れているように、改善の余地をいくつか残しています（スミマセン）。
ちなみに、セッションタイムアウトの時間は 30 分(1800 秒)をデフォルトとしていますが、アプリケーション設定ファイル(`application.properties` etc.) に `datastore.session.sessionTimeoutInSeconds` を定義することで変更可能としています。
```properties:application.properties
# セッションタイムアウトの時間を60分とする場合
datastore.session.sessionTimeoutInSeconds=3600
```

また、Key の作成に必要となる「プロジェクトID」は `GcpProjectIdProvider` クラスを使って取得しています。
こちらは、アプリケーション設定ファイル(application.properties etc.) の `spring.cloud.gcp.project-id` に値が設定されていれば、その値が返却されます。
ローカル実行において、必要に応じて定義してください。
```properties:application.properties
# プロジェクトID
spring.cloud.gcp.project-id=session-demo
```
なお、GCPデプロイ環境での実行においては、`spring.cloud.gcp.project-id` が未定義の場合、以下の優先順で値が返却されるため、余程のことがない限りは定義不要と思われます。

※ [Spring Cloud GCP Core](https://googlecloudplatform.github.io/spring-cloud-gcp/3.2.1/reference/html/core.html#_project_id) より抜粋。
1. GOOGLE_CLOUD_PROJECT 環境変数で指定されたプロジェクト ID
2. Google App Engine プロジェクト ID
3. GOOGLE_APPLICATION_CREDENTIALS 環境変数が指す JSON 認証情報ファイルで指定されたプロジェクト ID
4. Google Cloud SDK プロジェクト ID
5. Google Compute Engine メタデータ サーバーからの Google Compute Engine プロジェクト ID

:::note info
**改善ポイント1：無効なセッション情報の削除(別の仕組みで実行した方がよいかも)**
セッションの生成(createSession メソッド)に併せて、（Datastore から）無効なセッション情報の削除を行っていますが、セッション生成がずっと呼び出されなければ Datastore にゴミデータが残った状態になります。
対策としては、無効なセッション情報を削除する処理を別途作成し、[Cloud Scheduler](https://cloud.google.com/scheduler/docs/overview?hl=ja) 等を使って、定期的にその処理を呼び出すとよいかと思います。
:::

:::note warn
**改善ポイント2：(削除対象セッションの)取得件数制限を行った方がよいかも**
flushExpiredSessions メソッドで無効なセッション情報の削除処理を実行していますが、削除対象の件数が多い場合は問題となります。
例えば App Engine(特に Standard) で実行した場合は、件数無制限のデータ削除を行うと実行時間の制限に引っかかるリスクがあります。また、仮にその時間内に収まったとしてもセッションの生成が遅延するため、よいとは言えません。
なお、セッションの生成が遅延することについては、「コルーチン（coroutine）で非同期に（セッション削除処理を）呼び出せばよい」と思われるかもしれませんが、ネームスペースの管理がスレッドに関連づいているため、いくつかの問題があります [^1]（結局のところ、実行時間の制限の問題は解決しません）。

暫定的な対処としては、たとえば現在はコメントアウトしている
`.setLimit(10) // x件制限`
の箇所を有効にし、削除件数の上限を設ける方法があります。

ベストな対処方法は、セッション生成時でのセッションの削除処理を廃止し、「改善ポイント1」で述べているようにセッションの削除処理を別に切り出して、それを定期的に呼び出すことです。
:::

:::note info
**改善ポイント3：先々は(ネームスペースに)採用する値を選択できるようにしたい**
現在の実装では、DatastoreSession データの登録ネームスペースに URL のドメイン名を使っています。
このあたり、任意の値を返却できる仕組みがあるとよさそうです。
Spring Framework の DI 機能を使えば簡単に実装できると思いますので、挑戦してみてください。
:::


## 5.6. EnableDatastoreHttpSession.kt

```kotlin:EnableDatastoreHttpSession.kt
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import

/**
 * Enables DatastoreSession Configuration
 */
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@Import(DatastoreSessionConfiguration::class)
@Configuration
annotation class EnableDatastoreHttpSession
```

この Datastore 版 Spring Session を利用するためのアノテーション定義で、具体的にはアプリケーションの Config クラスに
`@EnableDatastoreHttpSession()`
を追加して使います。


# 6. サンプルコード

それでは作成した Datastore 版 Spring Session を使ってみましょう。
SpringBoot アプリケーションのサンプルになります。
[Spring Initializr](https://start.spring.io/) でプロジェクトを作成して、サンプルコードを組み込むのが簡単ではないかと思われます。

```kotlin:DemoConfig.kt
import jp.co.cs.spring.session.data.datastore.EnableDatastoreHttpSession
import org.springframework.context.annotation.ComponentScan
import org.springframework.context.annotation.Configuration

@Configuration
@EnableDatastoreHttpSession()
@ComponentScan(basePackages = ["jp.co.cs"]) // 適宜修正(Datastore版SpringSessionのベースパッケージ名)
class DemoConfig {
}
```

サンプルアプリケーション用のコンフィグクラスです。
ポイントは
`@EnableDatastoreHttpSession()`
の箇所です。
[5.6. EnableDatastoreHttpSession.kt](#56-enabledatastorehttpsessionkt) でも述べましたが、これは Datastore 版 Spring Session を利用するためのアノテーション定義になります。

なお、
`@ComponentScan(basePackages = [～])`
の箇所については、Datastore 版 Spring Session のベースパッケージ名を指定してください（アプリケーションのベースパッケージと同様の場合は定義不要です）。
また、EnableDatastoreHttpSession のインポート宣言
`import jp.co.cs.spring.session.data.datastore.EnableDatastoreHttpSession`
は私の環境での定義ですので、このパッケージ名についても適宜修正してください。

---

```kotlin:DemoInfo.kt
import java.io.Serializable

class DemoInfo: Serializable {
    companion object {
        private val serialVersionUid: Long = 1
    }

    var name: String? = null
    var description: String? = null
    var createdDate: Long? = null
    var createdDateStr: String? = null
}
```

セッション保存用の器として準備したクラスです。
名前(name)、説明(description)、現在のエポックミリ秒(createdDate)、現在の日時の文字列表記(createdDateStr)を持ちます。

---

```kotlin:DemoController.kt
import jakarta.servlet.http.HttpSession
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId

@RestController
class DemoController {
    @RequestMapping("/sessput")
    fun putDemoInfoToSession(session: HttpSession): String {
        val info = DemoInfo()
        info.name = "山田 太郎"
        info.description = "セッションに置かれた情報です"
        val now = Instant.now()
        info.createdDate = now.toEpochMilli()   //ミリ秒
        info.createdDateStr = LocalDateTime.ofInstant(now, ZoneId.systemDefault()).toString()

        session.setAttribute("demoInfo", info)

        return "セッションに保存しました"
    }

    @RequestMapping("/sessread")
    fun getDemoInfoFromSession(session: HttpSession): DemoInfo? {
        val info = session.getAttribute("demoInfo") as DemoInfo?
        return info
    }

    @RequestMapping("/sessdel")
    fun invalidateSession(session: HttpSession): String {
        session.invalidate()
        return "セッションを削除しました"
    }
}
```

サンプルアプリケーションのコントローラークラスです。
3 つの URL 呼び出しに対応する処理を定義しており、`/sessput` でセッションへの DemoInfo オブジェクトの保存、`/sessread` でセッションに保存されている DemoInfo オブジェクトの取り出し、`/sessdel` でセッションの解放を行います。

:::note info
**動作確認**

![sessput.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2512108/15cc9390-81ed-4061-8d86-8a29d9778038.png)
`sessput` 呼び出しで、セッションに DemoInfo オブジェクトを保存（Datastore にセッション情報を保存）。
↓
![sessread.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2512108/05bef8f7-2d91-4f96-a1ad-80f40cf9105b.png)
`sessread` 呼び出しで、セッションに保存された DemoInfo オブジェクトを取り出し。
↓
![sessdel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2512108/1cc982ca-16de-4f03-99f7-4946cf0c7dda.png)
`sessdel` 呼び出しで、セッションを解放（Datastore からセッション情報を削除）。
↓
![sessread2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2512108/583657c2-b53c-4cd3-9738-e5a00fd1c25b.png)
セッション解放後に `sessread` 呼び出すと、セッションに DemoInfo オブジェクトがないことが分かります。

:::

:::note info
**閑話休題**
`fun putDemoInfoToSession(session: HttpSession): String`
のようにメソッド引数に `session: HttpSession` を定義すると、Spring Framework がセッションオブジェクトをセットして渡してくれます。
セッションオブジェクトが作成済みの場合はそれが渡されますが、未作成の場合は作成して渡されます。
これはとても便利ですが、`sessread` や `sessdel` の呼び出しではセッションオブジェクトが作成済みであることを前提としているとも言えますので、現状のコードは不適当といえるかもしれません。
このような場合、たとえば以下のようにHTTPサーブレットリクエストからセッションを受け取るようにし（`HttpServletRequest` の `getSession` メソッドの引数は `false`）、Spring Framework に不必要なセッションを作らせないようにするとよいでしょう。
```
@RequestMapping("/sessread")
fun getDemoInfoFromSession(req: HttpServletRequest): DemoInfo? {
    val session: HttpSession? = req.getSession(false)
    val info = session?.getAttribute("demoInfo") as DemoInfo?
    return info
}
```

:::


# 7. 更なる改善

たとえばアプリケーションのヘルスチェックのような処理の場合、それが呼び出された直後にはセッションは不要物(ゴミデータ)になります。
したがって、セッション生成の対象外 URL を判断する処理を作るとよさそうです。
これについては、[このあたり](https://github.com/spring-projects/spring-session/issues/244)の情報が参考になります。


# 8. おわりに

前回投稿した[記事](https://qiita.com/Koji_Hataya/items/c3f473d8dd49bacd4a44)（生成 AI 関連）から一転して、またもや少しニッチな(？)記事になりました。
改善できる箇所があるとはいえ、実用に耐えられないというわけでもないため、ご活用いただければと思います。
誰かのお役に立てれば幸いです。


# 9. 参考にさせていただいた情報

Spring Session のストレージとして MongoDB を使う - Qiita
https://qiita.com/tmurakam99/items/2569037d7f1f6612006c


[^1]: コルーチンの中でネームスペースのセットを行うと良さそうですが、動作の確認は取れていません。また、そこで行ったネームスペースのセットについては、CustomNamespaceProviderFilter で行っている ThreadLocal 変数の値の削除では対象外となるため、使い終わったら自前で ThreadLocal 変数の値を削除する必要があります。また、コルーチンにより不要セッションの削除完了を待たずにセッションが返却されるようになりますが、問題として提起している削除処理の時間は短縮されません。