Skip to content

Nest如何实现带身份验证的GraphQL订阅Subscription

前言

最近在用nest+graphql做一个消息推送的功能,但发现相关资料真的好少,不仅官网文档没有详细介绍,社区中也少有人讨论,如下是笔者的一些配置,供大家参考,希望对你有所帮助。

1. 安装graphql-redis-subscriptions

在Nest官方文档中,我们可以看到这样一句话:

NOTE PubSub is a class that exposes a simple publish and subscribe API. Read more about it here. Note that the Apollo docs warn that the default implementation is not suitable for production (read more here). Production apps should use a PubSub implementation backed by an external store (read more here).

简单来说就是阿波罗自带的pubsub并不推荐用户生产环境,主要原因就是不支持多台机器,目前比较主流的方案就是使用graphql-redis-subscriptions,所以,我们先从这里开始...

1)安装

shell
npm i graphql-redis-subscriptions

2)docker-compose.yml增加redis配置,你也可以根据自己的需求自定义:

yaml
  redis:
    container_name: redis
    image: 'redis:alpine'
    ports:
      - 6379:${REDIS_PORT}

2. 新增PubSub文件

shell
nest g mo pubsub

然后在生成的文件中进行修改,如下是笔者修改该文件的内容:

ts
import { Global, Module } from '@nestjs/common';
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { ConfigService, ConfigModule } from '@nestjs/config';

export const PUB_SUB = 'PUB_SUB';

@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: PUB_SUB,
      inject: [ConfigService],
      useFactory: (configService: ConfigService) =>
        new RedisPubSub({
          connection: {
            host: configService.get('REDIS_HOST'),
            port: configService.get('REDIS_PORT'),
          },
        }),
    },
  ],
  exports: [PUB_SUB],
})
export class PubsubModule {}

3. App.module中配置

ts
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true, load: [config] }),
    PrismaModule.forRoot({
      isGlobal: true,
      prismaServiceOptions: {
        middlewares: [loggingMiddleware(new Logger('PrismaMiddleware'))], // configure your prisma middleware
      },
    }),

    AuthModule,
    GraphQLModule.forRootAsync<ApolloDriverConfig>({
      imports: [AuthModule],
      driver: ApolloDriver,
      useClass: GqlConfigService, // 下面有
      inject: [AuthService],
    }),
    ]
    // 略...
})

其中AuthModule是做身份验证的模块,因为笔者使用的是JWT方案,后续会使用该AuthService进行用户信息的解析。

4. 新增GqlConfigService配置

我是在app.module同级目录新增了gql-config.service.ts这个文件,注意其中的subscriptions: {...}context配置就可以了,其他的都是一些常见的配置,自己根据官网自定义,如下是文件内容:

ts
import { GraphqlConfig } from './common/configs/config.interface';
import { ConfigService } from '@nestjs/config';
import { ApolloDriverConfig } from '@nestjs/apollo';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { GqlOptionsFactory } from '@nestjs/graphql';
import { ApolloServerPluginLandingPageLocalDefault } from 'apollo-server-core';
import { AuthService } from 'src/auth/auth.service';

@Injectable()
export class GqlConfigService implements GqlOptionsFactory {
  constructor(
    private configService: ConfigService,
    private authService: AuthService
  ) {}
  createGqlOptions(): ApolloDriverConfig {
    const graphqlConfig = this.configService.get<GraphqlConfig>('graphql');
    return {
      // schema options
      autoSchemaFile: graphqlConfig.schemaDestination || './src/schema.graphql',
      sortSchema: graphqlConfig.sortSchema,
      buildSchemaOptions: {
        numberScalarMode: 'integer',
      },
      // subscription
      subscriptions: {
        'graphql-ws': {
          // websocket身份校验,并附带user
          onConnect: async (context: any) => {
            const { connectionParams, extra } = context;
            // user validation will remain the same as in the example above
            // when using with graphql-ws, additional context value should be stored in the extra field
            const authorization = connectionParams?.Authorization;
            const token = authorization?.split(' ')?.pop();
            try {
              const user = await this.authService.getUserFromToken(token);
              extra.user = user;
              extra.headers = { authorization };
            } catch (err) {
              throw new UnauthorizedException();
            }
            return context;
          },
        },
      },
      debug: graphqlConfig.debug,
      playground: graphqlConfig.playgroundEnabled,
      plugins: [ApolloServerPluginLandingPageLocalDefault()],
      context: ({ req, extra }) => ({
        req,
        extra,
      }),
    };
  }
}

在这里,我将token提取并解析成一个完整的用户对象,并挂载到了extra对象上方便后续使用。

5. 修改身份校验装饰器

笔者这里使用的JWT身份校验是@nestjs/passport,然后自定义了一个装饰器用户接口的身份校验,带上该装饰器就必须是登录的用户,否则则是所有用户都可以请求该接口:

ts
// src/auth/gql-auth.guard.ts
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GqlExecutionContext } from '@nestjs/graphql';

@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
  getRequest(context: ExecutionContext) {
    const ctx = GqlExecutionContext.create(context).getContext();
    return ctx.req || ctx.extra; // ws的也装载headers供JWT校验
  }
}

6.修改用户实体装饰器

这个装饰器的作用是获取用户实体信息,装饰在参数上,这两个装饰器后续会简单介绍如何使用:

ts
// src/common/user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';

export const UserEntity = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const context = GqlExecutionContext.create(ctx).getContext();
    return context?.req?.user || context?.extra?.user;
  }
);

可以使用了(演示)

这里,我在其中一个resolve.ts文件中进行简单演示:

ts
// 略..
import { RedisPubSub } from 'graphql-redis-subscriptions';
import { PUB_SUB } from 'src/pubsub/pubsub.module';

enum SUBSCRIPTION_EVENTS {
  haveWritten = 'haveWritten',
}


@Resolver(() => User2questionnaire)
export class User2questionnaireResolver {
  constructor(
    private prisma: PrismaService,
    private readonly user2questionnaireService: User2questionnaireService,
    @Inject(PUB_SUB) private readonly pubSub: RedisPubSub
  ) {}

  @UseGuards(GqlAuthGuard) // 携带UseGuards这个装饰器就必须包含登录态才能请求这个接口
  @Subscription(() => User2questionnaire)
  haveWritten(@UserEntity() user: User) {  // 不用传入user,就可以直接使用user相关参数
    return this.pubSub.asyncIterator(
      `${SUBSCRIPTION_EVENTS.haveWritten}.${user.id}` // 每个用户各包含动态事件
    );
  }
// 略...
  @UseGuards(GqlAuthGuard)
  @Mutation(() => User2questionnaire)
  async write(@UserEntity() user: User, @Args('data') data: CreateU2QInput) {
    const newU2Q = await this.user2questionnaireService.create(user, data);
    // pub
    const { ownerId, friendId } = data;
    // 自己填写就不进行通知
    if (ownerId !== friendId) {
      this.pubSub.publish(`${SUBSCRIPTION_EVENTS.haveWritten}.${ownerId}`, {
        haveWritten: newU2Q,
      });
    }
    return newU2Q;
  }

然后我们在对应的阿波罗playground就可以先订阅,会出现listening消息,然后在写问卷出发写入事件,对应就会通知到刚订阅的位置了,这里就不演示了,我的演示需要两个用户,较复杂

最后

上述配置仅供参考,基本上都是笔者自己逐步摸索出来的,并没有在任何官方文档中找到对应的肯定的配置描述,所以不一定是最佳实践,请根据自己的需求自行修改。

参考