Spring Boot 3.2.3 × Redisでセッション管理する

# Redis
- インメモリデータベース
    - メモリ上(RAM)にデータを保存する
    - メリット
        - 低レイテンシ
        - 高スループット
    - デメリット
        - RAMが高価
        - ストレージサイズに限界がある
    - 
- NoSQL
# Spring BootでRedisを用いたセッション管理をしてみる
ソースコードは以下。

https://github.com/tttol/redis-springboot-sample/tree/main

## Redisの準備
RedisはDockerで起動することにする。
```yml:compose.yml
version: '3'
services:
  redis:
    image: redis
    ports:
      - 6379:6379
```

compose.ymlからコンテナ起動し、`docker compose exec redis bash`でコンテナ内に入り、`redis-cli`を実行する。
```bash
$ docker compose up -d
$ docker compose exec redis bash
root@4229d9d7221f:/data# redis-cli
127.0.0.1:6379> 
```

`set key value`でデータ登録し、`keys *`で登録されたデータ一覧を参照できる。
```bash
127.0.0.1:6379> set aaa bbb
OK
127.0.0.1:6379> keys *
1) "aaa"
127.0.0.1:6379> get aaa
"bbb"
```

後述するJavaコードでセッションをRedisに登録するが、セッションはシリアライズされるためgetでは参照できない。
代わりに、`hgetall`で取得する。
```bash
127.0.0.1:6379> get spring:session:sessions:e8aa9a26-1c93-4b22-9733-b364586ffa52
(error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> hgetall spring:session:sessions:e8aa9a26-1c93-4b22-9733-b364586ffa52
1) "creationTime"
2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\x8d\xe3[j3"
3) "maxInactiveInterval"
4) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b"
5) "sessionAttr:hoge"
6) "\xac\xed\x00\x05t\x00\x04fuga"
7) "lastAccessedTime"
8) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\x8d\xe3[j4"
```

## Javaコード

特に凝ったことはしていない。

```yml:application.yml
spring:
    data:
        redis:
            host: localhost
            port: 6379
            password: null
```

```gradle:build.gradle
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.session:spring-session-data-redis'
	compileOnly 'org.projectlombok:lombok'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
```


```java:Controller
package io.github.tttol.redis.controller;

import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RestController
@Slf4j
@RequestMapping("redis")
@RequiredArgsConstructor
public class RedisController {
    private final HttpSession session;

    @GetMapping
    public String hello() {
        return "Hello, Redis!";
    }    

    @SuppressWarnings("null")
    @GetMapping("simple/put")
    public String put() {
        var connectionFactory = new LettuceConnectionFactory();
		connectionFactory.afterPropertiesSet();

		var template = new RedisTemplate<String, String>();
		template.setConnectionFactory(connectionFactory);
		template.setDefaultSerializer(StringRedisSerializer.UTF_8);
		template.afterPropertiesSet();

		template.opsForValue().set("foo", "bar");

		log.info("Value at foo:" + template.opsForValue().get("foo"));

		connectionFactory.destroy();
        return "put foo=bar to redis";
    }    

    @GetMapping("session/put/{key}/{value}")
    public String putSession(@PathVariable String key, @PathVariable String value) {
        session.setAttribute(key, value);
        return "put %s=%s to session".formatted(key, value);
    }
}

```

`putSession`メソッドをcurlで呼んでみる。
```bash
$ curl -v http://localhost:8080/redis/session/put/hoge/fuga
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /redis/session/put/hoge/fuga HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.5.0
> Accept: */*
> 
< HTTP/1.1 200 
< Set-Cookie: SESSION=ZThhYTlhMjYtMWM5My00YjIyLTk3MzMtYjM2NDU4NmZmYTUy; Path=/; HttpOnly; SameSite=Lax
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 24
< Date: Mon, 26 Feb 2024 03:00:36 GMT
< 
* Connection #0 to host localhost left intact
put hoge=fuga to session%                       
```
`< Set-Cookie: SESSION=ZThhYTlhMjYtMWM5My00YjIyLTk3MzMtYjM2NDU4NmZmYTUy; `より、セッション登録されてることがわかる。

Redisで確認してみる。
```bash
127.0.0.1:6379> keys spring*
1) "spring:session:sessions:e8aa9a26-1c93-4b22-9733-b364586ffa52"
127.0.0.1:6379> hgetall spring:session:sessions:e8aa9a26-1c93-4b22-9733-b364586ffa52
1) "creationTime"
2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\x8d\xe3[j3"
3) "maxInactiveInterval"
4) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b"
5) "sessionAttr:hoge"
6) "\xac\xed\x00\x05t\x00\x04fuga"
7) "lastAccessedTime"
8) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01\x8d\xe3[j4"
```

## セッションIDが一致しなくない？
レスポンスヘッダとRedisでセッションのIDが違うように見える。
- `SESSION=ZThhYTlhMjYtMWM5My00YjIyLTk3MzMtYjM2NDU4NmZmYTUy;`
- `1) "spring:session:sessions:e8aa9a26-1c93-4b22-9733-b364586ffa52"`

これはSpirng　SessionがRedisへ登録する際にIDをbase64エンコードしてるらしく、値が異なって見えているもよう。
デコードすると同じ値になる。
```python
import base64

encoded_session_id = "ZThhYTlhMjYtMWM5My00YjIyLTk3MzMtYjM2NDU4NmZmYTUy"
decoded_session_id = base64.urlsafe_b64decode(encoded_session_id).decode("utf-8")

print(decoded_session_id)  # Output: e8aa9a26-1c93-4b22-9733-b364586ffa52
```

Spring Sessionの公式リファレンスからはその記載は見つけることができていないが、ソースコードを見るとたしかにCookieから値を取得する際にbase64エンコードしていた。

https://github.com/spring-projects/spring-session/blob/ccf6daaa216c481674e16b14c011731711965c92/spring-session-core/src/main/java/org/springframework/session/web/http/DefaultCookieSerializer.java#L194

```java:org/springframework/session/web/http/DefaultCookieSerializer.java
	private String getValue(CookieValue cookieValue) {
		String requestedCookieValue = cookieValue.getCookieValue();
		String actualCookieValue = requestedCookieValue;
		if (this.jvmRoute != null) {
			actualCookieValue = requestedCookieValue + this.jvmRoute;
		}
		if (this.useBase64Encoding) {
			actualCookieValue = base64Encode(actualCookieValue);
		}
		return actualCookieValue;
	}
```

# 参考

https://speakerdeck.com/hanhan1978/web-app-session-101-2024

https://qiita.com/yu-tarrrr/items/07eabf48235d04331f9e

https://docs.spring.io/spring-data/redis/reference/redis/getting-started.html

https://qiita.com/statemachine/items/e124d6409999cdd1758b

https://spring.pleiades.io/spring-session/reference/guides/boot-redis.html