背景
本文将介绍 Nodejs 相关技术,让更多前端技术人员突破技术壁垒,向全栈靠拢
尝试坚持学习新技术、写技术文章,学而时习之,不亦说乎!
1、一叶障目,不见泰山,对技术怀有一颗敬畏之心
2、脚踏实地,一步一个脚印,最后肯定有回报
3、水滴石穿、熟能生巧,多敲多 debug
内容回顾
cookie 的性能影响
- 每次会把所有数据都携带到后台,产生不必要的宽带浪费。
- 客户端能够直接更改 cookie 数据,容易被篡改。
初识 session
由于 cookie 会产生相关的性能问题,session 应运而生,session 的数据只保留在服务器端,客户端无法修改,这样数据的安全性得到了一定的保障,客户端只需要携带映射 session 数据的 id 给服务器端即可,无需每次传递不必要的数据。
如何将这个 id 和服务器中的 session 数据一一对应起来呢?这里有常见的两种实现方式。
1、基于 Cookie 来实现映射
虽然把所有数据放到 cookie 中不可取,但是将口令放在 cookie 中还是可以的,因为口令一旦被篡改,也就丢失了映射关系,也就无法修改服务端中映射的 session 数据了。
举个例子,中秋节到了,公司给每一个员工送关怀,于是行政发表通知,所有人凭工牌来员工办公室领取月饼,对于员工,只需要工牌来领取对应的月饼即可。对于人事,工牌有对应的编号,也对应着个人信息表上的详细资料(身份证、性别、年龄、学历。。。),于是每次员工领取一次,人事对应的表上签一个字,这样月饼就不会发错发漏。
如果是 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
浏览器表现(第一次访问)
请求头没有携带 session id 信息到服务器端
响应头携带 Set-Cookie:n_session_id=MTY3NzA1NTY2NjA4OC4yMzc1 给浏览器,浏览器会保存到本地
浏览器表现(第二次访问)
请求头携带 n_session_id 给服务器
补充
n_session_id 可以随意约定,比如 Connect 默认采用的是 connect_uid,Tomcat 默认采用 jsessionId 等,一旦服务器检查到用户请求 Cookie 中没有携带该值,它就会马上生成一个,并且可以设定超时时间。
2、基于 URL 查询字符串的方式来实现映射
第一种方式依赖 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
浏览器表现
重定向后在请求头上加了一个 n_session_id 查询参数
Session 性能问题
在上面介绍的两种方式中,都是将 Session 对象直接存储在变量 pool 中,也就是放在内存中,但是随着用户量的增多,我们很可能接触到内存限制的上限,必然会引起垃圾回收的频繁扫描,引起性能问题。
另一个方面是用户请求的连接将可能分配到各个进程中,而 Node 进程与进程之间内存不能共享,所以有可能存在存取的错乱问题,比如存钱的时候存进去招商银行,取钱反而去工商银行。
为了解决上述问题,常采用的方法是将 Session 集中化,存入集中的数据存储中。一般常用的是 Redis,等后续使用到再详细的讨论及编码,这里就不再过多的讨论。
Session 与安全
从前文可以知道,我们把 Session 数据都放到后端了,在一定程度上避免了客户端直接篡改,但是 Session ID 依然存放在客户端,这样会存在 ID 伪造和盗用的情况,一旦被不法分子钻了漏洞,如果涉及到公司财产相关,涉及到的损失将无法估量。
第一种优化方式就是将 ID 签名,如果服务器端收到的篡改的非法签名,直接将数据立即过期或删除即可。
第二种优化方式就是将客户端的某些独有信息与口令签名绑定,即使被盗用了一个真正的签名,但是不在原始的客户端上进行访问,也会导致签名失败。