Nest.js 从零到壹系列(八):使用 Redis 实现登录挤出功能

8,795

前言

上一篇介绍了如何配合 Swagger UI 解决写文档这个痛点,这篇将介绍如何利用 Redis 解决 JWT 登录认证的另一个痛点:同账号的登录挤出问题。(再不更新,读者就要寄刀片了 -_-||)

GitHub 项目地址,欢迎各位大佬 Star。

为了照顾还没学到第八课读者,本篇教程单独开了一个分支 use-redis,拉项目后记得切换

前期准备

什么是 Redis

Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。

Redis 的效率很高,官方给出的数据是 100000+ QPS,这是因为:

  • Redis 完全基于内存,绝大部分请求是纯粹的内存操作,执行效率高。
  • Redis 使用单进程单线程模型的(K,V)数据库,将数据存储在内存中,存取均不会受到硬盘 IO 的限制,因此其执行速度极快。 另外单线程也能处理高并发请求,还可以避免频繁上下文切换和锁的竞争,如果想要多核运行也可以启动多个实例。
  • 数据结构简单,对数据操作也简单,Redis 不使用表,不会强制用户对各个关系进行关联,不会有复杂的关系限制,其存储结构就是键值对,类似于 HashMap,HashMap 最大的优点就是存取的时间复杂度为 O(1)。
  • Redis 使用多路 I/O 复用模型,为非阻塞 IO。

注:Redis 采用的 I/O 多路复用函数:epoll/kqueue/evport/select。

安装 Redis

要使用 Redis,那首先得安装 Redis,由于本篇的重点不在 Redis安装,这里贴上 Windows 和 MacOS 环境的安装教程,不再赘述:

mac os 安装 redis - 简书

在 windows 上安装 Redis - 官方

有意思的是,官方的教程中提到了:

Redis 官方不建议在 windows 下使用 Redis,所以官网没有 windows 版本可以下载。还好微软团队维护了开源的 window 版本,虽然只有 3.2 版本,对于普通测试使用足够了。

Redis 可视化客户端

1. Mac OS

笔者使用 MacOS 系统,故使用 AnotherRedisDesktopManager 作为 Redis 可视化客户端:

# clone code
git clone https://github.com/qishibo/AnotherRedisDesktopManager.git
cd AnotherRedisDesktopManager

# install dependencies
npm install

# if download electron failed during installing, use this command
# ELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/" npm install

# serve with hot reload at localhost:9988
npm start

# after the previous step is completed, open another tab, build up a desktop client
npm run electron

2. Windows

在 Windows 下,可以使用 Redis Desktop Manager

官网的需要付费,不过测试同事用的 0.8.8.384 版本,读者可自行选择:

启动 Redis 并连接客户端

由于使用的 MacOS 系统,这里直接拿 AnotherRedisDesktopManager 做演示了,Windows 也是大同小异的。

我们先将 Redis 服务开起来,进入 /usr/local/bin/(具体根据你的安装路径来定),输入下列命令:

$ redis-server

出现下图表示服务启动成功:

然后新开一个终端,进入同样的目录,启动 Redis 客户端:

$ redis-cli

使用客户端连接可能需要输入密码,我们先将它设好,这里涉及到 2 个指令

查看密码:

$ config get requirepass

设置密码:

$ config set requirepass [new passward]

下面是我的指令记录,因为设置了密码 root,所以退出重进后需要 -a [密码],还有一点是,这种方式设置的密码,重启电脑后,原先设置会消失,需要重新设置

接下来启动 AnotherRedisDesktopManager,启动方法在上文提到了,需要新开一个终端标签页启动 electron。

左上角点击【新建连接】,输入配置信息即可:

然后就可以看到总览了:

好了,终于可以步入文章正题了。

Nest 操作 Redis

1. Redis 连接配置

首先,编写 Redis 配置文件,这里就直接整合到 config/db.ts 中了:

// config/db.ts
const productConfig = {
  mysql: {
    port: '数据库端口',
    host: '数据库地址',
    user: '用户名',
    password: '密码',
    database: 'nest_zero_to_one', // 库名
    connectionLimit: 10, // 连接限制
  },
+  redis: {
+    port: '线上 Redis 端口',
+    host: '线上 Redis 域名',
+    db: '库名',
+    password: 'Redis 访问密码',
+  }
};

const localConfig = {
  mysql: {
    port: '数据库端口',
    host: '数据库地址',
    user: '用户名',
    password: '密码',
    database: 'nest_zero_to_one', // 库名
    connectionLimit: 10, // 连接限制
  },
+  redis: {
+    port: 6379,
+    host: '127.0.0.1',
+    db: 0,
+    password: 'root',
+  }
};

// 本地运行是没有 process.env.NODE_ENV 的,借此来区分[开发环境]和[生产环境]
const config = process.env.NODE_ENV ? productConfig : localConfig;

export default config;

2. 建造 Redis 工厂

将这里需要配合 ioredis 使用:

$ yarn add ioredis -S

添加成功后,我们需要编写一个生成 Redis 实例列表的文件:

// src/database/redis.ts
import * as Redis from 'ioredis';
import { Logger } from '../utils/log4js';
import config from '../../config/db';

let n: number = 0;
const redisIndex = []; // 用于记录 redis 实例索引
const redisList = []; // 用于存储 redis 实例

export class RedisInstance {
  static async initRedis(method: string, db: number = 0) {
    const isExist = redisIndex.some(x => x === db);
    if (!isExist) {
      Logger.debug(`[Redis ${db}]来自 ${method} 方法调用, Redis 实例化了 ${++n} 次 `);
      redisList[db] = new Redis({ ...config.redis, db });
      redisIndex.push(db);
    } else {
      Logger.debug(`[Redis ${db}]来自 ${method} 方法调用`);
    }
    return redisList[db];
  }
}

因为 redis 可以同时存在多个库(公司的有 255 个,刚刚本地新建的有 15 个),故需要传入 db 进行区分,当然,也可以写死,但之后每使用一个库,就要新写一个 class,从代码复用性上来说,这样设计很糟糕,所以在这里做了个整合。

函数里面的打印,是为了方便以后日志复盘,定位调用位置。

3. 调整 token 签发流程

在用户登录成功时,将用户信息和 token 存入 redis,并设置失效时间(单位:秒),正常情况应与 JWT 时效保持一致,这里为了调试方便,只写了 300 秒:

// src/logical/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { encryptPassword } from '../../utils/cryptogram';
+ import { RedisInstance } from '../../database/redis';

@Injectable()
export class AuthService {
  constructor(private readonly usersService: UserService, private readonly jwtService: JwtService) {}

  // JWT验证 - Step 2: 校验用户信息
  async validateUser(username: string, password: string): Promise<any> {
    // console.log('JWT验证 - Step 2: 校验用户信息');
    const user = await this.usersService.findOne(username);
    if (user) {
      const hashedPassword = user.password;
      const salt = user.salt;
      const hashPassword = encryptPassword(password, salt);
      if (hashedPassword === hashPassword) {
        // 密码正确
        return {
          code: 1,
          user,
        };
      } else {
        // 密码错误
        return {
          code: 2,
          user: null,
        };
      }
    }
    // 查无此人
    return {
      code: 3,
      user: null,
    };
  }

  // JWT验证 - Step 3: 处理 jwt 签证
  async certificate(user: any) {
    const payload = {
      username: user.username,
-      sub: user.userId, // 之前笔误,写错了
+      sub: user.id,
      realName: user.realName,
      role: user.role,
    };
    // console.log('JWT验证 - Step 3: 处理 jwt 签证', `payload: ${JSON.stringify(payload)}`);
    try {
      const token = this.jwtService.sign(payload);
+      // 实例化 redis
+      const redis = await RedisInstance.initRedis('auth.certificate', 0);
+      // 将用户信息和 token 存入 redis,并设置失效时间,语法:[key, seconds, value]
+      await redis.setex(`${user.id}-${user.username}`, 300, `${token}`);
      return {
        code: 200,
        data: {
          token,
        },
        msg: `登录成功`,
      };
    } catch (error) {
      return {
        code: 600,
        msg: `账号或密码错误`,
      };
    }
  }
}

关于 Redis 的使用,文末附上了一些科普教程,如果学习过程中需要查指令,可以去这里查询: Redis 命令参考

4. 调整守卫策略

这里本来想新建一个 token.guard.ts 的,但后面感觉每个路由又全加一遍,很麻烦,故直接调整 rbac.guard.ts

// src/guards/rbac.guard.ts
- import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
+ import { CanActivate, ExecutionContext, Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
+ import { RedisInstance } from '../database/redis';

@Injectable()
export class RbacGuard implements CanActivate {
  // role[用户角色]: 0-超级管理员 | 1-管理员 | 2-开发&测试&运营 | 3-普通用户(只能查看)
  constructor(private readonly role: number) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const user = request.user;

+    // 获取请求头里的 token
+    const authorization = request['headers'].authorization || void 0;
+    const token = authorization.split(' ')[1]; // authorization: Bearer xxx

+    // 获取 redis 里缓存的 token
+    const redis = await RedisInstance.initRedis('TokenGuard.canActivate', 0);
+    const key = `${user.userId}-${user.username}`;
+    const cache = await redis.get(key);

+    if (token !== cache) {
+      // 如果 token 不匹配,禁止访问
+      throw new UnauthorizedException('您的账号在其他地方登录,请重新登录');
+    }

    if (user.role > this.role) {
      // 如果权限不匹配,禁止访问
      throw new ForbiddenException('对不起,您无权操作');
    }
    return true;
  }
}

5. 验证

我们试着登录一下:

先看看日志,Redis 有没有被调用:

再看看 Redis 客户端里的记录:

发现已经将 token 存入了,并且到截图时,已经过去了 42 秒。

然后我们将 token 复制到请求商品列表的接口,请求:

上图是正常请求的样子,然后我们再登录,不修改这个接口的 token:

附上相关日志:

上图可以看到,策略已经生效了。

再看看 Redis 中记录到期会不会消失的情况,可以点击 TTL 旁边的绿色刷新键,查看剩余时间:

TTL 为 -2 就代表该键已到期,记录不存在了,我们可以点击左边的放大镜刷新一下:

注:TTL 为 -1 代表未设置过期时间(即一直存在);为 -2 表示该键不存在(即已失效)

可以看到,该条记录已经消失了,不再占用任何空间。

至此,大功告成。

总结

本篇介绍了如何在 Nest 中使用 Redis,并实现登录挤出的功能,稍稍弥补了 JWT 策略的缺陷。这里只是抛出一个“挤出”的思路,不局限于做在守卫上,如果有更好的思路,欢迎下方留言讨论。

利用 Redis 可以做很多事情,比如处理高并发,记录一些用户状态等。我曾经就用[队列]来处理红包雨活动,压测记录是 300+ 次请求/每秒。

还可以用来处理“登录超时”需求,比如把 JWT 的时效设置十天半个月的,然后就赋予 Redis 仅仅 1-2 个小时的时效,但是每次请求,都会重置过期时间,最后再判断这个键是否存在,来确认登录是否超时,具体实现就不在这里展开了,有兴趣的读者可自行完成。

本篇收录于NestJS 实战教程,更多文章敬请关注。

参考资料:

《Redis 由浅入深深深深深剖析》

《学 Redis 这篇就够了》

本文使用 mdnice 排版