Tomcat 源码分析 Session机制 (基于8.0.5)

1. Tomcat Session 概述

首先 HTTP 是一个无状态的协议, 这意味着每次发起的HTTP请求, 都是一个全新的请求(与上个请求没有任何联系, 服务端不会保留上个请求的任何信息), 而 Session 的出现就是为了解决这个问题, 将 Client 端的每次请求都关联起来, 要实现 Session 机制 通常通过 Cookie(cookie 里面保存统一标识符号), URI 附加参数, 或者就是SSL (就是SSL 中的各种属性作为一个Client请求的唯一标识), 而在初始化 ApplicationContext 指定默认的Session追踪机制(URL + COOKIE), 若 Connector 配置了 SSLEnabled, 则将通过 SSL 追踪Session的模式也加入追踪机制里面 (将 ApplicationContext.populateSessionTrackingModes()方法)

2. Cookie 概述

Cookie 是在Http传输中存在于Header中的一小撮文本信息(KV), 每次浏览器都会将服务端发送给自己的Cookie信息返回发送给服务端(PS: Cookie的内容存储在浏览器端); 有了这种技术服务端就知道这次请求是谁发送过来的(比如我们这里的Session, 就是基于在Http传输中, 在Cookie里面加入一个全局唯一的标识符号JsessionId来区分是哪个用户的请求)

3. Tomcat 中 Cookie 的解析

在 Tomcat 8.0.5 中 Cookie 的解析是通过内部的函数 processCookies() 来进行操作的(其实就是将Http header 的内容直接赋值给 Cookie 对象, Cookie在Header中找name是"Cookie"的数据, 拿出来进行解析), 我们这里主要从 jsessionid 的角度来看一下整个过程是如何触发的, 我们直接看函数 CoyoteAdapter.postParseRequest() 中解析 jsessionId 那部分

// 尝试从 URL, Cookie, SSL 回话中获取请求的 ID, 并将 mapRequired 设置为 false
String sessionID = null;
if (request.getServletContext().getEffectiveSessionTrackingModes()  // 1. 是否支持通过 URI 尾缀 JSessionId 的方式来追踪 Session 的变化 (默认是支持的)
        .contains(SessionTrackingMode.URL)) {
    sessionID = request.getPathParameter(                           // 2. 从 URI 尾缀的参数中拿取 jsessionId 的数据 (SessionConfig.getSessionUriParamName 是获取对应cookie的名字, 默认 jsessionId, 可以在 web.xml 里面进行定义)
            SessionConfig.getSessionUriParamName(request.getContext()));
    if (sessionID != null) {                                        // 3. 若从 URI 里面拿取了 jsessionId, 则直接进行赋值给 request
        request.setRequestedSessionId(sessionID);
        request.setRequestedSessionURL(true);
    }
}

// Look for session ID in cookies and SSL session
parseSessionCookiesId(req, request);                                // 4. 通过 cookie 里面获取 JSessionId 的值
parseSessionSslId(request);                                         // 5. 在 SSL 模式下获取 JSessionId 的值

/**
 * Parse session id in URL.
 */
protected void parseSessionCookiesId(org.apache.coyote.Request req, Request request) {

    // If session tracking via cookies has been disabled for the current
    // context, don't go looking for a session ID in a cookie as a cookie
    // from a parent context with a session ID may be present which would
    // overwrite the valid session ID encoded in the URL
    Context context = request.getMappingData().context;
    if (context != null && !context.getServletContext()
            .getEffectiveSessionTrackingModes().contains(
                    SessionTrackingMode.COOKIE)) {                      // 1. Tomcat 是否支持 通过 cookie 机制 跟踪 session
        return;
    }

    // Parse session id from cookies
    Cookies serverCookies = req.getCookies();                           // 2. 获取 Cookie的实际引用对象 (PS: 这里还没有触发 Cookie 解析, 也就是 serverCookies 里面是空数据, 数据还只是存储在 http header 里面)
    int count = serverCookies.getCookieCount();                         // 3. 就在这里出发了 Cookie 解析Header里面的数据 (PS: 其实就是 轮训查找 Header 里面那个 name 是 Cookie 的数据, 拿出来进行解析)
    if (count <= 0) {
        return;
    }

    String sessionCookieName = SessionConfig.getSessionCookieName(context); // 4. 获取 sessionId 的名称 JSessionId

    for (int i = 0; i < count; i++) {
        ServerCookie scookie = serverCookies.getCookie(i);              // 5. 轮询所有解析出来的 Cookie
        if (scookie.getName().equals(sessionCookieName)) {              // 6. 比较 Cookie 的名称是否是 jsessionId
            logger.info("scookie.getName().equals(sessionCookieName)");
            logger.info("Arrays.asList(Thread.currentThread().getStackTrace()):" + Arrays.asList(Thread.currentThread().getStackTrace()));
            // Override anything requested in the URL
            if (!request.isRequestedSessionIdFromCookie()) {            // 7. 是否 jsessionId 还没有解析 (并且只将第一个解析成功的值 set 进去)
                // Accept only the first session id cookie
                convertMB(scookie.getValue());                          // 8. 将MessageBytes转成 char
                request.setRequestedSessionId                           // 9. 设置 jsessionId 的值
                    (scookie.getValue().toString());
                request.setRequestedSessionCookie(true);
                request.setRequestedSessionURL(false);
                if (log.isDebugEnabled()) {
                    log.debug(" Requested cookie session id is " +
                        request.getRequestedSessionId());
                }
            } else {
                if (!request.isRequestedSessionIdValid()) {             // 10. 若 Cookie 里面存在好几个 jsessionid, 则进行覆盖 set 值
                    // Replace the session id until one is valid
                    convertMB(scookie.getValue());
                    request.setRequestedSessionId
                        (scookie.getValue().toString());
                }
            }
        }
    }

}

上面的步骤其实就是依次从 URI, Cookie, SSL 里面进行 jsessionId 的解析, 其中从Cookie里面进行解析是最常用的, 而且 就这个Tomcat版本里面, 从cookie里面解析 jsessionid 藏得比较深, 是由 Cookie.getCookieCount() 来进行触发的, 整个解析的过程其实就是将线程 header 里面的数据依次遍历, 找到 name="Cookie"的数据,拿出来解析字符串(这里就不再叙述了); 程序到这里其实若客户端传 jsessionId 的话, 则服务端已经将其解析出来, 并且set到Request对象里面了, 但是 Session 对象还没有触发创建, 最多也就是查找一下 jsessionId 对应的 Session 在 Manager 里面是否存在

4. Tomcat 中 Session 的创建

经过上面的Cookie解析, 则若存在jsessionId的话, 则已经set到Request里面了, 那Session又是何时触发创建的呢? 主要还是代码 request.getSession(), 看代码:

// 获取 request 对应的 session
public HttpSession getSession() {
    Session session = doGetSession(true); // 这里就是 通过 managerBase.sessions 获取 Session
    if (session == null) {
        return null;
    }
    return session.getSession();
}

// create 代表是否创建 StandardSession
protected Session doGetSession(boolean create) {                 // create: 是否创建 StandardSession

    // There cannot be a session if no context has been assigned yet
    if (context == null) {
        return (null);                                           // 1. 检验 StandardContext
    }

    // Return the current session if it exists and is valid
    if ((session != null) && !session.isValid()) {               // 2. 校验 Session 的有效性
        session = null;
    }
    if (session != null) {
        return (session);
    }

    // Return the requested session if it exists and is valid
    Manager manager = null;
    if (context != null) {
        manager = context.getManager();
    }
    if (manager == null)
     {
        return (null);      // Sessions are not supported
    }
    if (requestedSessionId != null) {
        /**
         * 通过 StandardContext 拿到对应的StandardManager, 查找缓存中是否有对应的客户端传递过来的 sessionId
         * 如果有的话, 那么直接 session.access (计数器 + 1), 然后返回
         */
        try {                                                    // 3. 通过 managerBase.sessions 获取 Session
            session = manager.findSession(requestedSessionId);   // 4. 通过客户端的 sessionId 从 managerBase.sessions 来获取 Session 对象
        } catch (IOException e) {
            session = null;
        }
        if ((session != null) && !session.isValid()) {           // 5. 判断 session 是否有效
            session = null;
        }
        if (session != null) {
            session.access();                                    // 6. session access +1
            return (session);
        }
    }

    // Create a new session if requested and the response is not committed
    if (!create) {
        return (null);                                           // 7. 根据标识是否创建 StandardSession ( false 直接返回)
    }
    if ((context != null) && (response != null) &&               // 当前的 Context 是否支持通过 cookie 的方式来追踪 Session
        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())               // 8. 到这里其实是没有找到 session, 直接创建 Session 出来
            && isRequestedSessionIdFromCookie()) || requestedSessionSSL ) {
        session = manager.createSession(getRequestedSessionId()); // 9. 从客户端读取 sessionID, 并且根据这个 sessionId 创建 Session
    } else {
        session = manager.createSession(null);
    }

    // Creating a new session cookie based on that session
    if ((session != null) && (getContext() != null)
           && getContext().getServletContext().
                   getEffectiveSessionTrackingModes().contains(
                           SessionTrackingMode.COOKIE)) {
        Cookie cookie =
            ApplicationSessionCookieConfig.createSessionCookie(  // 10. 根据 sessionId 来创建一个 Cookie
                    context, session.getIdInternal(), isSecure());

        response.addSessionCookieInternal(cookie);               // 11. 最后在响应体中写入 cookie
    }

    if (session == null) {
        return null;
    }

    session.access();                                           // 12. session access 计数器 + 1
    return session;
}


public Session createSession(String sessionId) {

    if ((maxActiveSessions >= 0) &&
            (getActiveSessions() >= maxActiveSessions)) {      // 1. 判断 单节点的 Session 个数是否超过限制
        rejectedSessions++;
        throw new TooManyActiveSessionsException(
                sm.getString("managerBase.createSession.ise"),
                maxActiveSessions);
    }

    // Recycle or create a Session instance
    // 创建一个 空的 session
    Session session = createEmptySession();                     // 2. 创建 Session

    // Initialize the properties of the new session and return it
    // 初始化空 session 的属性
    session.setNew(true);
    session.setValid(true);
    session.setCreationTime(System.currentTimeMillis());
    session.setMaxInactiveInterval(this.maxInactiveInterval);  // 3. StandardSession 最大的默认 Session 激活时间
    String id = sessionId;
    if (id == null) {                                          // 若没有从 client 端读取到 jsessionId
        id = generateSessionId();                              // 4. 生成 sessionId (这里通过随机数来生成)
    }
    session.setId(id);
    sessionCounter++;

    SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
    synchronized (sessionCreationTiming) {
        sessionCreationTiming.add(timing);                    // 5. 每次创建 Session 都会创建一个 SessionTiming, 并且 push 到 链表 sessionCreationTiming 的最后
        sessionCreationTiming.poll();                         // 6. 并且将 链表 最前面的节点删除
    }                                                         // 那这个 sessionCreationTiming 是什么作用呢, 其实 sessionCreationTiming 是用来统计 Session的新建及失效的频率 (好像Zookeeper 里面也有这个的统计方式)
    return (session);
}

其主要的步骤就是:

1. 若 request.Session != null, 则直接返回 (说明同一时刻之前有其他线程创建了Session, 并且赋值给了 request)
2. 若 requestedSessionId != null, 则直接通过 manager 来进行查找一下, 并且判断是否有效
3. 调用 manager.createSession 来创建对应的Session
4. 根据 SessionId 来创建 Cookie, 并且将 Cookie 放到 Response 里面
5. 直接返回 Session
5. Tomcat Session 过期操作

Session 的过期主要通过StandardContext.backgroundProcess() 来进行触发, 进而触发 ManagerBase.backgroundProcess()执行, 在这里面就是检测Session的过期操作, 见代码

public void backgroundProcess() {
    count = (count + 1) % processExpiresFrequency;        // 这里有两部操作, 1 增加background的执行次数, 2. 将 count 对processExpiresFrequency去模, 若结果是0, 则就执行过期检查操作(从这里也可以看出 processExpiresFrequency 越小, 则执行得越频繁)
    if (count == 0)
        processExpires();
}

/**
 * Invalidate all sessions that have expired.
 */
public void processExpires() {

    long timeNow = System.currentTimeMillis();
    Session sessions[] = findSessions();                         // 1. 获取所有待检查的 Session
    int expireHere = 0 ;

    if(log.isDebugEnabled())
        log.debug("Start expire sessions " + getName() + " at " + timeNow + " sessioncount " + sessions.length);
    for (int i = 0; i < sessions.length; i++) {
        if (sessions[i]!=null && !sessions[i].isValid()) {      // 2. 触发检查Session的过期检查
            expireHere++;
        }
    }
    long timeEnd = System.currentTimeMillis();
    if(log.isDebugEnabled())
         log.debug("End expire sessions " + getName() + " processingTime " + (timeEnd - timeNow) + " expired sessions: " + expireHere);
    processingTime += ( timeEnd - timeNow );
}

public boolean isValid() {

    if (!this.isValid) {                                    // 1. 对应 HttpSession 的 invalidate, 无论 session 有无过期, 若这个值是 false, 则直接 return
        return false;
    }

    if (this.expiring) {                                    // 2. 通过这个过期标识判断 Session 是否有效(可能其他地方触发了 Session 的过期)
        return true;
    }
    /**
     * 参考地址
     * http://tomcat.apache.org/tomcat-8.5-doc/config/systemprops.html
     * 当设置这个属性后, Tomcat 会对 session 进行请求计数, 其实就是 accessCount, 也就是说 在 session 的范围内, 有几个 有效的 链接是起作用的
     * 当 request 过来 accessCount + 1, 关闭 request, accessCount - 1, accessCount 实际上就是针对单个 Session 的在线活跃统计
     * 所以说 accessCount.get() > 0 则 session 一定有效
     */
    if (ACTIVITY_CHECK && accessCount.get() > 0) {
        return true;
    }

    if (maxInactiveInterval > 0) {                          // 3. maxInactiveInterval 标志的是最多再过多久, 客户端再次发送请求过来, Session 还会存活着
        long timeNow = System.currentTimeMillis();
        int timeIdle;
        if (LAST_ACCESS_AT_START) {
            timeIdle = (int) ((timeNow - lastAccessedTime) / 1000L);
        } else {
            timeIdle = (int) ((timeNow - thisAccessedTime) / 1000L);
        }
        if (timeIdle >= maxInactiveInterval) {              // 4. 说明 这个 Session 已经超过 maxInactiveInterval 这么长的时间没有没访问了
            expire(true);                                   // 5. 执行 过期处理函数(1. 主要是将 StandardSession 中的各种属性置为 false, 并且从 manager 里面删除, 触发相应的监听事件)
        }
    }

    return this.isValid;
}
6. Tomcat Session 分布式集群存储

Tomcat本身是支持集群的, 通过广播的形式进行事件消息通知(一台机器上Session有任何变化, 直接广播消息给集群中的所有节点), 当一旦出现网络不好, 极易出现集群中出现Session不一致的情况, 这其实是很糟糕的. 所以就出现了将Tomcat集群中的Session都存储在一个分布式缓存上的设计.
设计思路:

1. RedisSession 继承StandardSession, 其主要是判断是否在 put, remove 等修改属性的方法调用时触犯同步数据到 redis
2. RedisSessionHandlerValve 继承 ValveBase, 在执行完所有 Valve 后, 调用 RedisSessionManager 将Session同步至 redis
3. RedisSessionManager 继承 ManagerBase, 主要是将 RedisSession 序列化, 反序列化, 连接redis, 创建 RedisSession 将 RedisSession 的任何改变同步至 redis
(PS: 针对 Session 的过期, 完全可以用 redis 的 expire 功能, 每次更改Session后, expire新的时间)
对于以上设计的实现 : https://github.com/jcoleman/tomcat-redis-session-manager
7. 总结

本篇只对Tomcat 中的Session进行简述, 而Tomcat中的Session的设计能给我们设计程序很多指导意义, 此刻我还在想zookeeper的Session的操作机制, Zookeeper中将Session根据其expireTime 放入一个个槽内, 定期清除槽里面的数据, 来达到清除 过期Session 的功能

8. 参考

Servlets - Session Tracking
What is a Http Cookie
Tomcat 7.0 原理与源码分析
Tomcat 内核设计剖析
Tomcat 架构解析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容