# 前言
前面我们学习了登录鉴权的两种方式 session 和 jwt。
session 是在服务端保存用户数据,然后通过 cookie 返回 sessionId。cookie 在每次请求的时候会自动带上,服务端就能根据 sessionId 找到对应的 session,拿到用户的数据

而 jwt 是把所有的用户数据保存在加密后的 token 里返回,客户端只要在 authorization 的 header 里带上 token,服务端就能从中解析出用户数据。

jwt 天然是支持分布式的,比如有两个服务器的时候,任何一个服务器都能从 token 出拿到用户数据:

但是 session 的方式不行,它的数据是存在单台服务器的内存的,如果再请求另一台服务器就找不到对应的 session 了:

# 那如何让 session 支持分布式环境呢?
一种方式就是做 session 的同步,在多台服务器之间复制 session。
另一种方式就是自己基于 redis 实现一个分布式 session 了。
这节我们就来实现一下。
首先我们来分析下思路:
分布式 session 就是在多台服务器都可以访问到同一个 session。
我们可以在 redis 里存储它:

用户第一次请求的时候,生成一个随机 id,以它作为 key,存储的对象作为 value 放到 redis 里。
之后携带 cookie 的时候,根据其中的 sid 来取 redis 中的值,注入 handler。
修改 session 之后再设置到 redis 里。
这样就完成了 session 的创建、保存、修改。
我们具体实现下:
nest new redis-session-test -p npm

创建 nest 项目。
安装 redis 的包:
npm install --save redis
然后创建个 redis 模块:
nest g module redis
nest g service redis
2

在 RedisModule 创建连接 redis 的 provider,导出 RedisService,并把这个模块标记为 @Global 模块
import { Global, Module } from '@nestjs/common';
import { createClient } from 'redis';
import { RedisService } from './redis.service';
@Global()
@Module({
providers: [
RedisService,
{
provide: 'REDIS_CLIENT',
async useFactory() {
const client = createClient({
socket: {
host: 'localhost',
port: 6379
}
});
await client.connect();
return client;
}
}
],
exports: [RedisService]
})
export class RedisModule {}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
然后在 RedisService 里注入 REDIS_CLIENT,并封装一些方法:
import { Inject, Injectable } from '@nestjs/common';
import { RedisClientType } from 'redis';
@Injectable()
export class RedisService {
@Inject('REDIS_CLIENT')
private redisClient: RedisClientType;
async hashGet(key: string) {
return await this.redisClient.hGetAll(key);
}
async hashSet(key: string, obj: Record<string, any>, ttl?: number) {
for(let name in obj) {
await this.redisClient.hSet(key, name, obj[name]);
}
if(ttl) {
await this.redisClient.expire(key, ttl);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
因为我们要操作的是对象结构,比较适合使用 hash。
redis 的 hash 有这些方法:
HSET key field value: 设置指定哈希表 key 中字段 field 的值为 value。HGET key field:获取指定哈希表 key 中字段 field 的值。HMSET key field1 value1 field2 value2 ...:同时设置多个字段的值到哈希表 key 中。HMGET key field1 field2 ...:同时获取多个字段的值从哈希表 key 中。HGETALL key:获取哈希表 key 中所有字段和值。HDEL key field1 field2 ...:删除哈希表 key 中一个或多个字段。HEXISTS key field:检查哈希表 key 中是否存在字段 field。HKEYS key:获取哈希表 key 中的所有字段。HVALUES key:获取哈希表 key 中所有的值。 -HLEN key:获取哈希表 key 中字段的数量。HINCRBY key field increment:将哈希表 key 中字段 field 的值增加 increment。HSETNX key field value:只在字段 field 不存在时,设置其值为 value。
这里我们就用到 hGetAll 和 hSet 方法,再就是用 expire 设置 key 的过期时间。
这里的 Record<string, any> 是对象类型的意思。
然后再封装个 SessionModule:
nest g module session
nest g service session --no-spec
2

导出 SessionService,并且设置 SessionModule 为 Global:
import { Global, Module } from '@nestjs/common';
import { SessionService } from './session.service';
@Global()
@Module({
providers: [SessionService],
exports: [SessionService]
})
export class SessionModule {}
2
3
4
5
6
7
8
9
10

然后实现 SessionService:
import { Inject, Injectable } from '@nestjs/common';
import { RedisService } from 'src/redis/redis.service';
@Injectable()
export class SessionService {
@Inject(RedisService)
private redisService: RedisService;
async setSession(sid: string, value: Record<string, any>, ttl: number = 30 * 60) {
if(!sid) {
sid = this.generateSid();
}
await this.redisService.hashSet(`sid_${sid}`, value, ttl);
return sid;
}
async getSession(sid: string) {
return await this.redisService.hashGet(`sid_${sid}`);
}
generateSid() {
return Math.random().toString().slice(2,12);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
setSession 就是用 sid_xx 的 key 在 redis 里创建 string 的数据结构。
getSession 是用 sid_xx 从 redis 取值。
generateSid 是生成随机的 session id

setSession 的时候如果没有传入 sid,则随机生成一个,并返回 sid。
我们在 AppController 添加个方法测试下:
@Inject(SessionService)
private sessionService: SessionService;
@Get('count')
async count(@Req() req: Request, @Res() res: Response) {
const sid = req.cookies?.sid;
const session = await this.sessionService.getSession(sid);
}
2
3
4
5
6
7
8
9
10
11
这里用到 cookie,需要安装 cookie-parser 的包:
npm install --save cookie-parser
在 main.ts 里启用:

现在 getSession 返回的是 Record<string, any> 也就是对象类型,但并不知道有啥具体的属性。

所以我们改造下 getSession 的类型声明加个重载:
async getSession<SessionType extends Record<string,any>>(sid: string): Promise<SessionType>;
async getSession(sid: string) {
return await this.redisService.hashGet(`sid_${sid}`);
}
2
3
4
这样再用的时候,当不传类型参数,返回的是默认类型 Record<string, any>:

传入类型参数之后,返回的就是该类型了:

为什么这里是 string 呢?
因为 redis 虽然可以存整数、浮点数,但是它会转为 string 来存,所以取到的是 string,需要自己转换一下。
我们实现下计数逻辑:
@Inject(SessionService)
private sessionService: SessionService;
@Get('count')
async count(@Req() req: Request, @Res({ passthrough: true}) res: Response) {
const sid = req.cookies?.sid;
const session = await this.sessionService.getSession<{count: string}>(sid);
const curCount = session.count ? parseInt(session.count) + 1 : 1;
const curSid = await this.sessionService.setSession(sid, {
count: curCount
});
res.cookie('sid', curSid, { maxAge: 1800000 });
return curCount;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
先根据 cookie 的 sid 调用 getSession 取 session。
拿到的如果有 count,就 + 1 之后放回去,没有就设置 1
然后 setSession 更新 session。
在 cookie 中返回 sid。
默认用了 @Res 传入 response 之后就需要手动返回响应了,比如 res.end('xxx'),如果还是想让 nest 把返回值作为响应,就加个 passthrough: true。
我们测试下:

我们自己实现的 session 就生效了:

在 Redis Insight 里可以看到 session 的值

而且这个 session 是支持分布式的。

我们用 nginx 做网关层,使用轮询的负载均衡策略,那请求可能到任何一台服务器上。
如果是之前的 session,当前机器没有对应的 session 对象,就拿不到登录状态。
而现在基于 redis 存储的 session,不管请求到了哪台服务器,都能从 redis 中取出对应的 session 从而拿到登录状态、用户数据。
这就是分布式 session。
案例代码在小册仓库 (opens new window)。
# 总结
session 是在服务端内存存储会话数据,通过 cookie 中的 session id 关联。
但它不支持分布式,换台机器就不行了。
jwt 是在客户端存储会话数据,所以天然支持分布式。
我们通过 redis 自己实现了分布式的 session。
我们使用的是 hash 的数据结构,封装了 RedisModule 来操作 Redis。
又封装了 SessionModule 来读写 redis 中的 session,以 sid_xxx 为 key。
之后在 ctronller 里就可以读取和设置 session 了,用起来和内置的传统 session 差不多。但是它是支持分布式的。
如果你想在分布式场景下用 session,就自己基于 redis 实现一个吧。