Nestjs 从零到壹系列(八):使用 Redis 实现登

前言

上一篇介绍了如何配合 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 可视化客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
复制代码# 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/(具体根据你的安装路径来定),输入下列命令:

1
复制代码$ redis-server

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


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

1
复制代码$ redis-cli


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

查看密码:

1
复制代码$ config get requirepass

设置密码:

1
复制代码$ config set requirepass [new passward]

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


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

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


然后就可以看到总览了:


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


Nest 操作 Redis


1. Redis 连接配置

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
复制代码// 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 使用:

1
复制代码$ yarn add ioredis -S

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
复制代码// 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 秒:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
复制代码// 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
复制代码// 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 排版

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%