Tomcat之session详解

深入Tomcat源码分析Session到底是个啥!


Session到底是个啥?


我们都知道,HTTP协议本身是无状态的(Stateless),这对于一些简单的页面展示来说,功能足够,不受影响。而对于日渐复杂的动态页面、应用,各种需要登录认证等场景,就力不从心了。试想对于一个已经登录的用户仍然一次次的提示登录,会是什么感受呢?


因此,多数需要保持状态的应用,就要保证客户端和服务端交互状态的一致。对于浏览器发起的多次请求,仅基于HTTP协议,是无法识别出是否为同一用户请求的。而为了保持客户端和服务端交互状态,可以采取一系列的策略,例如:


  • Cookie

  • 隐藏form 表单域

  • Session

  • URL

  • SSL


本文将通过深入Tomcat源码,分析这一最常用的Servlet容器内部是如何使用Session来保持客户端与服务器状态一致的。


做为一名Web应用开发者,我们一定都听说过,甚至了解过Session这个词,以及其背后代表的一些概念。


Session,中文称之为会话用于两个设备之间交互时状态的保持。因此,会话中至少要有一方需要保存会话的状态。


在Servlet规范中,session对象由HttpSession这一接口来表示,接口描述简明的概括了其主要作用


Provides a way to identify a user across more than one page request or

visit to a Web site and to store information about that user.


The servlet container uses this interface to create a session between an HTTP

client and an HTTP server. The session persists for a specified time period,

across more than one connection or page request from the user. A session

usually corresponds to one user.


而Tomcat内部则是通过StandardSession实现了HttpSession这个接口,内部统一使用StandardSession来处理。


在初次请求应用时,如果需要用到Session,则会创建之。一般的Servlet中并不会直接使用。而如果是请求JSP文件,由于JSP默认的隐式对象中是包含

session的,其在生成Servlet文件时,内部相当于包含了


HttpServletRequest.getSession(true)


因此,请求时会直接创建session对象。


创建session的过程,大致是下面的样子:


protected Session doGetSession(boolean create) {

// There cannot be a session if no context has been assigned yet

Context context = getContext();

if (context == null) {

return (null);

}

// Return the current session if it exists and is valid

if ((session != null) && !session.isValid()) {

session = null;

}

if (session != null) {

return (session);

}


// Return the requested session if it exists and is valid

Manager manager = context.getManager();

if (manager == null) {

return (null); // Sessions are not supported

}

if (requestedSessionId != null) {

try {

session = manager.findSession(requestedSessionId);

} catch (IOException e) {

session = null;

}

if ((session != null) && !session.isValid()) {

session = null;

}

if (session != null) {

session.access();

return (session);

}

}


// Create a new session if requested and the response is not committed

if (!create) {

return (null);

}

if (response != null

&& context.getServletContext()

.getEffectiveSessionTrackingModes()

.contains(SessionTrackingMode.COOKIE)

&& response.getResponse().isCommitted()) {

throw new IllegalStateException(

sm.getString("coyoteRequest.sessionCreateCommitted"));

}


// Attempt to reuse session id if one was submitted in a cookie

// Do not reuse the session id if it is from a URL, to prevent possible

// phishing attacks

// Use the SSL session ID if one is present.

if (("/".equals(context.getSessionCookiePath())

&& isRequestedSessionIdFromCookie()) || requestedSessionSSL ) {

session = manager.createSession(getRequestedSessionId());

} else {

session = manager.createSession(null);

}


// Creating a new session cookie based on that session

if (session != null

&& context.getServletContext()

.getEffectiveSessionTrackingModes()

.contains(SessionTrackingMode.COOKIE)) {

Cookie cookie =

ApplicationSessionCookieConfig.createSessionCookie(

context, session.getIdInternal(), isSecure());

response.addSessionCookieInternal(cookie);

}

if (session == null) {

return null;

}

session.access();

return session;

}


整体流程基本是先判断是否已经存在session,如果没有则创建。如果有,则直接使用。


此时,需要注意两个问题:

  1. 初次请求时,session如何生成并传递给客户端的

  2. 后续的其它请求中,如果将客户端的请求与服务端已有的session建立关联的


上面代码中,判断session不存在并创建的过程,是直接调用createSession这个方法,并会根据sessionId是否为空,来确定是完全新创建session,还是恢复已有session。


public Session createSession(String sessionId) {

if ((maxActiveSessions >= 0) &&

(getActiveSessions() >= maxActiveSessions)) {

rejectedSessions++;

throw new TooManyActiveSessionsException(

sm.getString("managerBase.createSession.ise"),

maxActiveSessions); //注意这里有个策略

}

// Recycle or create a Session instance

Session session = createEmptySession();

// Initialize the properties of the new session and return it

session.setNew(true);

session.setValid(true);

session.setCreationTime(System.currentTimeMillis());

session.setMaxInactiveInterval(this.maxInactiveInterval);

String id = sessionId;

if (id == null) {

id = generateSessionId();

}

session.setId(id);

sessionCounter++;

SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);

synchronized (sessionCreationTiming) {

sessionCreationTiming.add(timing);

sessionCreationTiming.poll();

}

return (session);

}



这里有个Session超时时间,即最大空闲时间


注意此处maxInactiveInterval的值,即为我们默认的web.xml中提供的session超时时间(后台回复关键字004,了解更多),为30分钟。


在创建完Session之后,Tomcat通过在响应头中设置Set-Cookie这个MimeHeader来返回给客户端session数据。返回的数据是这样的:

JSESSIONID=CC4D83F3A61823AA8F980C89890A19D7; Path=/manager/; HttpOnly


设置Header的过程如下:

public void addSessionCookieInternal(final Cookie cookie) {

if (isCommitted()) { //此处判断,如果response已经提交,则不能再设置

return;

}

String name = cookie.getName();

final String headername = "Set-Cookie";

final String startsWith = name + "=";

String header = generateCookieString(cookie); //此处根据具体cookie的内容生成对应的串,内部会判断cookie的版本,过期时间等

if (!set) {

addHeader(headername, header);

} }



我们看到,初次请求时,响应头中包含了高亮的数据。



那再次请求呢,我们看到这次响应头中没有sessionId的数据,而是转移到请求头中,并且是以Cookie的形式提供:


此时,传到服务端,服务端解析Cookie对应的JSESSIOONID,并提取对应的sessionId值,与服务端对应的session数据做关联。


我们看代码中的实现


再次请求时,从Request中获取SessionCookie的地方在这里:

CoyoteAdapter.postParseRequset()


其内部调用 parseSessionCookiesId(request), 解析请求头中的cookie数据。

public void parseCookieHeader(MimeHeaders headers, ServerCookies serverCookies) {

// process each "cookie" header

int pos = headers.findHeader("Cookie", 0);

}

}


此处需要注意SessionCookie的名称是允许配置的,因此这一名称不一定一直都是JSESSIONID。


在解析Cookie获取SessionId之后,我们拿到的仅仅是一个字符串,还不能马上和Session关联起来,此时request会将此值赋值给其内部的一个名为

requestSessionId的属性。

当后面再次请求session时,就和我们最上面代码看到的一样,会有一个findSession的过程,


到此,我们基本了解了客户端浏览器和服务端Tomcat之间,如果保持交互状态的一致中的一种实现方式,即SessionCookie


而本质上,这一过程就是传统上Socket交互的一个过程,我们完全可以自已写一段代码模拟返回响应的数据,只是需要注意响应头数据在HTTP规范中有特定的格式要求,如下,即数据之间要以CRLF分隔


总结下,客户端初次请求服务端会创建Session,此时通过在响应头中设置Set-Cookie将sessionId传递给客户端。后续客户端的请求会在请求头中设置Cookie项带上sessionId,从而保证客户端与服务端交互状态的一致。


Tomcat 的 Session 持久化策略


关于Tomcat的Session,我们都知道默认是保存在内存中。为了解决重启应用服务器Session丢失的问题,Tomcat内部的StandardManager会在正常的关闭时钝化活动的Session 并在重启的时候重新加载。

而Tomcat在 StandardManager 之外,还提供了对应持久化Session的Manager实现:  PersistentManager ,目前对应的有两种持久化的实现

  • FileStore

  • JDBCStore

分别是将活动的Session保存在磁盘上和保存到数据库中。

本次我们以FileStore为例,来分析下PersistentManager在Tomcat中的实现。

配置

PersistentManager的配置基本上是这样的:

<Manager className="org.apache.catalina.session. PersistentManager " debug="0" saveOnRestart="true" maxActiveSessions="-1" minIdleSwap="-1" maxIdleSwap="5" maxIdleBackup="3" >

Store className="org.apache.catalina.session.FileStore" directory="/home/mytestsession"/>

</Manager>

对于FileStore和JDBCStore,基本配置都类似,有差异的只是 Store 中对应的具体属性,比如JDBCStore需要额外指定数据的用户名和密码等。上面的配置可以直接 用于FileStore。

其中,像 maxIdleBackup 、 maxIdleSwap 、 minIdleSwap 默认都是关闭的,默认值都是-1。当然,我们上面的配置是修改过的。默认的行为会和StandardManager一致,即在关闭重启时进行Session的钝化和解析。

而当按照我们上面的配置启动Tomcat后,服务器会根据maxIdleBackup的时间,以秒为单位,进行空闲Session的持久化。在配置的目录中,会生成以sessionId为文件名.session的文件

例如:5E62468BFF33CF7DE28464A76416B85E.session

主要参数说明:

  • saveOnRestart -当服务器关闭时,是否要将所有的session持久化;

  • maxActiveSessions - 可处于活动状态的session数;

  • minIdleSwap/maxIdleSwap -session处于不活动状态最短/长时间(s),sesson对象转移到File Store中;

  • maxIdleBackup -大于这一时间时,会将session备份。

写文件:

public void save(Session session) throws IOException {

// Open an output stream to the specified pathname, if any

File file = file(session.getIdInternal());

if (file == null) {

return;

}

if (manager.getContext().getLogger().isDebugEnabled()) {

manager.getContext().getLogger().debug(sm.getString(getStoreName() + ".saving",

session.getIdInternal(), file.getAbsolutePath()));

}

try (FileOutputStream fos = new FileOutputStream(file.getAbsolutePath());

ObjectOutputStream oos = new ObjectOutputStream(new BufferedOutputStream(fos))) {

((StandardSession)session). writeObjectData (oos);

}

}

我们来看load操作,和StandardManager加载Session基本一致,先创建空的session,再readObjectData:

public Session load(String id) {

File file = file(id);

Context context = getManager().getContext();

try (FileInputStream fis = new FileInputStream(file.getAbsolutePath());

ObjectInputStream ois = getObjectInputStream(fis)) {

StandardSession session = (StandardSession) manager.createEmptySession();

session.readObjectData(ois);

session.setManager(manager);

return session;

} catch (FileNotFoundException e) {

return null;

}

}

删除操作:

public void remove(String id) throws IOException {

File file = file(id);

file.delete();

}

而这些load,remove等操作的触发点,就是我们之前提到过的 后台线程 :我们在前面分析过期的session是如何处理的时候,曾提到过,可以移步这里: 对于过期的session,Tomcat做了什么?

都是使用 backgroundProcess

public void backgroundProcess() {

count = (count + 1) % processExpiresFrequency;

if (count == 0)

processExpires();

}

在 PersistentManager 的processExpires方法中,重点有以下几行

processPersistenceChecks();

if (getStore() instanceof StoreBase) {

((StoreBase) getStore()).processExpires();

}

其中在 processPersistenceChecks 中,就会对我们上面配置的几项进行检查,判断是否要进行session文件的持久化等操作

/**

* Called by the background thread after active sessions have been checked

* for expiration, to allow sessions to be swapped out, backed up, etc.

* /

public void processPersistenceChecks() {

processMaxIdleSwaps();

processMaxActiveSwaps();

processMaxIdleBackups();

}

此外,通过配置 pathname 为空,即可禁用session的持久化策略,在代码中,判断pathname为空时,不再创建持久化文件,从而禁用此功能。

<Manager pathname="" />

总结下,正如文档中所描述,StandardManager所支持的重启时加载已持久化的Session这一特性,相比PersistentManager只能算简单实现。要实现更健壮、更符合生产环境的重启持久化,最好使用PersistentManager并进行恰当的配置。

详解集群内Session高可用的实现原理

在这个互联网高度发达的时代,许多应用的用户动辄成百上千万,甚至上亿。为了支持海量用户的访问,应用服务器集群这种水平扩展的方式是最常用的。这种情形下,就会涉及到许多单机环境下完全不需要考虑的问题,这其中session的创建、共享和存储是最常见之一。


在单机环境中,Session的创建和存储都是由同一个应用服务器实例来完成,而存储也仅是内存中,最多会在正常的停止服务器的时候,把当前活动的Session钝化到本地,再次启动时重新加载。


而多个实例之间,Session数据是完全隔离的。而为了实现Session的高可用,多实例间数据共享是必然的,下面我们以Redis 的SessionManager实现多Tomcat实例Session共享的配置为例,我们来梳理下一般session共享的流程:

  1. 添加具体要使用的manager的Jar文件及其依赖

    • redis session manager依赖jedis, commons-pool, commons-pool2

    • 对应版本的redis session manager的jar文件

  2. 在TOMCAT_HOME/conf/context.xml中增加如下配置

<Valve className="com.radiadesign.catalina.session.RedisSessionHandlerValve" />
<Manager className="com.radiadesign.catalina.session.RedisSessionManager"
         host="localhost"   
         port="6379" database="0" 
         maxInactiveInterval="30" />

其中hostport等替换为对应的配置信息

  1. 启动多个Tomcat实例,以自带的examples应用为例进行验证

  2. 访问examples应用的servlets/servlet/SessionExample

  3. 在页面中添加数据到session中,并查看页面上对应的session信息

  4. 访问另一个实例上相同应用的页面,查看session信息,两者应该是一致的

  5. 使用redis-cli查看redis中存储的对应数据,相应的sessionId对应的数据已经保存了下来

以上是一个基本的配置过程,而在这些配置与验证的步骤中,第二步是核心逻辑实现。 前面的文章,曾介绍过Tomcat的Valve,在请求处理时,Pipeline中的各个Valve的invoke方法会依次执行。Tomcat的AccessLogValve介绍

此处的session处理,就是以一个自定义Valve的形式进行的。关于Session的文章,前面也写过几篇,会附在结尾处

以下是RedisSessionhandlerValve的invoke方法,我们看,主要是在Valve执行后进行Session的存储或移除。

public void invoke(Request request, Response response) {
    try {
      getNext().invoke(request, response);
    } finally {
      final Session session = request.getSessionInternal(false);
      storeOrRemoveSession(session);
      manager.afterRequest();
    }
  }

而session的保存和移除又是通过manager执行的。 manager.save(session); manager.remove(session);

这里,manager就是前面定义的RedisSessionManager。默认单实例情况下,我们使用的都是StandardManager,对比一下两者,标准的Manager对于session的创建和删除,都会调到其父类ManagerBase中相应的方法,

public void add(Session session) {

        sessions.put(session.getIdInternal(), session);
        int size = getActiveSessions();
        if( size > maxActive ) {
            synchronized(maxActiveUpdateLock) {
                if( size > maxActive ) {
                    maxActive = size;
                }
            }
        }
    }
    
public void remove(Session session, boolean update) {   
if (session.getIdInternal() != null) {
            sessions.remove(session.getIdInternal());
        }
    }   

我们来看,由于其只保存在内存的Map中protected Map<String, Session> sessions = new ConcurrentHashMap<>(),每个Tomcat实例都对于不同的map,多个实例间无法共享数据。

对应到RedisSessionManager对于session的处理,都是直接操作redis,基本代码是下面这个样:

public void save(Session session) throws IOException {
    Jedis jedis = null;
    Boolean error = true;
    try {
      RedisSession redisSession = (RedisSession) session;

      Boolean sessionIsDirty = redisSession.isDirty();
      redisSession.resetDirtyTracking();
      byte[] binaryId = redisSession.getId().getBytes();

      jedis = acquireConnection();
      if (sessionIsDirty || currentSessionIsPersisted.get() != true) {
        jedis.set(binaryId, serializer.serializeFrom(redisSession));
      }
      currentSessionIsPersisted.set(true);
      jedis.expire(binaryId, getMaxInactiveInterval());
    } }

移除时的操作是这样的

public void remove(Session session, boolean update) {
    Jedis jedis = null;
    Boolean error = true;

    log.trace("Removing session ID : " + session.getId());

    try {
      jedis = acquireConnection();
      jedis.del(session.getId());
      error = false;
    } finally {
      if (jedis != null) {
        returnConnection(jedis, error);
      }
    }
  }

而此时,多个Tomcat实例都读取相同的Redis,session数据是共享的,其它实例的初始请求过来时,由于会执行findSession的操作,此时会从Redis中加载session,

public Session findSession(String id) throws IOException {
    RedisSession session;

    if (id == null) {
      session = null;
      currentSessionIsPersisted.set(false);
    } else if (id.equals(currentSessionId.get())) {
      session = currentSession.get();
    } else {
      session = loadSessionFromRedis(id); // 看这里,会从redis中load

      if (session != null) {
        currentSessionIsPersisted.set(true);
      }
    }

    currentSession.set(session);
    currentSessionId.set(id);

    return session;
  }

从而可以保证在一个实例被切换后,另外的实例可以继续响应同一个session的请求。

以上即为Redis实现session共享高可用的一些关键内容。有兴趣的朋友可以看下通过Memcached实现高可用,也是这个原理。顺着这个思路,如果你有将Session存储在其它地方的需求时,完全可以写一个出来,自己动手,丰衣足食。

总结一下,我们是通过自定义的Valve来实现请求后session的拦截,同时,使用自定义的SessionManager,来满足不同的session创建与存储的需求。而至于是存储在Redis/Memcached中,还是存储在DB中,只是位置的区别。原理,是一致的。


禁用Cookie后,Session怎么样使用?


其实本质上是浏览器在关闭之后,应用对应的SessionCookie被清除了,再次打开浏览器请求应用时,之前的SessionId对应的Cookie不存在,所以就会重新创建一个Session。而服务端原来的Session其实还是存在的,只是没人与之对应,就默默的等着超时时间一到,被清除了。



而对于Cookie,我们都知道其是浏览器保存客户端本地的,安全问题暂且不说,但Cookie是可以在浏览器中配置后关闭的。关闭之后,服务器就不能再向浏览器写Cookie的,此时我们基于SessionCookie的实现方式就遇到了问题。


虽然每一次仍然通过response将Set-Cookie添加到header里,但发到浏览器的时候,不能再写Cookie,后续的请求依然是重新发一个sessionId为null的请求,导致每次仍是重新创建session对象,并没有解决交互状态的问题。


为了解决这个问题,服务器提供了另外一种方式:


URL重写,即英文的URLrewrite。


这一方式,其本质上是在每次请求的url后面append 上一个类似于jsessionid=xxxx这样的参数,在服务端解析时,获取到jsessionid对应的值,并根据其获取到对应的Session对象,从而保证了交互状态一致。


一句话就说明白了。


但这一句话背后,有一些事情还是需要注意的,


例如,我们可以自己在url后面写上jsessionid=当前session的id值。这种类似于硬编码,因为服务端获取这个session的id是通过jsessionid这个参数名来获取的,而这个参数我们在前一篇文章中了解到,是可以配置的,当改了之后,后面的sessionId就获取不到了。


其次,为了保证各类url规则的一致,服务端提供了response API来处理,只需要直接使用,就可以完成jsessionid的参数追加。

/**
* Encode the session identifier associated with this response
* into the specified URL, if necessary.
*
*
@param url URL to be encoded
*/
@Override
public String encodeURL(String url) {

String absolute;
try {
absolute = toAbsolute(url);
} catch (IllegalArgumentException iae) {
// Relative URL
return url;
}

if (isEncodeable(absolute)) {//关键在这里
// W3c spec clearly said
if (url.equalsIgnoreCase("")) {
url = absolute;
} else if (url.equals(absolute) && !hasPath(url)) {
url += '/';
}
return (toEncoded(url, request.getSessionInternal().getIdInternal()));
} else {
return (url);
}

}

我们看代码中的实现逻辑:


/**
* Return
<code>true</code> if the specified URL should be encoded with
* a session identifier. This will be true if all of the following
* conditions are met:
*
<ul>
*
<li>The request we are responding to asked for a valid session
*
<li>The requested session ID was not received via a cookie
*
<li>The specified URL points back to somewhere within the web
* application that is responding to this request
*
</ul>
*
*
@param location Absolute URL to be validated
*/
protected boolean isEncodeable(final String location) {

if (location == null) {
return (false);
}

// Is this an intra-document reference?
if (location.startsWith("#")) {
return (false);
}

// Are we in a valid session that is not using cookies?
final Request hreq = request;
final Session session = hreq.getSessionInternal(false);
if (session == null) {
return (false);
}
if (hreq.isRequestedSessionIdFromCookie()) {
return (false);
}

// Is URL encoding permitted
if (!hreq.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.URL)) {
return false;
}

return doIsEncodeable(hreq, session, location);

}


代码中会根据是否使用SessionCookie来决定是否要继续,

之后会继承判断可使用的Session tracking Mode里都有哪些,是否包含URL。


在doIsEncodeable方法中,最终实现是这一行代码

        String tok = ";" +
SessionConfig.getSessionUriParamName(request.getContext()) +
"=" + session.getIdInternal();

也就是我们上面提到的硬编码jsessionid到url后面不太好的原因,这里就是在读取它的配置。


public static String getSessionUriParamName(Context context) {

String result = getConfiguredSessionCookieName(context);

if (result == null) {

result = DEFAULT_SESSION_PARAMETER_NAME;

}

return result;

}


另外,我们上面提到的Session tracking Mode,是在Tomcat启动的时候判断的,而服务端并不可能得知以后要连接的浏览器中,哪些是不允许Cookie的,所以对于Sesion tracking mode,URL无论如何都是可以使用的,而Session cookie是否要使用,是通过在Context组件中配置的其cookies属性为false时禁止的

private void populateSessionTrackingModes() {

// URL re-writing is always enabled by default

defaultSessionTrackingModes = EnumSet.of(SessionTrackingMode.URL);

supportedSessionTrackingModes = EnumSet.of(SessionTrackingMode.URL);

if (context.getCookies()) { //此处读取Context组件的cookies配置,如果为false,则不使用SessionCookie

defaultSessionTrackingModes.add(SessionTrackingMode.COOKIE);

supportedSessionTrackingModes.add(SessionTrackingMode.COOKIE); }


总结下,即为了防止客户端禁用Cookie导致的Session状态不一致的情况,我们可以采用UrlRewrite的方式来保证。

这一过程,我们可以使用response的encodeURL方法来使sessionid添加到url后面,不过是需要先在Context组件中声明不使用cookies。



  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值