[6]菜雞寫Tomcat之Cookie與Session

上學期買了本How Tomcat Works然後一直丟在書櫃裏沒看,之前有一天閒,翻出來看了幾頁,覺得挺有趣的,所以就跟着書上的思路和一些tomcat源碼,自己寫了一個簡單的應用服務器—Tiny Server

感覺在這個過程中學到了不少東西,所以想把自己的思路和想法寫出來分享一下

Ps:因爲是自己的理解,所以如有不對請各位大佬指出,感激不盡

我寫的Tiny Server的源碼在這裏:https://github.com/Lehr130/TinyServer

整個項目並沒有完全按照tomcat的寫法來,只是按照自己的理解去實現的,而且有些功能並沒有完全實現,還有待各位指教

相關內容

[7]菜雞寫Tomcat之Container
[6]菜雞寫Tomcat之Cookie與Session
[5]菜雞寫Tomcat之WebClassloader
[4]菜雞寫Tomcat之生命週期控制
[3]菜雞寫Tomcat之Filter
[2]菜雞寫Tomcat之Context
[1]菜雞寫Tomcat之Wrapper

兩種會話跟蹤技術

是一些基本概念性的東西,讀者會的話可以跳過,不影響後面的

Cookie

概述

Cookie,又稱曲奇餅,是一種會話跟蹤技術:服務器爲了辨別用戶的身份,而向瀏覽器發送的一段小內容,此後,瀏覽器每次發送請求的時候就會攜帶着服務器給他的曲奇餅,作爲一個標識,讓瀏覽器知道他是誰
在這裏插入圖片描述

工作原理

獲得曲奇

以Servlet服務器爲例,當服務器想要在瀏覽器中設置一塊曲奇餅的時候,他會這樣做:

resp.addCookie(new Cookie("丹麥皇家曲奇","一斤曲奇"));

調用response的add方法,然後放入一塊曲奇餅。這個曲奇餅目前只有兩個屬性key和value,不過具體可以拓展開來設置:

image-20200229234542011

其中,path屬性可以指定這個曲奇只有在訪問哪些uri的時候纔有效,而maxAge則定義了曲奇的有效期生存時間,默認情況下,如果不給指定時間,則你關閉當前瀏覽器後曲奇就沒有了

在服務器響應之後,我們可以在響應頭的Set-Cookie裏發現我們設置的曲奇

image-20200229235229059

其中,如果你顯式指定了Max-Age之類的值,他的效果就像最後那一排一樣

另外補充一個有點坑的地方:Cookie的key和value都只能是數字或者字母,而不能有中文或者空格,不然會報錯(不過我看網上有調整編碼來解決這個問題的方式),報錯場景如下:

image-20200229235546200

發回曲奇

當瀏覽器獲得了曲奇之後,他每次發送請求的時候,就也會自動帶上曲奇了(但是訪問不同域名的時候不會帶上所有的曲奇,只會帶上對應域名的曲奇)(所以說曲奇搞不好就會被人CSRF給暴錘)

在請求頭中,會有一個叫做Cookies的請求頭,後面跟着多組Cookie:

Cookie:cookie1=danis;cookie2=good;cookie3=verygood

這裏同樣注意,當你在設計算法來解析Cookie的時候,記得使用trim()來處理掉意外產生的空格情況,因爲上文說到了,如果你的曲奇裏有空格或者漢字之類的,就會報錯

至此,這就是曲奇工作的原理了

Session

Session,直譯過來,就是會話的意思,也是一種會話跟蹤技術

和Cookie不同的是,Cookie把信息存放在客戶端,而Session是把信息放在服務器,同時只給客戶端一個SessionId用來下次找到對應的Session
而在Java Servlet中,他的Session的實現就依賴到了Cookie來交給客戶端這個SessionId

Session的工作流程

當某個瀏覽器在打開之後第一次訪問某個服務器,則稱之爲一次會話的開始;在瀏覽器全部關閉(如果只關了個別頁面則不算)的時候,這次會話就結束了

其中,整個Session會話的過程示意如下:

image-20200227163238464

瀏覽器首次訪問服務器,服務器生成一個Session來記錄該用戶的信息內容,然後給一個SessionId作爲本用戶的身份標識(關於Session的生成場景,後面會具體講到),SessionId將被放在Cookie中返回,以後每次攜帶這個Id,服務器在找數據的時候就會去你對應的Session裏找,然後進行處理

舉一個不是那麼恰當的例子:就像去喫火鍋的時候,剛坐下來的時候(會話開始),服務員給你一個號碼(SessionId),然後他那邊就開始記錄你的消費情況賬單了(Session),你每次去選菜的時候給他看你的號碼牌(SessionId),他就通過號碼牌(SessionId)找到你這桌的賬單(Session),然後去賬單(Session)上記錄你點了些什麼東西(執行一些對本用戶的操作),進行金額計算之類的,最後你走的時候給他看牌子(SessionId),然後他找到你的賬單(Session),給你結賬,然後這個牌子就沒用了(會話結束,Session失效:服務器銷燬,或者用戶再也不使用)

J2EE裏一次完整的Session工作流程跟蹤

會的大佬還是可以跳過

我的後端代碼是這樣寫的:獲取session,然後從Session裏企圖得到name這個參數,然後輸出到頁面,然後再設置name這個參數爲’lehr’

image-20200301212715365

首先,打開一個全新的瀏覽器代表會話開始:

image-20200301211847064

然後訪問我們的servlet:

image-20200301213053707

獲取到內容:名字爲空,因爲我們之前並沒有設置

這時候我們仔細來看請求報文和響應報文:

image-20200301213119514

我們可以發現:我們的請求頭裏一開始是沒有Cookie的,然後在服務器返回給我們的響應頭裏多了一個Cookie:一個名字叫做JSESSIONID的,只有在當前路徑下有效的Cookie

這其實就是服務器生成了Session之後返回給我們的SessionId,下次,我們只需要拿着這一個SessionId就能找到同一個Session了

然後我們第二次訪問這個頁面:

image-20200301213203201

這時候,我們上一次設置的名字就生效了,說明瀏覽器已經認識我們了

然後我們仔細看一下請求和響應報文:

image-20200301213243158

請求報文這一次就帶上了上次設置的cookie,所以服務器就認識我們了呢

然後我們關閉瀏覽器再次打開呢?

image-20200301213410716

他又不認識我們了,然後又給了我們一個SessionId,說明上次的會話已經結束了

Session有關的類

HttpSession

是一個接口,來自Servlet規範中,用來表示會話,所以如果想想要實現會話機制,那麼就需要寫一個類來實現這個接口

StandardSession和Facade類

這個類就是Tomcat對於Session的實現,除了實現HttpSession接口以外,還需要實現序列化Serializable接口,以方便序列化Session(用於本地持久化或者做分佈式處理)

然後就是Facade類了,返回給servlet程序員的時候,要門面對象來封裝一下,以避免別把你服務器幹翻

SessionManager

在Context容器中,有一個叫做SessionManager的組件(原版裏就叫做Manager),他是一個會話管理器,來管理什麼時候創建,更新,銷燬Session對象,當有請求來的時候,會找出一個有效的Session來進行服務

其中,有一些Manager還能提供持久化功能,比如Tomcat中的PersistenManager,他在當服務器關閉的時候會把內存裏的session存放到磁盤裏,然後下次服務器啓動的時候去重新加載,在我後續的代碼中,我並沒有完全實現這個類,而是給我自己寫的Manager添加了一個叫做Store的組件來專門做持久化處理

完整的Session實現過程

接下里我會順着一次Session的工作流程,來具體介紹每個步驟的實現(我自己的實現,不是tomcat的哈)

接受請求並處理Cookie

要獲得Session,首先我們需要看下請求報文中是否有之前我們給的JsessionId

首先,在封裝請求的類Request中(一個實現了HttpServletRequest的類),我們需要有這樣一個成員:

private List<Cookie> cookies = new ArrayList<>();

他會統一存儲從請求頭中解析到的曲奇餅

然後我們來看下在生成request對象的時候解析請求頭部分是怎麼獲取曲奇餅的

(其實感覺也沒啥看的,強行湊字數…)

      String cookieStr = headers.get("Cookie");

        if(cookieStr!=null&&cookieStr.length()>0)
        {
            String[] cookieStrs = cookieStr.split(";");
            for (String s : cookieStrs) {
                String[] str = s.split("=");

                //特殊情況處理一下sessionID  //這裏注意一下所有的key前面都有空格!!!
                String key = str[0].trim();
                String value = str[1];

                if("JSESSIONID".equalsIgnoreCase(key))
                {
                    //單獨存放,好找
                    jSessionId = value;
                }
                //這裏注意一個萬惡的bug
                //cookie的字符串裏不能有空格不然會報錯tmd
                cookies.add(new Cookie(key,value));
            }

Cookie請求頭的形式是這樣的:

Cookie: a=1;b=2;c=3

所以就,就,就解析瞭然後new Cookie放入LIst即可

然後接下來Request類的getCookies就可以寫了

@Override
    public Cookie[] getCookies() {
        return cookies.toArray(new Cookie[cookies.size()]);
    }

由於我之前保存曲奇是用的List,所以這裏做了個轉換

Session的創建

首先,來看一個Servlet的一個知識點:Session只有在用戶調用req.getSession或者req.getSession(true)的時候纔會被創建

所以,我們來看一下HttpServletRequest類中提供的這兩種方法:

(這裏就以我的實現爲例子了,大體思路和Tomcat是差不多的)

getSession()

    @Override
    public HttpSession getSession() {
        return getSession(true);
    }

emmm,他其實就是調用了另外那個getSession(Boolean flag)的方法嘛,然後直接默認爲true了

這個地方的設計思路就和Servlet的init()和init(ServletConfig sc)很類似了,都那個意思

getSession(Boolean flag)

    @Override
    public HttpSession getSession(boolean b) {
        return doGetSession(b);
    }

emmm這不是套娃嘛,他又調用了另外一個方法

其實這只是封裝起來簡潔點而已

不過這裏我想講下,當那個布爾變量取不同的值的時候是什麼意思,然後我再去講他的實現邏輯

首先,當調用getSession(true)的時候,如果之前沒有session,那麼,他就會去創建一個新的session並返回

但是如果調用getSession(false)的時候,服務器只會去試試,如果有session就返回給你,如果沒有session,就返回空而不創建新的

好了,現在我們再來看具體的實現邏輯

doGetSession(Boolean flag)

先上代碼:

(在Tomcat源代碼中,他是把這個方法寫到了一個叫做requestBase的地方的)

    private HttpSession doGetSession(boolean create)
    {
        if(context==null)
        {
            return null;
        }

        //獲取當前上下文中的session管理器
        TommySessionManager sessionManager = context.getSessionManager();

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

        //設置session
        HttpSession session = sessionManager.findSession(jSessionId);

        if(create==false)
        {
            return session;
        }

        //創建session
        if(session==null)
        {
            session = sessionManager.createSession();
            //響應的時候響應頭裏需要放東西,所以這裏記錄一下sessionId
            jSessionId = session.getId();
        }
        return session;
    }

先補充一點,在Request進入Context容器之後,他會把自己和這個容器綁定,所以我們能從自己寫的request中獲取他當前所在的容器情況(但是交給用戶之後就門面對象處理了,用戶無法獲得)

首先我們會從當前上下文Context容器中獲取session管理器

  • Request會先試圖用自己從cookie中解析到的jsessionId來查找,看看是否能找到一個有效的Session
  • 如果找不到,則根據傳入的布爾變量來判斷是否需要生成一個Session
  • 如果生成一個新的session,這時候我們就要記錄下他的jsessionid以便後續調用的時候不必再創建

關於Session是如何生成的,這就是SessionManager的事情了,最後會講到

Session調用時的各種方法

這裏又是Serlvet基礎知識小課堂,和實現無關,可以跳過

有效期系列

Session中有這幾個屬性:

 	//最後的修改時間
	private long lastAccessedTime;
	//被創建的時間
    private long creationTime;
    //默認的有效期是半小時
    private int maxInactiveInterval = 30*60;

Session在創建之後,默認的有效期是半個小時(也可以設置,負數是永遠不過期),這半個小時的計算方式不是從被創建開始,而是從距離上次調用後算起,也就是,當你最後一次使用Sessoin,然後過半個小時不登錄,這個session就失效了,他就會把自己銷燬:

    @Override
    public void invalidate() {
        //直接銷燬自己
        this.manager.removeSession(sessionId);
    }

是否是新的

    @Override
    public boolean isNew() {
       
        return creationTime==lastAccessedTime;
    }

當在本次請求中,如果是剛創建的,則這裏會返回true,不過如果是第二次會話,在Session已經存在了的情況下,這裏就是false了,說明他不是新創建的了

Ps:如果是本次新建的,假如你在一次請求中調用兩次getSession(),得到的session,他們的結果都是true(雖然新建只會發生在第一次)(但是我這裏並沒有實現這個,所以這裏是個,有空我再研究下確定了再回來填)

Attribute VS Value

用戶可以在Session中房屬性,不過有兩種選擇:Attribute 和 Value

對於Attribute,提供了get/set/remove/getAttributeNames這幾種方法

對於Value,提供了 getValueNames() 、putValue(String s, Object o) 、removeValue(String s) 方法

但是,Value方法和Attribute方法本質上是共享的一個map的

session.setAttribute("name","Lehr");
System.out.println(session.getValue("name"));

他的執行結果就是:輸出"Lehr"

你通過Attribute set的方法,可以通過Value去get,反之

最後,Value方法即將被廢棄,不建議使用了

響應報文

響應的時候分爲兩種情況:

  • 之前已經有Session了,則什麼都不用管
  • 本次請求的過程新創建了Session,則我們需要在響應報文裏設置一個帶有sessionId的曲奇返回,邏輯如下:
            HttpSession session = req.getSession(false);
            if (session != null && session.isNew()) {
                res.addCookie(new Cookie("JSESSIONID", session.getId()));
            }

這個步驟是在你的service方法執行完之後的,我把這個方法放到了Wrapper的invoke執行chain.doFilters動作的最後

這裏首先判斷你本次有沒有session,如果有,則判斷是否是本次新生成的,如果是新生成的,則將其id設置爲曲奇返回

至此,Session的工作流程就結束了

接下來我們來看SessionManager

SessionManager的工作

Session的查找

    public HttpSession findSession(String sessionId)
    {
        TommySession session = sessionPool.get(sessionId);
        if(session!=null)
        {
            //檢查是否過期:
            //session的過期時間是從session不活動的時候開始計算
            // 如果session一直活動,session就總不會過期
            // 從該Session未被訪問,開始計時
            // 一旦Session被訪問,計時清0;
            Long lastTime = session.getLastAccessedTime();
            Long validTime = session.getMaxInactiveInterval()*1000L;


            //FIXME:這裏沒有考負數作爲永久有效的判定情況

            if(lastTime+validTime<System.currentTimeMillis())
            {
                //過期了
                System.out.println("過期了!");
                removeSession(sessionId);
                return null;
            }

            //修改最近的訪問時間
            session.setLastAccessedTime(System.currentTimeMillis());

        }

        return session;
    }

request中的doGetSesssion方法獲取Session是調用的findSession方法

這個方法首先會去按照sessionId去內存裏查找session,如果沒有則去創建,如果有的話,則會驗證過期,並對訪問時間進行修改,然後返回這個Session

Session創建

當findSession沒有找到Session的時候,SessionManager會去創建一個Session:

    public HttpSession createSession()
    {
        String sessionId = UUID.randomUUID().toString();
        TommySession session = new TommySession(this,sessionId,container.getServletContext());
        sessionPool.put(sessionId,session);
        //注意這裏的門面對象!!!
        return new TommySessionFacade(session);
    }

創建Session的時候,會給每個Session標記上,他是由哪一個SessionManager管理,這裏採用了關聯的方式,在接下來的刪除階段,我們就會用Session內部關聯的SessionManager來銷燬這個Session

Session銷燬

之前我們在Session中看到的這個方法:

@Override
public void invalidate() {
    //直接銷燬自己
    this.manager.removeSession(sessionId);
}

由於Session實例對象是全部被保存在SessionManager中的,所以到銷燬的時候,Session自己需要通知Manager來銷燬他,並把自己的Id給他,然後SessionManager中會執行這個方法:

    //銷燬方法,具體是在session裏面通過自己綁定的這個manager來調用從而銷燬
    public void removeSession(String sessionId)
    {
        sessionPool.remove(sessionId);
    }

從緩存中刪除他,這樣,sessionId也就失效了,這個Session也就沒有了

Session後臺檢查

Context的後臺進程中,會有一條線程,每隔默認10秒來檢查一次是否有session過期需要被清理(這個線程也就是Webclassloader裏檢查文件夾變化的那個線程)

    public void clean()
    {
        sessionPool.values().forEach(session->{

            Long lastTime = session.getLastAccessedTime();
            Long validTime = session.getMaxInactiveInterval()*1000L;

            if(lastTime+validTime<System.currentTimeMillis())
            {
                //過期了
                System.out.println("過期了!");
                removeSession(session.getId());
            }
        });

    }

當然,原版中還做了各種換出,置換算法,我這裏很簡陋

Session持久化

在Tomcat源碼中,有一個Store的接口來負責Session的持久化,而我這裏寫得簡陋,就直接寫了一個叫做StoreManager的組件來進行專門的Session存儲

每當SessionManager執行start(開始的生命週期的時候),就會先用這個來看看本地是否有上次持久化的Session,當SessionManager執行stop(生命週期結束的時候),就會通過他來把當前內存裏的Session全部持久化到本地去,代碼如下:

package tiny.lehr.tomcat;

import tiny.lehr.enums.Message;
import tiny.lehr.tomcat.bean.TommySession;
import tiny.lehr.tomcat.lifecircle.TommyLifecycle;
import tiny.lehr.tomcat.lifecircle.TommyLifecycleListener;

import java.io.*;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author Lehr
 * @create: 2020-02-21
 * 這個其實就是簡單版的給session持久化的組件
 */
public class TommyStoreManager implements TommyLifecycle {


    //默認全部放在servlet容器頂層文件夾
    private String persistantPath = Message.SERVLET_PATH+ File.separator+"/sessionStorage";

    private static final String PREFIX = "sessionStorage_";

    //每次加載好了是會把文件刪除的!!!所以這裏也要新建文件
    public void store(Map<String, TommySession> sessions,String appName) throws Exception
    {

        File f = new File(persistantPath+File.separator+PREFIX+File.separator+appName);
        if(f.exists())
        {
            f.delete();
        }


        f.createNewFile();

        if(sessions==null)
        {
            f.delete();
            return ;
        }


        try(ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(f));)
        {
            List<TommySession> sessionList = new ArrayList<>();

            sessionList.addAll(sessions.values());

            System.out.println(sessionList.size());

            oos.writeObject(sessionList);

            oos.flush();

        }

    }

    public Map<String, TommySession> getSessions(String appName)
    {
        Map<String, TommySession> sessions = new HashMap<>();

        File f = new File(persistantPath+File.separator+PREFIX+File.separator+appName);
        if(!f.exists())
        {
            return null;
        }


        try(ObjectInputStream in = new ObjectInputStream(new FileInputStream(f));)
        {
            List<TommySession> tommySessions = (List<TommySession>) in.readObject();

            tommySessions.forEach(s->sessions.put(s.getId(),s));

        } catch (Exception e) {
            e.printStackTrace();
        }

        //獲取完之後要把文件刪除了!!!
        f.delete();

        return sessions;
    }
    
    @Override
    public void addLifecycleListener(TommyLifecycleListener listener) {

    }

    @Override
    public List<TommyLifecycleListener> findLifecycleListeners() {
        return null;
    }

    @Override
    public void removeLifecycleListener(TommyLifecycleListener listener) {

    }

    @Override
    public void start() throws Exception {

    }

    @Override
    public void stop() throws Exception {

    }
}

🎉 到這裏,Session功能就實現啦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章