跳过正文
  1. NestJS 通关秘籍/

基于 Redis 实现分布式 session

·2153 字·5 分钟·
hujiacheng
作者
hujiacheng
Front-end Developer / Strive To Become Better
目录
实战技巧 - 这篇文章属于一个选集。
§ 1: 本文

前面我们学习了登录鉴权的两种方式 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

在 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 {}

然后在 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);
        }
    }
}

因为我们要操作的是对象结构,比较适合使用 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

导出 SessionService,并且设置 SessionModule 为 Global:

import { Global, Module } from '@nestjs/common';
import { SessionService } from './session.service';

@Global()
@Module({
  providers: [SessionService],
  exports: [SessionService]
})
export class SessionModule {}

然后实现 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);
    }
}

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);

}

这里用到 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}`);
}

这样再用的时候,当不传类型参数,返回的是默认类型 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;
}

先根据 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。

案例代码在小册仓库

总结
#

session 是在服务端内存存储会话数据,通过 cookie 中的 session id 关联。

但它不支持分布式,换台机器就不行了。

jwt 是在客户端存储会话数据,所以天然支持分布式。

我们通过 redis 自己实现了分布式的 session。

我们使用的是 hash 的数据结构,封装了 RedisModule 来操作 Redis。

又封装了 SessionModule 来读写 redis 中的 session,以 sid_xxx 为 key。

之后在 ctronller 里就可以读取和设置 session 了,用起来和内置的传统 session 差不多。但是它是支持分布式的。

如果你想在分布式场景下用 session,就自己基于 redis 实现一个吧。

实战技巧 - 这篇文章属于一个选集。
§ 1: 本文

相关文章