利用 Redis 解决 NodeJS 中 Session 存储问题
August 22, 2019
前言
本文就如何在服务端存储 Session 的做一次实践。
Session 的存储方案
存储在 Cookie 中
在 Cookie 中存储 Session 是我们经常使用的方法,虽然简便,却存在两个致命的缺点:
-
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.com
,b.hello.com
服务器的 session 是老的(用户权限为 0),由于新的 session 会根据新 Cookie 解析出用户权限为 1,这和原本的 session 内容冲突,出于安全考虑,服务器会放弃新的 session,继续采用老的 session,造成了 session 同步失败,引发问题。 -
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.js
和 index.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) {// 返回一个 Promisereturn 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) {// 返回一个 Promisereturn 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,// 提供外部 Storestore: 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,// 提供外部 Storestore: 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 返回。