从Spring-Session源码看Session机制的实现细节

Posted 沧海一滴

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从Spring-Session源码看Session机制的实现细节相关的知识,希望对你有一定的参考价值。

 

Re:从零开始的Spring Session(一)
Re:从零开始的Spring Session(二)
Re:从零开始的Spring Session(三)

 

 

 

 



去年我曾经写过几篇和 Spring Session 相关的文章,从一个未接触过 Spring Session 的初学者视角介绍了 Spring Session 如何上手,如果你未接触过 Spring Session,推荐先阅读下「从零开始学习Spring Session」系列(https://www.cnkirito.moe/categories/Spring-Session/) Spring Session 主要解决了分布式场景下 Session 的共享问题,本文将从 Spring Session 的源码出发,来讨论一些 Session 设计的细节。

Spring Session 数据结构解读

想象一个场景,现在一道面试题呈现在你面前,让你从零开始设计一个 Session 存储方案,你会怎么回答?

说白了就是让你设计一套数据结构存储 Session,并且我相信提出这个问题时,大多数读者脑海中会浮现出 redis,设计一个 map,使用 ttl 等等,但没想到的细节可能会更多。先来预览一下 Spring Session 的实际数据结构是什么样的(使用 spring-session-redis 实现),当我们访问一次集成了Spring Session 的 web 应用时

@RequestMapping("/helloworld")
public String hello(HttpSession session){
  session.setAttribute("name","xu");
  return "hello.html";
}

 

可以在 Redis 中看到如下的数据结构:

A) "spring:session:sessions:39feb101-87d4-42c7-ab53-ac6fe0d91925"

B) "spring:session:expirations:1523934840000"

C) "spring:session:sessions:expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"

 

这三种键职责的分析将会贯彻全文,为了统一叙述,在此将他们进行编号,后续简称为 A 类型键,B 类型键,C 类型键。先简单分析下他们的特点

  • 他们公用的前缀是 spring:session
  • A 类型键的组成是前缀 +”sessions”+sessionId,对应的值是一个 hash 数据结构。在我的 demo 中,其值如下
    {
        "lastAccessedTime": 1523933008926,/*2018/4/17 10:43:28*/
        "creationTime": 1523933008926, /*2018/4/17 10:43:28*/
        "maxInactiveInterval": 1800,
        "sessionAttr:name": "xu"
    }
    其中 creationTime(创建时间),lastAccessedTime(最后访问时间),maxInactiveInterval(session 失效的间隔时长) 等字段是系统字段,sessionAttr:xx 可能会存在多个键值对,用户存放在 session 中的数据如数存放于此。

A 类型键对应的默认 TTL 是 35 分钟。

  • B 类型键的组成是前缀+”expirations”+时间戳,无需纠结这个时间戳的含义,先卖个关子。其对应的值是一个 set 数据结构,这个 set 数据结构中存储着一系列的 C 类型键。在我的 demo 中,其值如下
    [
        "expires:39feb101-87d4-42c7-ab53-ac6fe0d91925"
    ]
    B 类型键对应的默认 TTL 是 30 分钟
  • C 类型键的组成是前缀+”sessions:expires”+sessionId,对应一个空值,它仅仅是 sessionId 在 redis 中的一个引用,具体作用继续卖关子。

C 类型键对应的默认 TTL 是 30 分钟。

小贴士:
Spring Session中操作A类键的API:
org.springframework.session.SessionRepository
org.springframework.session.ExpiringSession【存放A类键中的值】

    @Resource
    private SessionRepository<ExpiringSession> sessionRepository;

    @Override
    public ExpiringSession getSession(String sessionId) {
        ExpiringSession session = sessionRepository.getSession(sessionId);
        if (session == null) {
            log.error("session不存在 sessionId:{} ", sessionId);
            return null;
        }
        return session;
    }

 

kirito-session 的天使轮方案

介绍完 Spring Session 的数据结构,我们先放到一边,来看看如果我们自己设计一个 Session 方案,拟定为 kirito-session 吧,该如何设计。

kirito 的心路历程是这样的:“使用 redis 存 session 数据,对,session 需要有过期机制,redis 的键可以自动过期,肯定很方便。”

于是 kirito 设计出了 spring-session 中的 A 类型键,复用它的数据结构:

{
    "lastAccessedTime": 1523933008926,
    "creationTime": 1523933008926, 
    "maxInactiveInterval": 1800,
    key/value...
}

然后对 A 类型的键设置 ttl A 30 分钟,这样 30分钟之后 session 过期,0-30 分钟期间如果用户持续操作,那就根据 sessionId 找到 A 类型的 key,刷新 lastAccessedTime 的值,并重新设置 ttl,这样就完成了「续签」的特性。

显然 Spring Session 没有采用如此简练的设计,为什么呢?翻看 Spring Session 的文档

One problem with relying on Redis expiration exclusively is that Redis makes no guarantee of when the expired event will be fired if the key has not been accessed. Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration. For additional details see Timing of expired events section in the Redis documentation.

大致意思是说,redis 的键过期机制不“保险”,这和 redis 的设计有关,不在此拓展开,研究这个的时候翻了不少资料,得出了如下的总结:

  1. redis 在键实际过期之后不一定会被删除,可能会继续存留,但具体存留的时间我没有做过研究,可能是 1~2 分钟,可能会更久。
  2. 具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。划重点:这启发我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键
  3. 如果没有指令持续关注 key,并且 redis 中存在许多与 TTL 关联的 key,则 key 真正被删除的时间将会有显著的延迟!显著的延迟!显著的延迟!

天使轮计划惨遭破产,看来单纯依赖于 redis 的过期时间是不可靠的,秉持着力求严谨的态度,迎来了 A 轮改造。

A 轮改造—引入 B 类型键确保 session 的过期机制

redis 的官方文档启发我们,可以启用一个后台定时任务,定时去删除那些过期的键,配合上 redis 的自动过期,这样可以双重保险。第一个问题来了,我们将这些过期键存在哪儿呢?不找个合适的地方存起来,定时任务到哪儿去删除这些应该过期的键呢?总不能扫描全库吧!来解释我前面卖的第一个关子,看看 B 类型键的特点:

1
spring:session:expirations:1523934840000

时间戳的含义

1523934840000 这明显是个 Unix 时间戳,它的含义是存放着这一分钟内应该过期的键,所以它是一个 set 数据结构。解释下这个时间戳是怎么计算出来的org.springframework.session.data.redis.RedisSessionExpirationPolicy#roundUpToNextMinute

1
2
3
4
5
6
7
8
static long roundUpToNextMinute(long timeInMs) {
Calendar date = Calendar.getInstance();
date.setTimeInMillis(timeInMs);
date.add(Calendar.MINUTE, 1);
date.clear(Calendar.SECOND);
date.clear(Calendar.MILLISECOND);
return date.getTimeInMillis();
}

还记得 lastAccessedTime=1523933008926,maxInactiveInterval=1800 吧,lastAccessedTime 转换成北京时间是: 2018/4/17 10:43:28,向上取整是2018/4/17 10:44:00,再次转换为 Unix 时间戳得到 1523932980000,单位是 ms,1800 是过期时间的间隔,单位是 s,二者相加 1523932980000+1800*1000=1523934840000。这样 B 类型键便作为了一个「桶」,存放着这一分钟应当过期的 session 的 key。

后台定时任务

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanupExpiredSessions

1
2
3
4
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}

后台提供了定时任务去“删除”过期的 key,来补偿 redis 到期未删除的 key。方案再描述下,方便大家理解:取得当前时间的时间戳作为 key,去 redis 中定位到 spring:session:expirations:{当前时间戳} ,这个 set 里面存放的便是所有过期的 key 了。

续签的影响

每次 session 的续签,需要将旧桶中的数据移除,放到新桶中。验证这一点很容易。

在第一分钟访问一次 http://localhost:8080/helloworld 端点,得到的 B 类型键为:spring:session:expirations:1523934840000;第二分钟再访问一次 http://localhost:8080/helloworld 端点,A 类型键的 lastAccessedTime 得到更新,并且 spring:session:expirations:1523934840000 这个桶被删除了,新增了 spring:session:expirations:1523934900000 这个桶。当众多用户活跃时,桶的增删和以及 set 中数据的增删都是很频繁的。对了,没提到的一点,对应 key 的 ttl 时间也会被更新。

kirito-session 方案貌似比之前严谨了,目前为止使用了 A 类型键和 B 类型键解决了 session 存储和 redis 键到期不删除的两个问题,但还是存在问题的。

B 轮改造—优雅地解决 B 类型键的并发问题

引入 B 类型键看似解决了问题,却也引入了一个新的问题:并发问题。

来看看一个场景:

假设存在一个 sessionId=1 的会话,初始时间戳为 1420656360000

1
2
spring:session:expirations:1420656360000 -> [1]
spring:session:session:1 -> <session>

接下来迎来了并发访问,(用户可能在浏览器中多次点击):

  • 线程 1 在第 2 分钟请求,产生了续签,session:1 应当从 1420656360000 这个桶移动到 142065642000 这个桶
  • 线程 2 在第 3 分钟请求,也产生了续签,session:1 本应当从 1420656360000 这个桶移动到 142065648000 这个桶
  • 如果上两步按照次序执行,自然不会有问题。但第 3 分钟的请求可能已经执行完毕了,第 2 分钟才刚开始执行。

像下面这样:

线程 2 从第一分钟的桶中移除 session:1,并移动到第三分钟的桶中

1
2
3
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]

线程 1 完成相同的操作,它也是基于第一分钟来做的,但会移动到第二分钟的桶中

1
2
3
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656420000 -> [1]

最后 redis 中键的情况变成了这样:

1
2
3
4
spring:session:expirations:1420656360000 -> []
spring:session:session:1 -> <session>
spring:session:expirations:1420656480000 -> [1]
spring:session:expirations:1420656420000 -> [1]

后台定时任务会在第 32 分钟扫描到 spring:session:expirations:1420656420000 桶中存在的 session,这意味着,本应该在第 33 分钟才会过期的 key,在第 32 分钟就会被删除!

一种简单的方法是用户的每次 session 续期加上分布式锁,这显然不能被接受。来看看 Spring Session 是怎么巧妙地应对这个并发问题的。

org.springframework.session.data.redis.RedisSessionExpirationPolicy#cleanExpiredSessions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);

if (logger.isDebugEnabled()) {
logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
}

// 获取到 B 类型键
String expirationKey = getExpirationKey(prevMin);
// 取出当前这一分钟应当过期的 session
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
// 注意:这里删除的是 B 类型键,不是删除 session 本身!
this.redis.delete(expirationKey);
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
// 遍历一下 C 类型的键
touch(sessionKey);
}
}

/**
* By trying to access the session we only trigger a deletion if it the TTL is
* expired. This is done to handle
* https://github.com/spring-projects/spring-session/issues/93
*
* @param key the key
*/
private void touch(String key) {
// 并不是删除 key,而只是访问 key
this.redis.hasKey(key);
}


这里面逻辑主要是拿到过期键的集合(实际上是 C 类型的 key,但这里可以理解为 sessionId,C 类型我下面会介绍),此时这个集合里面存在三种类型的 sessionId。

  1. 已经被 redis 删除的过期键。万事大吉,redis 很靠谱的及时清理了过期的键。
  2. 已经过期,但是还没来得及被 redis 清除的 key。还记得前面 redis 文档里面提到的一个技巧吗?我们在 key 到期后只需要访问一下 key 就可以确保 redis 删除该过期键,所以 redis.hasKey(key); 该操作就是为了触发 redis 的自己删除。
  3. 并发问题导致的多余数据,实际上并未过期。如上所述,第 32 分钟的桶里面存在的 session:1 实际上并不应该被删除,使用 touch 的好处便是我只负责检测,删不删交给 redis 判断。session:1 在第 32 分钟被 touch 了一次,并未被删除,在第 33 分钟时应当被 redis 删除,但可能存在延时,这个时候 touch 一次,确保删除。

所以,源码里面特别强调了一下:要用 touch 去触发 key 的删除,而不能直接 del key。

参考 https://github.com/spring-projects/spring-session/issues/93

C 轮改造—增加 C 类型键完善过期通知事件

虽然引入了 B 类型键,并且在后台加了定时器去确保 session 的过期,但似乎…emmmmm…还是不够完善。在此之前,kirito-session 的设计方案中,存储 session 实际内容的 A 类型键和用于定时器确保删除的桶 B 类型键过期时间都是 30 分钟(key 的 TTL 是 30 分钟),注意一个细节,spring-session 中 A 类型键的过期时间是 35 分钟,比实际的 30 分钟多了 5 分钟,这意味着即便 session 已经过期,我们还是可以在 redis 中有 5 分钟间隔来操作过期的 session。于此同时,spring-session 引入了 C 类型键来作为 session 的引用。

解释下之前卖的第二个关子,C 类型键的组成为前缀+”sessions:expires”+sessionId,对应一个空值,同时也是 B 类型键桶中存放的 session 引用,ttl 为 30 分钟,具体作用便是在自身过期后触发 redis 的 keyspace notifications (http://redis.io/topics/notifications),具体如何监听 redis 的过期事件简单介绍下:org.springframework.session.data.redis.config.ConfigureNotifyKeyspaceEventsAction 该类配置了相关的过期监听,并使用 SessionExpiredEvent 事件发放 session 的过期事件。为什么引入 C 类型键?keyspace notifications 只会告诉我们哪个键过期了,不会告诉我们内容是什么。关键就在于如果 session 过期后监听器可能想要访问 session 的具体内容,然而自身都过期了,还怎么获取内容。所以,C 类型键存在的意义便是解耦 session 的存储和 session 的过期,并且使得 server 获取到过期通知后可以访问到 session 真实的值。对于用户来说,C 类型键过期后,意味着登录失效,而对于服务端而言,真正的过期其实是 A 类型键过期,这中间会有 5 分钟的误差。

一点点想法,担忧,疑惑

本文大概介绍了 Spring Session 的三种 key 的原因,理清楚其中的逻辑花了不少时间,项目改造正好涉及到相关的缓存值过期这一需求,完全可以参考 Spring Session 的方案。但担忧也是有的,如果真的只是 1~2 两分钟的延迟过期(对应 A 轮改造中遇到的问题),以及 1 分钟的提前删除(对应 B 轮改造中的并发问题)其实个人感觉没必要计较。从产品体验上来说,用户应该不会在意 32 分钟自动退出和 30 分钟退出,可以说 Spring Session 是为了严谨而设计了这一套方案,但引入了定时器和很多辅助的键值对,无疑对内存消耗和 cpu 消耗都是一种浪费。如果在生产环境大量使用 Spring Session,最好权衡下本文提及的相关问题。

 

https://www.cnkirito.moe/spring-session-4/

(This applies to Spring 1.5.x at the time of this writing)

To add to @radrocket81\'s reply, here\'s an example code.
Also this is how you set the max-age and other properties of Spring boot cookies if you enabled Redis session by @EnableRedisHttpSession as application property server.session won\'t be applied.

@Bean
public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(SessionRepository<S> sessionRepository, ServletContext servletContext) {
    SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<S>(sessionRepository);
    sessionRepositoryFilter.setServletContext(servletContext);
    CookieHttpSessionStrategy httpSessionStrategy = new CookieHttpSessionStrategy();
    httpSessionStrategy.setCookieSerializer(this.cookieSerializer());
    sessionRepositoryFilter.setHttpSessionStrategy(httpSessionStrategy);
    return sessionRepositoryFilter;
}

private CookieSerializer cookieSerializer() {
    DefaultCookieSerializer serializer = new DefaultCookieSerializer();
    serializer.setCookieName("CUSTOM_SESSION_KEY");
    serializer.setDomainName("domain.com");
    serializer.setCookiePath("/");
    serializer.setCookieMaxAge(10); //Set the cookie max age in seconds, e.g. 10 seconds
    return serializer;
}

https://stackoverflow.com/questions/34941350/how-to-set-cookie-domain-and-path-with-spring-boot

SpringSession系列-sessionId解析和Cookie读写策略
sessionId 解析策略
HttpSessionIdResolver
基于Cookie解析sessionId
基于请求头解析sessionId
Cookie 序列化策略
CookieValue
Cookie 回写
jvm_router的处理
参考
首先需求在这里说明下,SpringSession的版本迭代的过程中肯定会伴随着一些类的移除和一些类的加入,目前本系列使用的版本是github上对象的master的代码流版本。如果有同学对其他版本中的一些类或者处理有疑惑,欢迎交流。

本篇将来介绍下SpringSession中两种sessionId解析的策略,这个在之前的文章中其实是有提到过的,这里再拿出来和SpringSession中Cookie相关策略一起学习
下。

sessionId 解析策略
SpringSession中对于sessionId的解析相关的策略是通过HttpSessionIdResolver这个接口来体现的。HttpSessionIdResolver有两个实现类:

 


这两个类就分别对应SpringSession解析sessionId的两种不同的实现策略。再深入了解不同策略的实现细节之前,先来看下HttpSessionIdResolver接口定义的一些行为有哪些。

HttpSessionIdResolver
HttpSessionIdResolver定义了sessionId解析策略的契约(Contract)。允许通过请求解析sessionId,并通过响应发送sessionId或终止会话。接口定义如下:

public interface HttpSessionIdResolver {
List<String> resolveSessionIds(HttpServletRequest request);
void setSessionId(HttpServletRequest request, HttpServletResponse response,String sessionId);
void expireSession(HttpServletRequest request, HttpServletResponse response);
}

HttpSessionIdResolver中有三个方法:

resolveSessionIds:解析与当前请求相关联的sessionId。sessionId可能来自Cookie或请求头。
setSessionId:将给定的sessionId发送给客户端。这个方法是在创建一个新session时被调用,并告知客户端新sessionId是什么。
expireSession:指示客户端结束当前session。当session无效时调用此方法,并应通知客户端sessionId不再有效。比如,它可能删除一个包含sessionId的Cookie,或者设置一个HTTP响应头,其值为空就表示客户端不再提交sessionId。
下面就针对上面提到的两种策略来进行详细的分析。

基于Cookie解析sessionId
这种策略对应的实现类是CookieHttpSessionIdResolver,通过从Cookie中获取session;具体来说,这个实现将允许使用CookieHttpSessionIdResolver#setCookieSerializer(CookieSerializer)指定Cookie序列化策略。默认的Cookie名称是“SESSION”。创建一个session时,HTTP响应中将会携带一个指定 Cookie name且value是sessionId的Cookie。Cookie 将被标记为一个 session cookie,Cookie 的 domain path 使用 context path,且被标记为HttpOnly,如果HttpServletRequest#isSecure()返回true,那么Cookie将标记为安全的。如下:

关于Cookie,可以参考:聊一聊session和cookie。

HTTP/1.1 200 OK
Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Path=/context-root; Secure; HttpOnly

这个时候,客户端应该通过在每个请求中指定相同的Cookie来包含session信息。例如:

GET /messages/ HTTP/1.1
Host: example.com
Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6

当会话无效时,服务器将发送过期的HTTP响应Cookie,例如:

HTTP/1.1 200 OK
Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Expires=Thur, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly

CookieHttpSessionIdResolver 类的实现如下:

public final class CookieHttpSessionIdResolver implements HttpSessionIdResolver {
private static final String WRITTEN_SESSION_ID_ATTR = CookieHttpSessionIdResolver.class
.getName().concat(".WRITTEN_SESSION_ID_ATTR");
// Cookie序列化策略,默认是 DefaultCookieSerializer
private CookieSerializer cookieSerializer = new DefaultCookieSerializer();

@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
// 根据提供的cookieSerializer从请求中获取sessionId
return this.cookieSerializer.readCookieValues(request);
}

@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response,
String sessionId) {
if (sessionId.equals(request.getAttribute(WRITTEN_SESSION_ID_ATTR))) {
return;
}
request.setAttribute(WRITTEN_SESSION_ID_ATTR, sessionId);
// 根据提供的cookieSerializer将sessionId回写到cookie中
this.cookieSerializer
.writeCookieValue(new CookieValue(request, response, sessionId));
}

@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
// 这里因为是过期,所以回写的sessionId的值是“”,当请求下次进来时,就会取不到sessionId,也就意味着当前会话失效了
this.cookieSerializer.writeCookieValue(new CookieValue(request, response, ""));
}

// 指定Cookie序列化的方式
public void setCookieSerializer(CookieSerializer cookieSerializer) {
if (cookieSerializer == null) {
throw new IllegalArgumentException("cookieSerializer cannot be null");
}
this.cookieSerializer = cookieSerializer;
}
}

这里可以看到CookieHttpSessionIdResolver 中的读取操作都是围绕CookieSerializer来完成的。CookieSerializer 是SpringSession中对于Cookie操作提供的一种机制。下面细说。

基于请求头解析sessionId
这种策略对应的实现类是HeaderHttpSessionIdResolver,通过从请求头header中解析出sessionId。具体地说,这个实现将允许使用HeaderHttpSessionIdResolver(String)来指定头名称。还可以使用便利的工厂方法来创建使用公共头名称(例如“X-Auth-Token”和“authenticing-info”)的实例。创建会话时,HTTP响应将具有指定名称和sessionId值的响应头。

// 使用X-Auth-Token作为headerName
public static HeaderHttpSessionIdResolver xAuthToken() {
return new HeaderHttpSessionIdResolver(HEADER_X_AUTH_TOKEN);
}
// 使用Authentication-Info作为headerName
public static HeaderHttpSessionIdResolver authenticationInfo() {
return new HeaderHttpSessionIdResolver(HEADER_AUTHENTICATION_INFO);
}

HeaderHttpSessionIdResolver在处理sessionId上相比较于CookieHttpSessionIdResolver来说简单很多。就是围绕request.getHeader(String)和request.setHeader(String,String)
两个方法来玩的。

HeaderHttpSessionIdResolver这种策略通常会在无线端来使用,以弥补对于无Cookie场景的支持。

Cookie 序列化策略
基于Cookie解析sessionId的实现类CookieHttpSessionIdResolver 中实际对于Cookie的读写操作都是通过CookieSerializer来完成的。SpringSession 提供了CookieSerializer接口的默认实现DefaultCookieSerializer,当然在实际应用中,我们也可以自己实现这个接口,然后通过CookieHttpSessionIdResolver#setCookieSerializer(CookieSerializer)方法来指定我们自己的实现方式。

PS:不得不说,强大的用户扩展能力真的是Spring家族的优良家风。

篇幅有限,这里就只看下两个点:

CookieValue 存在的意义是什么
DefaultCookieSerializer回写Cookie的的具体实现,读Cookie在 SpringSession系列-请求与响应重写 这篇文章中有介绍过,这里不再赘述。
jvm_router的处理
CookieValue
CookieValue是CookieSerializer中的内部类,封装了向HttpServletResponse写入所需的所有信息。其实CookieValue的存在并没有什么特殊的意义,个人觉得作者一开始只是想通过CookieValue的封装来简化回写cookie链路中的参数传递的问题,但是实际上貌似并没有什么减少多少工作量。

Cookie 回写
Cookie 回写我觉得对于分布式session的实现来说是必不可少的;基于标准servlet实现的HttpSession,我们在使用时实际上是不用关心回写cookie这个事情的,因为servlet容器都已经做了。但是对于分布式session来说,由于重写了response,所以需要在返回response时需要将当前session信息通过cookie的方式塞到response中返回给客户端-这就是Cookie回写。下面是DefaultCookieSerializer中回写Cookie的逻辑,细节在代码中通过注释标注出来。

@Override
public void writeCookieValue(CookieValue cookieValue) {
HttpServletRequest request = cookieValue.getRequest();
HttpServletResponse response = cookieValue.getResponse();
StringBuilder sb = new StringBuilder();
sb.append(this.cookieName).append(\'=\');
String value = getValue(cookieValue);
if (value != null && value.length() > 0) {
validateValue(value);
sb.append(value);
}
int maxAge = getMaxAge(cookieValue);
if (maxAge > -1) {
sb.append("; Max-Age=").append(cookieValue.getCookieMaxAge());
OffsetDateTime expires = (maxAge != 0)
? OffsetDateTime.now().plusSeconds(maxAge)
: Instant.EPOCH.atOffset(ZoneOffset.UTC);
sb.append("; Expires=")
.append(expires.format(DateTimeFormatter.RFC_1123_DATE_TIME));
}
String domain = getDomainName(request);
if (domain != null && domain.length() > 0) {
validateDomain(domain);
sb.append("; Domain=").append(domain);
}
String path = getCookiePath(request);
if (path != null && path.length() > 0) {
validatePath(path);
sb.append("; Path=").append(path);
}
if (isSecureCookie(request)) {
sb.append("; Secure");
}
if (this.useHttpOnlyCookie) {
sb.append("; HttpOnly");
}
if (this.sameSite != null) {
sb.append("; SameSite=").append(this.sameSite);
}

response.addHeader("Set-Cookie", sb.toString());
}

这上面就是拼凑字符串,然后塞到Header里面去,最终再浏览器中显示大体如下:

Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Path=/context-root; Secure; HttpOnly
1
jvm_router的处理
在Cookie的读写代码中都涉及到对于jvmRoute这个属性的判断及对应的处理逻辑。

1、读取Cookie中的代码片段

if (this.jvmRoute != null && sessionId.endsWith(this.jvmRoute)) {
sessionId = sessionId.substring(0,
sessionId.length() - this.jvmRoute.length());
}

2、回写Cookie中的代码片段

if (this.jvmRoute != null) {
actualCookieValue = requestedCookieValue + this.jvmRoute;
}

jvm_route是nginx中的一个模块,其作用是通过session cookie的方式来获取session粘性。如果在cookie和url中并没有session,则这只是个简单的 round-robin 负载均衡。其具体过程分为以下几步:

1.第一个请求过来,没有带session信息,jvm_route就根据round robin策略发到一台tomcat上面。
2.tomcat添加上 session 信息,并返回给客户。
3.用户再次请求,jvm_route看到session中有后端服务器的名称,它就把请求转到对应的服务器上。
从本质上来说,jvm_route也是解决session共享的一种解决方式。这种和 SpringSession系列-分布式Session实现方案 中提到的基于IP-HASH的方式有点类似。那么同样,这里存在的问题是无法解决宕机后session数据转移的问题,既宕机就丢失。

DefaultCookieSerializer 中除了Cookie的读写之后,还有一些细节也值得关注下,比如对Cookie中值的验证、remember-me的实现等。

参考
SpringSession官方文档
jvm_router原理
SpringSession中文注释持续更新代码分支
---------------------
作者:sinat_25518349
来源:CSDN
原文:https://blog.csdn.net/sinat_25518349/article/details/85042029
版权声明:本文为博主原创文章,转载请附上博文链接!

https://stackoverflow.com/questions/33095345/how-to-change-spring-session-redis-cookie-name

spring-session+jedis中session的过期时间和内置tomcat的处理方式的区别