[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功能就实现啦!

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