8.1.5 Session(TS 版)

109 阅读6分钟

背景

       本文将介绍 Nodejs 相关技术,让更多前端技术人员突破技术壁垒,向全栈靠拢
尝试坚持学习新技术、写技术文章,学而时习之,不亦说乎!

1、一叶障目,不见泰山,对技术怀有一颗敬畏之心
2、脚踏实地,一步一个脚印,最后肯定有回报
3、水滴石穿、熟能生巧,多敲多 debug

内容回顾

cookie 的性能影响

  1. 每次会把所有数据都携带到后台,产生不必要的宽带浪费。
  2. 客户端能够直接更改 cookie 数据,容易被篡改。

初识 session

由于 cookie 会产生相关的性能问题,session 应运而生,session 的数据只保留在服务器端,客户端无法修改,这样数据的安全性得到了一定的保障,客户端只需要携带映射 session 数据的 id 给服务器端即可,无需每次传递不必要的数据。
如何将这个 id 和服务器中的 session 数据一一对应起来呢?这里有常见的两种实现方式。

1、基于 Cookie 来实现映射

image.png 虽然把所有数据放到 cookie 中不可取,但是将口令放在 cookie 中还是可以的,因为口令一旦被篡改,也就丢失了映射关系,也就无法修改服务端中映射的 session 数据了。

image.png

举个例子,中秋节到了,公司给每一个员工送关怀,于是行政发表通知,所有人凭工牌来员工办公室领取月饼,对于员工,只需要工牌来领取对应的月饼即可。对于人事,工牌有对应的编号,也对应着个人信息表上的详细资料(身份证、性别、年龄、学历。。。),于是每次员工领取一次,人事对应的表上签一个字,这样月饼就不会发错发漏。
如果是 cookie 的方式,你不仅要用工牌去领月饼,恐怕还得带上身份证、性别、年龄、学历。。。等个人信息。

开始动手

目录结构

├── ch8 
│ ├── 8.1.3QueryString.ts
│ ├── 8.1.4Cookie.ts
│ ├── 8.1.5Session.ts
├── package.json 
├── index.ts

代码内容

// ./ch8/8.1.3QueryString.ts

import type { Request, Response } from ".";

const parseQueryString = (req: Request, res: Response) => {
    req.query = {};
    if (req.url) {
        const searchParams = new URL(req.url, `http://${req.headers.host}`).searchParams;
        req.query = Object.fromEntries(searchParams);
    }
    return req.query;
}

export default parseQueryString;
// ./ch8/8.1.4Cookie.ts

import type { Request, Response } from "."
import type { IncomingHttpHeaders } from "node:http"

export type CookieHeaders = Pick<IncomingHttpHeaders,
    "Max-Age" | "Domain" | "Path" | "Expires" | "HttpOnly" | "Secure"
>;

export type CookieOptions = {
    maxAge?: number,
    domain?: string,
    path?: string,
    expires?: Date,
    httpOnly?: boolean,
    secure?: boolean
}

export const parseCookie = (req: Request, res: Response) => {
    req.cookie = {};
    let cookies = req.headers.cookie;
    if (cookies) {
        cookies = cookies.replaceAll(";", "&");
        const searchParams = new URLSearchParams(cookies);
        const entries = searchParams.entries();
        for (const [key, val] of entries) {
            req.cookie[key.trim()] = val;
        }
    }
    return req.cookie;
}

export const serialize = (name: string, val: string, opts?: Partial<CookieOptions>) => {
    const pairs = [name + "=" + encodeURIComponent(val)];
    opts = opts || {};

    if (opts.maxAge) pairs.push('Max-Age=' + opts.maxAge);
    if (opts.domain) pairs.push('Domain=' + opts.maxAge);
    if (opts.path) pairs.push('Path=' + opts.maxAge);
    if (opts.expires) pairs.push('Expires=' + opts.expires.toUTCString());
    if (opts.httpOnly) pairs.push('HttpOnly');
    if (opts.secure) pairs.push('Secure');

    return pairs.join("; ");
}

export default parseCookie;
// ./ch8/8.1.5Session.ts

import type { Request, Response } from "."
import { serialize } from "./8.1.4Cookie"

export type Session = {
    id: string,
    cookie: {
        expire: number
    },
    data: Record<string, unknown>,
}

const pool: Record<Session["id"], Session> = {};
const key: string = "n_session_id";
const EXPIRES: number = 20 * 60 * 1000;

/**
 * 生成随机 id
 * @returns {string}
 */
const randomId = () => Buffer.from(`${Date.now() + Math.random()}`).toString("base64");

/**
 * 生成 session
 * @returns {Session}
 */
const generate = () => {
    const session: Session = {
        id: randomId(),
        cookie: {
            expire: Date.now() + EXPIRES
        },
        data: {}
    };
    pool[session.id] = session;
    return session;
}

/**
 * 默认以 cookie 的形式处理
 * @param {Request} req 
 * @param {Response} res 
 */
const handle = (req: Request, res: Response) => {
    const cookie = req.cookie;
    if (!cookie || !cookie[key]) {
        req.session = generate();
    } else {
        const id = cookie[key];
        const session = pool[id];
        if (!session) {
            req.session = generate();
        } else {
            if (session.cookie.expire > Date.now()) {
                session.cookie.expire = Date.now() + EXPIRES;
                session.data.isVisit = true;
                req.session = session;
            } else {
                delete pool[id];
                req.session = generate();
            }
        }
    }
    hackWriteHead(req, res);
}

/**
 * 重写请求头
 * @param {Request} req 
 * @param {Response} res 
 */
const hackWriteHead = (req: Request, res: Response) => {
    const writeHead = res.writeHead;
    // 这是使用 Parameters<typeof writeHead> 报错,所以使用了 any
    // 有没有大佬知道重载的方法入参类型要这么做,评论区留言,感谢!!!
    res.writeHead = function (...args: any) {
        res.setHeader("Set-Cookie", serialize(key, req.session?.id!));
        return writeHead.apply(this, args);
    }
}

const test = (req: Request, res: Response) => {
    const session = req.session;
    res.writeHead(200, { "Content-Type": "text/html; charset=UTF-8" });
    if (session && session.data.isVisit) {
        res.end("欢迎再次访问")
    } else {
        res.end("欢迎第一次访问")
    }
}

export default {
    handle,
    test
}
// ./index.ts

import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";

import parseQueryString from "./8.1.3QueryString";
import parseCookie from "./8.1.4Cookie";
import sessionManager, { Session } from "./8.1.5Session";

export type Request = IncomingMessage & {
    query?: Record<string, string>,
    cookie?: Record<string, string>,
    session?: Session
};
export type Response = ServerResponse;

const server = http.createServer((req: Request, res) => {
    parseQueryString(req, res);
    parseCookie(req, res);

    sessionManager.handle(req, res);
    sessionManager.test(req, res);
});

const host = "127.0.0.1";
const port = 9527;
server.listen(port, host, () => {
    console.log(`服务器启动成功: http://${host}:${port}`);
})

执行命令

ts-node .\index.ts

浏览器访问

http://127.0.0.1:9527

浏览器表现(第一次访问)

image.png

请求头没有携带 session id 信息到服务器端
响应头携带 Set-Cookie:n_session_id=MTY3NzA1NTY2NjA4OC4yMzc1 给浏览器,浏览器会保存到本地

浏览器表现(第二次访问)

image.png

请求头携带 n_session_id 给服务器

补充

n_session_id 可以随意约定,比如 Connect 默认采用的是 connect_uid,Tomcat 默认采用 jsessionId 等,一旦服务器检查到用户请求 Cookie 中没有携带该值,它就会马上生成一个,并且可以设定超时时间。

2、基于 URL 查询字符串的方式来实现映射

image.png

第一种方式依赖 cookie 来实现,也是大多数 web 应用的方案,当采用这种方案时候,网站第一时间会请求是否开启 cookie,如果禁用 cookie,那第一种方式也无法实现相关功能,毕竟巧妇难为无米之炊~

第二种方式原理是检查是否携带对应的查询字符串,如果没有,就会生成新的带值的 URL,然后形成跳转,返回给前端

开始动手

添加代码

// ./ch8/8.1.5Session.ts

const getURL = (_url: string, key: string, val: string) => {
    const url = new URL(_url);
    url.searchParams.set(key, val);
    return url.href;
}

/**
 * 重定向
 * tips: 请求头携带 Location 字段,且状态码为 302,则可以使客户端重定向到 Location 对应的 url
 * @param {Response} res
 * @param {string} url 
 */
const redirect = (res: Response, url: string) => {
    res.setHeader("Location", url);
    res.writeHead(302);
    res.end()
}

/**
 * 以 URL 携带参数的方式来处理
 * @param {Request} req 
 * @param {Response} res 
 * @returns {boolean} 
 */
const handleByURL = (req: Request, res: Response) => {

    const query = req.query;
    const url = new URL(req.url ?? '', `http://${req.headers.host}`);

    if (!query || !query[key]) {
        const session = generate();
        return redirect(res, getURL(url.href, key, session.id));
    } else {
        const id = query[key];
        const session = pool[id];
        if (session) {
            if (session.cookie.expire > Date.now()) {
                session.cookie.expire = Date.now() + EXPIRES;
                session.data.isVisit = true;
                req.session = session;
                return true;
            } else {
                delete pool[id];
                const session = generate();
                return redirect(res, getURL(url.href, key, session.id));
            }
        } else {
            const session = generate();
            return redirect(res, getURL(url.href, key, session.id));
        }
    }

}

export default {
    handle,
    handleByURL,
    test
}
// ./index.ts

import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";

import parseQueryString from "./8.1.3QueryString";
import parseCookie from "./8.1.4Cookie";
import sessionManager, { Session } from "./8.1.5Session";

export type Request = IncomingMessage & {
    query?: Record<string, string>,
    cookie?: Record<string, string>,
    session?: Session
};
export type Response = ServerResponse;

const server = http.createServer((req: Request, res) => {
    parseQueryString(req, res);
    parseCookie(req, res);

    // sessionManager.handle(req, res);
    // sessionManager.test(req, res);
    if(sessionManager.handleByURL(req, res)) {
        sessionManager.test(req, res);
    }
});

const host = "127.0.0.1";
const port = 9527;
server.listen(port, host, () => {
    console.log(`服务器启动成功: http://${host}:${port}`);
})

执行命令

ts-node .\index.ts

浏览器访问

http://127.0.0.1:9527

浏览器表现

image.png

重定向后在请求头上加了一个 n_session_id 查询参数

Session 性能问题

在上面介绍的两种方式中,都是将 Session 对象直接存储在变量 pool 中,也就是放在内存中,但是随着用户量的增多,我们很可能接触到内存限制的上限,必然会引起垃圾回收的频繁扫描,引起性能问题。
另一个方面是用户请求的连接将可能分配到各个进程中,而 Node 进程与进程之间内存不能共享,所以有可能存在存取的错乱问题,比如存钱的时候存进去招商银行,取钱反而去工商银行。
为了解决上述问题,常采用的方法是将 Session 集中化,存入集中的数据存储中。一般常用的是 Redis,等后续使用到再详细的讨论及编码,这里就不再过多的讨论。

Session 与安全

从前文可以知道,我们把 Session 数据都放到后端了,在一定程度上避免了客户端直接篡改,但是 Session ID 依然存放在客户端,这样会存在 ID 伪造和盗用的情况,一旦被不法分子钻了漏洞,如果涉及到公司财产相关,涉及到的损失将无法估量。
第一种优化方式就是将 ID 签名,如果服务器端收到的篡改的非法签名,直接将数据立即过期或删除即可。
第二种优化方式就是将客户端的某些独有信息与口令签名绑定,即使被盗用了一个真正的签名,但是不在原始的客户端上进行访问,也会导致签名失败。