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

August 22, 2019

NodeJS

前言

本文就如何在服务端存储 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,依次输入以下命令,可以看到如下的界面

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

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

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

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

启动 Node 应用

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

cd node-redis-session
npm init -y
npm i koa koa-session ioredis -S

新建 app.jsindex.html

我们在 index.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 静态文件,并做登录业务的相关逻辑处理

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 函数,并继续完善我们的登录逻辑处理

// app.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 包

npm i koa-session -S

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

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

// app.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

npm i ioredis -S

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

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 做出相应的修改

// 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 根据你电脑情况而定,每个人不一样

$ keys *
$ get SESSION:hX_tSS8Od

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

思考

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

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


Written by B2D1(包邦东)