提到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 idprotected 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使用*/@Overridepublic HttpSession getSession() {if (facade == null) {if (SecurityUtil.isPackageProtectionEnabled()) {facade = AccessController.doPrivileged(new PrivilegedNewSessionFacade(this));} else {facade = new StandardSessionFacade(this);}}return facade;}/*** 当该Session相关的请求进来时,更新访问时间。这个方法由Context容器自动调用,无需用户手动在servlet中调用*/@Overridepublic void access() {this.thisAccessedTime = System.currentTimeMillis();if (ACTIVITY_CHECK) {accessCount.incrementAndGet();}}/*** 当请求访问结束时(已经执行了相关servlet的方法了),调用此方法,更新lastAccessedTime的值*/@Overridepublic 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过期*/@Overridepublic void expire() {expire(true);}/*** 使session失效** @param notify 是否通知监听器*/public void expire(boolean notify) {if (notify) {// 通知监听器}// session管理器移除该session// 该将session置为无效// 解绑此session关联的属性对象,即attributes中的值。无用的对象就可以被GC掉了}/*** 回收该session。* 释放所有的对象引用,初始化实例变量的值,准备被再次使用*/@Overridepublic void recycle() {// Reset the instance variables associated with this Sessionattributes.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;@Overridepublic long getCreationTime() {return session.getCreationTime();}@Overridepublic 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 instanceSession 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);elsesession = 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的代码,新版和老版的整体思路没变,在一些细节上有些优化,可以先看老版代码,再对比着新版代码去看。