利用 Redis 解决 NodeJS 中 Session 存储问题

August 22, 2019

前言

本文就如何在服务端存储 Session 的做一次实践。

Session 的存储方案

存储在 Cookie 中

在 Cookie 中存储 Session 是我们经常使用的方法,虽然简便,却存在两个致命的缺点:

  1. session 同步问题,假设我们现有两个站点,分别为 a.hello.com b.hello.com,如果要实现多站点共享 Cookie,基本就是把 Cookie 的 Domain 属性设置成 .hello.com,这样的话,我们在 a.hello.com 完成了登录,进入 b.hello.com 会进行自动登录。此时,我们在 a.hello.com 修改了 session(把用户权限从 0 设置为 1),同时会修改浏览器的 Cookie。再进入 b.hello.comb.hello.com 服务器的 session 是老的(用户权限为 0),由于新的 session 会根据新 Cookie 解析出用户权限为 1,这和原本的 session 内容冲突,出于安全考虑,服务器会放弃新的 session,继续采用老的 session,造成了 session 同步失败,引发问题。

  2. Cookie 的大小是有限制的,一般为 4KB,任何 Cookie 大小超过限制都被忽略,且永远不会被设置,万一我们的状态信息超过 4KB,就会丢失。

存储在 MongoDB 中

MongoDB 是一个基于文档的数据库,所有数据是从磁盘上进行读写的。其 document 相当于 MySQL 中的 record,collection 相当于相当于 MySQL 中 table,不用写 SQL 语句,利用 JSON 进行数据存储。

存储在 Redis 中

Redis 是一个基于内存的键值数据库,它由 C 语言实现的,与 Nginx / NodeJS 工作原理近似,同样以单线程异步的方式工作,先读写内存再异步同步到磁盘,读写速度上比 MongoDB 有巨大的提升。因此目前很多超高并发的网站/应用都使用 Redis 做缓存层,普遍认为其性能明显好于 MemoryCache。当并发达到一定程度时,即可考虑使用 Redis 来缓存数据和持久化 Session.

无论是采用 MongoDB 还是 Redis 都能解决 Cookie 存储的缺点,因为采用数据库存储后,Cookie 只负责存储 Key,所有的状态信息保存在同个数据库中,根据 Key 去查找数据库中的数据,再进行相应的读取、修改、删除。

显而易见,对于 Session 这种读写场景频繁,CRUD 操作频繁的持久化内容,使用 Redis 进行存储简直是小而美。

安装 Redis

下载地址:https://github.com/microsoftarchive/redis/releases/tag/win-3.2.100

如果是 64 位,那么直接下载红框标注的即可,下载完后,新建一个文件夹叫 redis,把下载来的 zip 包解压至 redis 文件夹,然后将文件夹移至 C 盘根目录下。

我们打开 cmd,依次输入以下命令,可以看到如下的界面:

bash
$ cd c:\redis
$ redis-server.exe redis.windows.conf
bash
$ cd c:\redis
$ redis-server.exe redis.windows.conf

这个时候再开一个 cmd,原先的不要关闭,输入以下命令,我们完成了最基本的基于 Key-Value 形式数据的存储。

bash
$ cd c:\redis
$ redis-cli.exe -h 127.0.0.1 -p 6379
$ set today 8.22
$ get today
bash
$ cd c:\redis
$ redis-cli.exe -h 127.0.0.1 -p 6379
$ set today 8.22
$ get today

Redis 还有很多实用的数据类型,像列表、哈希、集合,大家有兴趣自行 Google.

启动 Node 应用

现在开始搭建我们的 Node 应用,我们采用当下比较热门的框架 Koa2,我们新建一个文件夹叫 node-redis-session,并安装所需要的 npm 包。

bash
$ cd node-redis-session
$ npm init -y
$ npm i koa koa-session ioredis
bash
$ cd node-redis-session
$ npm init -y
$ npm i koa koa-session ioredis

新建 app.jsindex.html.

我们在 index.html 中写入以下代码,目的是为了模拟用户登录。

html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>登录</title>
</head>
<body>
<div>
<label for="usr">username:</label>
<input type="text" name="usr" id="usr" />
</div>
<div>
<label for="psd">password:</label>
<input type="password" name="psd" id="psd" />
</div>
<button type="button" id="login">login</button>
<h1 id="data"></h1>
<script>
const login = document.getElementById("login");
login.addEventListener("click", function(e) {
const usr = document.getElementById("usr").value;
const psd = document.getElementById("psd").value;
if (!usr || !psd) {
return;
}
//采用 fetch 发起请求
const req = fetch("http://localhost:3000/login", {
method: "post",
body: `usr=${usr}&psd=${psd}`,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
req
.then(stream => stream.text())
.then(res => {
document.getElementById("data").innerText = res;
});
});
</script>
</body>
</html>
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>登录</title>
</head>
<body>
<div>
<label for="usr">username:</label>
<input type="text" name="usr" id="usr" />
</div>
<div>
<label for="psd">password:</label>
<input type="password" name="psd" id="psd" />
</div>
<button type="button" id="login">login</button>
<h1 id="data"></h1>
<script>
const login = document.getElementById("login");
login.addEventListener("click", function(e) {
const usr = document.getElementById("usr").value;
const psd = document.getElementById("psd").value;
if (!usr || !psd) {
return;
}
//采用 fetch 发起请求
const req = fetch("http://localhost:3000/login", {
method: "post",
body: `usr=${usr}&psd=${psd}`,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
req
.then(stream => stream.text())
.then(res => {
document.getElementById("data").innerText = res;
});
});
</script>
</body>
</html>

回到 app.js,我们需要启动一个最基本的服务器,监听 3000 端口,返回 index.html 静态文件,并做登录业务的相关逻辑处理。

js
const Koa = require("koa");
const fs = require("fs");
const app = new Koa();
app.use(async ctx => {
if (ctx.request.url === "/") {
// 返回 index.html 内容
ctx.set({ "Content-Type": "text/html" });
ctx.body = fs.readFileSync("./index.html");
return;
}
if (ctx.url === "/login" && ctx.method === "POST") {
// 登录逻辑处理……
}
});
app.listen(3000, () => {
console.log(`server is running at localhost:3000`);
});
js
const Koa = require("koa");
const fs = require("fs");
const app = new Koa();
app.use(async ctx => {
if (ctx.request.url === "/") {
// 返回 index.html 内容
ctx.set({ "Content-Type": "text/html" });
ctx.body = fs.readFileSync("./index.html");
return;
}
if (ctx.url === "/login" && ctx.method === "POST") {
// 登录逻辑处理……
}
});
app.listen(3000, () => {
console.log(`server is running at localhost:3000`);
});

访问 http://localhost:3000/,可以看到一个基本的登录内容。

处理 POST 请求的数据

接下来,我们需要对客户端发出的 fetch 请求做解析,特别是 POST 请求中携带的 body 实体内容,在 index.html 我们已经将用户名和密码以 queryString 的形式发送,即 usr=jack&psd=123 的形式。

我们在 app.js 中编写 parsePostData 函数,并继续完善我们的登录逻辑处理。

js
++ const qs = require('querystring');
++ function parsePostData(ctx) {
// 返回一个 Promise
return new Promise((resolve, reject) => {
let data = '';
// ctx.req 为 NodeJS 原生请求对象<IncomingMessage>,我们可以利用 on('data'),获取数据
ctx.req.on('data', chunk => {
data += chunk;
});
// on('end'),数据获取完成
ctx.req.on('end', () => {
data = data.toString();
resolve(data);
});
});
}
app.use(async ctx => {
...
if (ctx.url === '/login' && ctx.method === 'POST') {
// 登录逻辑处理……
let postData = await parsePostData(ctx);
// 用 qs 模块,将 queryString 转为对象
postData = qs.parse(postData);
console.log(postData)
}
});
js
++ const qs = require('querystring');
++ function parsePostData(ctx) {
// 返回一个 Promise
return new Promise((resolve, reject) => {
let data = '';
// ctx.req 为 NodeJS 原生请求对象<IncomingMessage>,我们可以利用 on('data'),获取数据
ctx.req.on('data', chunk => {
data += chunk;
});
// on('end'),数据获取完成
ctx.req.on('end', () => {
data = data.toString();
resolve(data);
});
});
}
app.use(async ctx => {
...
if (ctx.url === '/login' && ctx.method === 'POST') {
// 登录逻辑处理……
let postData = await parsePostData(ctx);
// 用 qs 模块,将 queryString 转为对象
postData = qs.parse(postData);
console.log(postData)
}
});

填写完用户名和密码,点击登录,查看 Node 应用的打印记录,哈哈!我们已经成功拿到了 body 的数据。

引入 Session 处理

我们先安装 Koa2 处理 session 的 npm 包。

bash
$ npm i koa-session
bash
$ npm i koa-session

它是一个简单易用的 session 相关的 Koa 中间件,默认基于 Cookie 实现并且支持外部存储。

我们在 app.js 中引入并做相关配置。

js
++ const session = require('koa-session');
++ const sessionConfig = {
// cookie 键名
key: 'koa:sess',
// 过期时间为一天
maxAge: 86400000,
// 不做签名
signed: false,
};
++ app.use(session(sessionConfig, app));
app.use(async ctx => {
...
if (ctx.url === '/login' && ctx.method === 'POST') {
// 登录逻辑处理……
let postData = await parsePostData(ctx);
postData = qs.parse(postData);
if (ctx.session.usr) {
ctx.body = `hello, ${ctx.session.usr}`;
} else {
ctx.session = postData;
ctx.body = 'you are first login';
}
}
});
js
++ const session = require('koa-session');
++ const sessionConfig = {
// cookie 键名
key: 'koa:sess',
// 过期时间为一天
maxAge: 86400000,
// 不做签名
signed: false,
};
++ app.use(session(sessionConfig, app));
app.use(async ctx => {
...
if (ctx.url === '/login' && ctx.method === 'POST') {
// 登录逻辑处理……
let postData = await parsePostData(ctx);
postData = qs.parse(postData);
if (ctx.session.usr) {
ctx.body = `hello, ${ctx.session.usr}`;
} else {
ctx.session = postData;
ctx.body = 'you are first login';
}
}
});

填写完相关信息,点击登录,可以看到首次登录,显示你为第一次登录,并且在浏览器设置了 Cookie.

这里 Cookie 的值之所以为一段类似乱码的字符串,是因为 koa-session 默认对我们的数据做了 encode 处理,防止被明文读取。

再次点击登录,可以看到,服务端已经拥有了我们的 session,记住了我们的会话状态。

创建 RedisStore 类

当当当当!这时候,就需要我们的 Redis 闪亮登场,上文中提到 koa-session 提供了外部存储接口,我们只需提供一个 Store 类 就实现将 session 存储到外部数据库,完美契合。

首先需要安装一个叫 ioredis 的 npm 包,它封装了 redis 的原生操作,提供了很多丰富的 API,就像 Mongoose 之于 MongoDB.

bash
$ npm i ioredis
bash
$ npm i ioredis

我们在根目录下新建 redis-store.js,编写 RedisStore 类。

js
const Redis = require("ioredis");
// 编写的 Store 类,必须要有 get() set() destory() 这三个方法
class RedisStore {
constructor(redisConfig) {
this.redis = new Redis(redisConfig);
}
async get(key) {
const data = await this.redis.get(`SESSION:${key}`);
return JSON.parse(data);
}
async set(key, sess, maxAge) {
await this.redis.set(
`SESSION:${key}`,
JSON.stringify(sess),
"EX",
maxAge / 1000
);
}
async destroy(key) {
return await this.redis.del(`SESSION:${key}`);
}
}
module.exports = RedisStore;
js
const Redis = require("ioredis");
// 编写的 Store 类,必须要有 get() set() destory() 这三个方法
class RedisStore {
constructor(redisConfig) {
this.redis = new Redis(redisConfig);
}
async get(key) {
const data = await this.redis.get(`SESSION:${key}`);
return JSON.parse(data);
}
async set(key, sess, maxAge) {
await this.redis.set(
`SESSION:${key}`,
JSON.stringify(sess),
"EX",
maxAge / 1000
);
}
async destroy(key) {
return await this.redis.del(`SESSION:${key}`);
}
}
module.exports = RedisStore;

现在我们的 Cookie 不再存储会话信息,只存储一个 Key,用来映射 redis 中的 Key,Value 都被存储到 Redis 中,安全行和可拓展性都大大得到了提高。

我们再安装一个 npm 包,叫 shortid,它可以产生一个较短的 id,来方便作为我们的映射 Key.

同时,我们需要在 app.js 做出相应的修改。

js
// app.js
++ const Store = require('./redis-store')
++ const shortid = require('shortid');
++ const redisConfig = {
redis: {
port: 6379,
host: 'localhost',
family: 4,
password: '123456',
db: 0,
},
};
const sessionConfig = {
key: 'koa:sess',
maxAge: 86400000,
signed: false,
// 提供外部 Store
store: new Store(redisConfig),
// key 的生成函数
genid: () => shortid.generate(),
};
js
// app.js
++ const Store = require('./redis-store')
++ const shortid = require('shortid');
++ const redisConfig = {
redis: {
port: 6379,
host: 'localhost',
family: 4,
password: '123456',
db: 0,
},
};
const sessionConfig = {
key: 'koa:sess',
maxAge: 86400000,
signed: false,
// 提供外部 Store
store: new Store(redisConfig),
// key 的生成函数
genid: () => shortid.generate(),
};

让我们清空 Cookie,再次模拟登录。

再次点击登录,成功实现了外部存储。

打开之前的 cmd,输入以下命令,Key 根据你电脑情况而定,每个人不一样。

bash
$ keys *
$ get SESSION:hX_tSS8Od
bash
$ keys *
$ get SESSION:hX_tSS8Od

查询到了之前存储的用户信息!

思考

写在最后,我们成功利用 Redis 解决 NodeJS 中 Session 存储问题,同时做到了安全,健壮,快速的三个要性。

其实 Redis 还可以实现很多功能,作为一个缓存中间层,在某些场景下,可以大大优化我们的应用的性能,比如在数据更新不频繁,但用户读取频繁的场景下,可以将数据保存在 Redis 中,通过 Node 返回。


B2D1 (包邦东)

Written by B2D1 (包邦东)