提到Session,相信大家都不陌生,Http协议本身是无状态的,每次请求都是独立的,而当我们想要将多次请求建立某种关系的时候,就会用到Cookie+Session这个组合,也就是常说的“会话”概念,将多次请求当成一次会话来看待。而Tomcat恰恰就是支持这个机制的。
Cookie+Session
Cookie是由客户端来支持的,这个客户端通常是由浏览器来担任,当使用ip或域名访问一个服务时,如果在服务端的响应头中有“Set-Cookie”这个key,证明服务端给了客户端一些Cookie,在接下来的请求中,客户端就要在请求头中带上这些Cookie。在Tomcat的会话机制中,这个Cookie通常叫做“JSESSIONID”。
Session由服务端来管理,准确的说,是由Tomcat中的一个Context容器持有的Session管理器来管理。一个服务端可能与多个客户端建立会话,所以Session管理器要管理多个Session对象,管理的方式其实就是用一个Map来存储所有活跃的Session,key为Session的id,value就是Session对象本身。而“JSESSIONID”其实就是这个Map的key,所以Tomcat通过JSESSIONID就能找到对应的Session对象。
至于为什么是Context容器持有Session管理器,前面文章有讲过,一个Context容器代表一个Web应用,而我们客户端访问的其实就是一个Web应用的接口,与这个Web应用建立会话。Tomcat本身是支持部署多个Web应用的,也就是可能会有多个Context容器,他们分别管理自己的会话;但是基于目前springboot大流行的场景下,一个Tomcat也就只包含了一个Context容器,针对的也就是我们这个springboot项目。
在Tomcat中,Session只存在于内存中吗?关闭Tomcat后,所有Session就会丢失吗?
答案是不会!默认情况下,Tomcat在正常关闭时,会将当前活跃的Session序列化存储到一个本地文件中,这个文件的文件名默认为“SESSIONS.ser”;Tomcat启动时会去找这个文件,如果文件存在的话,就将这个文件中的Session集合反序列化回来,重新放到内存中。当然如果你觉得你的应用中Session非常重要的话,Tomcat还支持你将Session存储到数据库中(这个好像用到的场景不多)。
如果你kill -9杀进程的话,Tomcat没有正常关闭,是来不及将session持久化的。
每个首次请求的Http请求,Tomcat都会为其创建Session吗?
不都会。Tomcat不会为所有请求都创建Session,仅当某个请求需要Session支持时才创建Session。那什么情况下代表它需要Session支持呢?通常情况下是在servlet中显示调用“HttpServletRequest.getSession()”方法后,就代表它需要Session了。如果一个全新的请求打进来后,没有任何逻辑去获取Session,那Tomcat就不会为其创建Session。
Seesion有效期是怎么刷新的呢?如何判断过没过有效期?
每个Session对象都会维护一个“最后一次访问时间”的字段,叫做“lastAccessedTime” ;另外还维护了一个Session有效期的时长字段,叫做“maxInactiveInterval”。
判断Session有没有过期的算法为“ 当前时间- lastAccessedTime > maxInactiveInterval”,为true就是过期了;基于当前会话,如果后续请求打进来后,Session会自动将lastAccessedTime更新为当前时间,也就是刷新了有效期(对应StandardSession#access()方法,该方法是被一个叫AuthenticatorBase的阀调用的)。
Session过期后,Session管理器会将该对象回收利用,下次需要创建Session对象时,如果回收池中有可用对象的话,就直接用了,避免了重复创建对象。什么时候触发这个Session过期的判断呢?有两个触发点:
- 每次请求打进来后,如果携带了sessionId,就判断一次
- Tomcat后台维护一个线程,每隔一段时间就检查一遍所有Session,判断有没有过期。
上面大白话说了一堆,下面来看看Tomcat是怎么设计这套Session管理机制的。
Session设计
Session对象
Session在Tomcat中以对象的形式存在。
这里有个概念要提前弄明白,Tomcat是基于servlet规范来设计的,servlet规范中对于容器、Session等有一套定义好的接口,比如Session类就是实现了javax.servlet.http.HttpSession 接口的类,这些servlet规范的接口都在servlet.jar中。再说Catalina,它是servlet容器的一个实现,可以理解为是以一个插件的形式放在Tomcat中,是存在被替换掉的可能性的。Catalina内部为了实现特有的功能,会自己定义一些接口,比如针对Session就设计了org.apache.catalina.Session接口。所以在Catalina中,一个Session的具体实现类要继承两个接口:来自servlet的HttpSession接口与自己yy的Session接口。但是,当Catalina要把做好的Session对象提供给具体的servlet进行使用时,就不能暴露自己在org.apache.catalina.Session中定义的方法了,因为这个接口是它自己yy的,没在servlet规范中,如果某天Tomcat心血来潮替换掉了Catalina这个组件,那这个yy的接口就根本不存在了,所以只能让servlet使用javax.servlet.http.HttpSession这个接口中定义的方法。 但是catalina中的Session实现类是实现了两个接口的,怎么办呢?Tomcat针对这种事件的通用做法就是使用门面类,类名通常就叫做 xxxxFacade。以静态代理的方式,使用门面类只暴露HttpSession接口中的方法即可,将这个门面类给servlet使用就OK了。
在Tomcat中,每种组件的规范,其实都是通过接口来定义的,比如本次讲的Session就是HttpSession接口与Session接口,接口代码就不往这放了,随便找个Web项目你都能看到,这里我仅以Catalina中对Session的默认实现类StandardSession,来简单讲一下Session对象都有啥
下面是删减后的StandardSession类的代码
public class StandardSession implements HttpSession, Session, Serializable {
// 该session中的属性集合,通常我们往session中放东西就是放到了这个属性里
protected ConcurrentMap<String, Object> attributes = new ConcurrentHashMap<>();
// session id
protected String id = null;
// 当前请求的访问时间
protected volatile long thisAccessedTime = creationTime;
// 最后一次访问时间
protected volatile long lastAccessedTime = creationTime;
// 该session关联的session管理器
protected transient Manager manager = null;
// session会话的超时时间,单位为秒,负值代表永不超时
protected volatile int maxInactiveInterval = -1;
// 该sesssion会话请求次数计数器
protected transient AtomicInteger accessCount = null;
// Session的门面类,提供给servlet使用
protected transient StandardSessionFacade facade = null;
/**
* 返回一个实现了HttpSession接口的门面类的对象,供servlet使用
*/
@Override
public HttpSession getSession() {
if (facade == null) {
if (SecurityUtil.isPackageProtectionEnabled()) {
facade = AccessController.doPrivileged(new PrivilegedNewSessionFacade(this));
} else {
facade = new StandardSessionFacade(this);
}
}
return facade;
}
/**
* 当该Session相关的请求进来时,更新访问时间。这个方法由Context容器自动调用,无需用户手动在servlet中调用
*/
@Override
public void access() {
this.thisAccessedTime = System.currentTimeMillis();
if (ACTIVITY_CHECK) {
accessCount.incrementAndGet();
}
}
/**
* 当请求访问结束时(已经执行了相关servlet的方法了),调用此方法,更新lastAccessedTime的值
*/
@Override
public void endAccess() {
isNew = false;
/**
* The servlet spec mandates to ignore request handling time
* in lastAccessedTime.
*/
if (LAST_ACCESS_AT_START) {
this.lastAccessedTime = this.thisAccessedTime;
this.thisAccessedTime = System.currentTimeMillis();
} else {
this.thisAccessedTime = System.currentTimeMillis();
this.lastAccessedTime = this.thisAccessedTime;
}
if (ACTIVITY_CHECK) {
accessCount.decrementAndGet();
}
}
/**
* 当判断出session过期后,调用此方法使session过期
*/
@Override
public void expire() {
expire(true);
}
/**
* 使session失效
*
* @param notify 是否通知监听器
*/
public void expire(boolean notify) {
if (notify) {
// 通知监听器
}
// session管理器移除该session
// 该将session置为无效
// 解绑此session关联的属性对象,即attributes中的值。无用的对象就可以被GC掉了
}
/**
* 回收该session。
* 释放所有的对象引用,初始化实例变量的值,准备被再次使用
*/
@Override
public void recycle() {
// Reset the instance variables associated with this Session
attributes.clear();
setAuthType(null);
creationTime = 0L;
expiring = false;
id = null;
lastAccessedTime = 0L;
maxInactiveInterval = -1;
notes.clear();
setPrincipal(null);
isNew = false;
isValid = false;
manager = null;
}
}
我们最常用到的应该就是attributes这个属性了,通常我们会在会话中放入一些信息,如验证码、用户信息等,就是放到了这个属性中。类似于下面这种用法
@GetMapping("/login")
public String login(HttpServletRequest request) {
// 登录验证的逻辑……
HttpSession session = request.getSession();
session.setAttribute("userInfo",user);
// 其他逻辑……
}
其他各项属性都是为了控制Session而存在的,平时用时感知不大。如session有效期的控制,提供门面装饰对象StandardSessionFacade等,它还提供了recycle方法来重置自己,方便被回收利用。
装饰类StandardSessionFacade就比较简单了,它只实现了HttpSession接口,内部代理一个HttpSession对象(通常来说就是StandardSession对象),接口的实现逻辑都是直接调用的代理对象的同名方法。
public class StandardSessionFacade implements HttpSession {
public StandardSessionFacade(HttpSession session) {
this.session = session;
}
private final HttpSession session;
@Override
public long getCreationTime() {
return session.getCreationTime();
}
@Override
public String getId() {
return session.getId();
}
// 其他方法省略 …………
}
上面提到的相关类的类图如下
Session管理器
上面展示了Session对象,它代表一个会话,这个对象里存放了该会话的一些属性。Session管理器就是管理多个Session对象的一个存在,catalina制定了Session管理器的规范,封装成了org.apache.catalina.Manager接口,并提供了ManagerBase类(实现了Manager接口)来提供Session管理器的一些统一行为。StandardManager是ManagerBase的子类,是Tomcat默认的Session管理器。下面我综合ManagerBase与StandardManager中的一些属性和方法,来展示下Session管理器的主要作用
首先看这两个属性
// 当前活跃的session,key为session的id
protected Map<String, Session> sessions = new ConcurrentHashMap<>();
// 已经被回收的Session集合,可以被再次使用(这个回收利用的机制好似高版本已经不用了,读者注意版本区别,这里是Tomcat4的逻辑)
protected List<Session> recycled = new ArrayList<>();
Session管理器负责创建Session,对应的方法为createSession,HttpServletRequest.getSession()最终也会调到这个createSession方法。createSession中会尝试利用回收池中的Session,减少对象的创建,节省资源。
public Session createSession() {
// Recycle or create a Session instance
Session session = null;
synchronized (recycled) {
int size = recycled.size();
if (size > 0) {
session = recycled.get(size - 1);
recycled.remove(size - 1);
}
}
if (session != null)
session.setManager(this);
else
session = new StandardSession(this);
// 为新的Session初始化属性值
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
session.setMaxInactiveInterval(this.maxInactiveInterval);
String sessionId = generateSessionId();
String jvmRoute = getJvmRoute();
if (jvmRoute != null) {
sessionId += '.' + jvmRoute;
session.setId(sessionId);
}
// setId这个方法中会将这个session加入到sessions这个Map中
session.setId(sessionId);
return session;
}
Session管理器支持通过sessionId来查找session,其实就是对sessions这个Map做检索, 方法太简单,不再放代码了。
Session管理器支持序列化sessions到“SESSIONS.ser”文件中,也支持从“SESSIONS.ser”文件中反序列化出Session对象集合,放到sessions属性中。对应的方法分别为load()和unload(),这两个方法是在对应的生命周期方法执行时被调用的,比如 Session管理器组件初始化时执行的start()方法中会调用load()方法,组件销毁时调用的stop()方法中会调用unload()方法。代码不在展示了,感兴趣的同学去看下源码吧。
Session管理器还负责销毁那些已经失效的Session对象,Tomcat中会有一个独立线程周期性的调用StandardManager的processExpires方法来巡检所有Session对象,销毁那些已过期的。
private void processExpires() {
long timeNow = System.currentTimeMillis();
Session sessions[] = findSessions();
for (int i = 0; i < sessions.length; i++) {
StandardSession session = (StandardSession) sessions[i];
if (!session.isValid()) {
continue;
}
int maxInactiveInterval = session.getMaxInactiveInterval();
if (maxInactiveInterval < 0) {
continue;
}
// 计算出session已经空闲的时间,与最大空闲时间做对比,如果超时了,就将session进行过期处理(重置并放入回收池)
int timeIdle = (int) ((timeNow - session.getLastAccessedTime()) / 1000L);
if (timeIdle >= maxInactiveInterval) {
try {
session.expire();
} catch (Throwable t) {
log(sm.getString("standardManager.expireException"), t);
}
}
}
}
除了StandardManager,Tomcat还提供了PersistentManager来支持将Session持久化到不同的储存库中,可以是文件(FileStore)也可以是数据库(JDBCStore),用的比较少,这里就不详细介绍了。
上面提到的类的类图如下
下面画个流程图来串一下 Session建立、刷新、回收的过程
集群共享Session
线上系统为了高可用,一般都会采用集群部署,并使用负载均衡工具(如Nginx)做负载。这会带来一个问题:Tomcat中的Session管理都是针对单机服务而言的,集群服务如何管理Session呢?通常这就需要一个外部介质来存Session了,而且这个外部介质需要是集群中每个节点都能访问到的。通常来说我们会选择redis来充当这个介质,而spring也提供了spring-session-data-redis包来支持这项工作。
spring-session-data-redis的用法
首先引入maven依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
<version>2.7.4</version>
</dependency>
然后在springboot的启动类上加上@EnableRedisHttpSession注解,就完事了,用起来非常简单。
@EnableRedisHttpSession注解中有几个属性,方便你做一些个性化设置,如果没有特殊需求,用它的默认值即可。
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(RedisHttpSessionConfiguration.class)
public @interface EnableRedisHttpSession {
/**
* Session的有效期,单位为秒,默认是30分钟
*/
int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
/**
* 为key定义唯一的命名空间。默认为“spring: session:”,用于隔离会话。
* 例如,如果你有一个名为“应用A”的应用程序需要与“应用B”保持会话隔离,你可以为应用程序设置两个不同的值,它们可以在同一个Redis实例中运行。
*/
String redisNamespace() default RedisSessionRepository.DEFAULT_KEY_NAMESPACE;
/**
* Redis会话的刷新模式。默认是ON_SAVE,在HTTP响应提交之前将Session刷新到redis。
* 将该值设置为IMMEDIATE时,对Session有任何更新时,都会立即写入到Redis。
*/
FlushMode flushMode() default FlushMode.ON_SAVE;
/**
* 保存session的模式,默认为ON_SET_ATTRIBUTE,只向redis中刷新发生改变的属性。
* 设为ON_GET_ATTRIBUTE时,除了session中属性变化时会保存,在从session中读取属性时也会向redis刷新数据。
* 设为ALWAYS,代表对session的任何操作,都会向redis中刷新数据
*/
SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE;
}
最常使用的属性是maxInactiveIntervalInSeconds,自定义一个session有效期。
其次是redisNamespace,在多个不同的服务都需要往同一个redis实例中放session时,会通过这个属性来做服务区分。
flushMode和saveMode 几乎没用过。甚至我对saveMode这个属性的应用场景都没看懂😂。
spring-session-data-redis的原理
用法很简单,原理才重要。
工作原理
-
会话存储替换:Spring Session Data Redis 替换了标准的 HTTP 会话存储机制,通过 Redis 进行会话数据的存储和管理。
-
拦截器和过滤器:Spring Session 提供了一个
SessionRepositoryFilter
过滤器,拦截所有进入的 HTTP 请求,替换原有的HttpSession
实现。 -
RedisIndexedSessionRepository:该类持有RedisTemplate引用,将会话数据存储在 Redis 中,并负责会话的创建、更新、删除等操作。
主要流程
1.请求拦截:SessionRepositoryFilter是一个servlet过滤器,它会拦截所有
HTTP 请求,使用自己的requestWrapper与responseWrapper对象将原本的request与response对象包装了一下,然后将包装后的对象扔回了过滤器链中,也就是说当请求到达servlet后,servlet拿到的是这个requestWrapper与responseWrapper对象。
这个FIlter是设计的精髓,拦截到请求并将request与response偷梁换柱后,自己想特殊实现的逻辑就在xxxWrapper类中重写相关方法,还想使用原逻辑的就还调用原来对象的方法。
2.会话操作与持久化
再看下SessionRepositoryFilter的类结构,在SessionRepositoryRequestWrapper这个类中还有一个HttpSessionWrapper类,这个类就是用来替掉Tomcat原本的HttpSession的。
RedisIndexedSessionRepository类持有一个RedisTemplate对象,负责与redis进行交互。HttpSessionWrapper对session的操作会调用RedisIndexedSessionRepository类中对应方法去redis中存取数据。
redis中存储的session数据
spring-session-data-redis将一个session放入redis时,会创建三个key。Hash结构的key存储的是session的数据,我们编码用到的也是这个数据;另外两个key是与session过期处理相关的,平常我们也不用关心。
前端收到的cookie
使用spring-session-data-redis后,这个框架会将真实的sessionId进行base64编码后再返给前端,如上图的session信息,前端收到的cookie就为
SESSION=Zjc0MWNmMzYtYTZiMy00YmY2LTgyNDQtM2U3MGYzNmE4NmI0
注意,这时候已经没有“JSESSIONID”了,前后端辨认会话使用“SESSION”。
好,spring-session-data-redis就聊到这里。
下篇文章聊一聊Tomcat的安全性,敬请期待!
源码分享
https://gitee.com/huo-ming-lu/HowTomcatWorks
本篇文章并没有讲原书中的示例,感兴趣的同学可以自己运行观察一下。另外,我在gitee上提供的源码为Tomcat4的代码,写这篇文章时我也参考了Tomcat9的代码,新版和老版的整体思路没变,在一些细节上有些优化,可以先看老版代码,再对比着新版代码去看。