本文由图雀社区认证作者 布拉德特皮 写作而成,点击阅读原文查看作者掘金链接,感谢作者的优质输出,让我们的技术世界变得更加美好????
上一篇介绍了如何配合 Swagger UI 解决写文档这个痛点,这篇将介绍如何利用 Redis 解决 JWT 登录认证的另一个痛点:同账号的登录挤出问题。(再不更新,读者就要寄刀片了 -_-||)
GitHub 项目地址,欢迎各位大佬 Star。
为了照顾还没学到第八课读者,本篇教程单独开了一个分支
use-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安装,这里贴上 Windows 和 MacOS 环境的安装教程,不再赘述:
mac os 安装 redis - 简书
在 windows 上安装 Redis - 官方
有意思的是,官方的教程中提到了:
Redis 官方不建议在 windows 下使用 Redis,所以官网没有 windows 版本可以下载。还好微软团队维护了开源的 window 版本,虽然只有 3.2 版本,对于普通测试使用足够了。
笔者使用 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
复制代码
在 Windows 下,可以使用 Redis Desktop Manager
官网的需要付费,不过测试同事用的 0.8.8.384
版本,读者可自行选择:
由于使用的 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。
左上角点击【新建连接】,输入配置信息即可:
然后就可以看到总览了:
好了,终于可以步入文章正题了。
首先,编写 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;
复制代码
将这里需要配合 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 = 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
,从代码复用性上来说,这样设计很糟糕,所以在这里做了个整合。
函数里面的打印,是为了方便以后日志复盘,定位调用位置。
在用户登录成功时,将用户信息和 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 {
// 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 命令参考
这里本来想新建一个 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 {
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;
}
}
复制代码
我们试着登录一下:
先看看日志,Redis 有没有被调用:
再看看 Redis 客户端里的记录:
发现已经将 token 存入了,并且到截图时,已经过去了 42 秒。
然后我们将 token 复制到请求商品列表的接口,请求:
上图是正常请求的样子,然后我们再登录,不修改这个接口的 token:
附上相关日志:
上图可以看到,策略已经生效了。
再看看 Redis 中记录到期会不会消失的情况,可以点击 TTL 旁边的绿色刷新键,查看剩余时间:
TTL 为负数就代表该键已到期,记录不存在了,我们可以点击左边的放大镜刷新一下:
可以看到,该条记录已经消失了,不再占用任何空间。
至此,大功告成。
本篇介绍了如何在 Nest 中使用 Redis,并实现登录挤出的功能,稍稍弥补了 JWT 策略的缺陷。这里只是抛出一个“挤出”的思路,不局限于做在守卫上,如果有更好的思路,欢迎下方留言讨论。
利用 Redis 可以做很多事情,比如处理高并发,记录一些用户状态等。我曾经就用[队列]来处理红包雨活动,压测记录是 300+ 次请求/每秒。
还可以用来处理“登录超时”需求,比如把 JWT 的时效设置十天半个月的,然后就赋予 Redis 仅仅 1-2 个小时的时效,但是每次请求,都会重置过期时间,最后再判断这个键是否存在,来确认登录是否超时,具体实现就不在这里展开了,有兴趣的读者可自行完成。
参考资料:
《Redis 由浅入深深深深深剖析》:https://juejin.im/post/5d809a89e51d456206115ab3
《Redis 这篇就够了》:https://www.toutiao.com/i6713520017595433485
1.看到这里了就点个在看支持下吧,你的「在看」是我创作的动力。
2.关注公众号图雀社区
,「带你一起学优质实战技术教程」!
3.特殊阶段,带好口罩,做好个人防护。
4.添加微信【little-tuture】,拉你进技术交流群一起学习。
● Nest.js 从零到壹系列(三):使用 JWT 实现单点登录● Nest.js 从零到壹系列(六):用 15 行代码实现 RBAC 0● Nest.js 从零到壹系列(七):讨厌写文档,Swagger UI 了解一下?
·END·
图雀社区
汇聚精彩的免费实战教程
关注公众号回复 z 拉学习交流群喜欢本文,点个“在看”告诉我