上學期買了本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,不過具體可以拓展開來設置:
其中,path屬性可以指定這個曲奇只有在訪問哪些uri的時候纔有效,而maxAge則定義了曲奇的有效期生存時間,默認情況下,如果不給指定時間,則你關閉當前瀏覽器後曲奇就沒有了
在服務器響應之後,我們可以在響應頭的Set-Cookie裏發現我們設置的曲奇
其中,如果你顯式指定了Max-Age之類的值,他的效果就像最後那一排一樣
另外補充一個有點坑的地方:Cookie的key和value都只能是數字或者字母,而不能有中文或者空格,不然會報錯(不過我看網上有調整編碼來解決這個問題的方式),報錯場景如下:
發回曲奇
當瀏覽器獲得了曲奇之後,他每次發送請求的時候,就也會自動帶上曲奇了(但是訪問不同域名的時候不會帶上所有的曲奇,只會帶上對應域名的曲奇)(所以說曲奇搞不好就會被人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會話的過程示意如下:
瀏覽器首次訪問服務器,服務器生成一個Session來記錄該用戶的信息內容,然後給一個SessionId作爲本用戶的身份標識(關於Session的生成場景,後面會具體講到),SessionId將被放在Cookie中返回,以後每次攜帶這個Id,服務器在找數據的時候就會去你對應的Session裏找,然後進行處理
舉一個不是那麼恰當的例子:就像去喫火鍋的時候,剛坐下來的時候(會話開始),服務員給你一個號碼(SessionId),然後他那邊就開始記錄你的消費情況賬單了(Session),你每次去選菜的時候給他看你的號碼牌(SessionId),他就通過號碼牌(SessionId)找到你這桌的賬單(Session),然後去賬單(Session)上記錄你點了些什麼東西(執行一些對本用戶的操作),進行金額計算之類的,最後你走的時候給他看牌子(SessionId),然後他找到你的賬單(Session),給你結賬,然後這個牌子就沒用了(會話結束,Session失效:服務器銷燬,或者用戶再也不使用)
J2EE裏一次完整的Session工作流程跟蹤
會的大佬還是可以跳過
我的後端代碼是這樣寫的:獲取session,然後從Session裏企圖得到name這個參數,然後輸出到頁面,然後再設置name這個參數爲’lehr’
首先,打開一個全新的瀏覽器代表會話開始:
然後訪問我們的servlet:
獲取到內容:名字爲空,因爲我們之前並沒有設置
這時候我們仔細來看請求報文和響應報文:
我們可以發現:我們的請求頭裏一開始是沒有Cookie的,然後在服務器返回給我們的響應頭裏多了一個Cookie:一個名字叫做JSESSIONID的,只有在當前路徑下有效的Cookie
這其實就是服務器生成了Session之後返回給我們的SessionId,下次,我們只需要拿着這一個SessionId就能找到同一個Session了
然後我們第二次訪問這個頁面:
這時候,我們上一次設置的名字就生效了,說明瀏覽器已經認識我們了
然後我們仔細看一下請求和響應報文:
請求報文這一次就帶上了上次設置的cookie,所以服務器就認識我們了呢
然後我們關閉瀏覽器再次打開呢?
他又不認識我們了,然後又給了我們一個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功能就實現啦!