上学期买了本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功能就实现啦!