NestJS 9 GraphQL 中文文档(四) - 订阅

407 阅读11分钟

除了用查询获取数据和用突变修改数据以外,GraphQL还特别支持第三种操作类型,叫subscription。GraphQL订阅是一种将数据从服务器推送到客户端的方法,前提是这些客户端选择监听来自服务器的即时消息。订阅类似于查询操作,因为它们都指定了一组要传递给客户端的字段,但订阅不是立即返回单个应答,而是打开一个通道,每当在服务器上发生特定事件时,才将结果发送给客户端。

订阅的一个常见用例是通知客户端特定事件,例如创建新对象、更新字段等(在此阅读更多)。

使用Apollo驱动程序启用订阅

要启用订阅,设置installSubscriptionHandlers属性为true即可。

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  installSubscriptionHandlers: true,
}),

警告⚠️
installSubscriptionHandlers配置项在最新版本的Apollo服务器中已经被移除了,并将很快在此包中弃用。默认情况下,installSubscriptionHandlers将回退使用subscriptions-transport-ws阅读更多),但我们强烈建议你使用graphql-ws阅读更多)库来替代。

要改用graphql-ws包,可以使用以下配置:

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  subscriptions: {
    'graphql-ws': true
  },
}),

提示
你可以同时使用这两个包(subscriptions-transport-wsgraphql-ws),以便向后兼容。

代码优先

使用代码优先方式创建订阅,我们可以使用@Subscription()装饰器(从@nestjs/graphql包导出)和从graphql-subscriptions包导出的PubSub类,这个类可以提供一个简单的发布/订阅 API。

以下订阅处理器通过调用 PubSub#asyncIterator 负责订阅事件。此方法接收一个参数,即triggerName,它对应一个事件主题名称。

const pubSub = new PubSub();

@Resolver((of) => Author)
export class AuthorResolver {
  // ...
  @Subscription((returns) => Comment)
  commentAdded() {
    return pubSub.asyncIterator('commentAdded');
  }
}

提示
所有装饰器都是从@nestjs/graphql包中导出的,但PubSub类是从graphql-subscriptions包里导出的。

注意
PubSub是一个类,暴露了一个简单的publishsubscribe API。在此了解更多关于它的信息。注意Apollo 在文档中警告了这种默认实现不适合用于生产环境(在此阅读更多)。生产应用应该使用由外部存储(译者注:例如Redis)支持的PubSub实现(在此了解更多)。

最终在SDL中生成GraphQL schema 的以下部分:

type Subscription {
  commentAdded(): Comment!
}

请注意,根据定义,订阅会返回一个具有单个顶级属性的对象,该属性的键是订阅的名称。这个名字要么是从订阅处理器方法(即:上面的commentAdded)的名字继承的,要么是通过将带有键名的选项作为第二个参数,传递给@Subscription() 装饰器来显式提供,如下所示。

@Subscription(returns => Comment, {
  name: 'commentAdded',
})
subscribeToCommentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

此构造生成与前面的代码示例相同的SDL,但允许我们将方法名和订阅分离。

发布

现在可以发布事件了,我们使用PubSub#publish方法。我们通常在突变中使用发布,当对象图的一部分发生更改时触发客户端更新。例如:

//posts/posts.resolver.ts

@Mutation(returns => Post)
async addComment(
  @Args('postId', { type: () => Int }) postId: number,
  @Args('comment', { type: () => Comment }) comment: CommentInput,
) {
  const newComment = this.commentsService.addComment({ id: postId, comment });
  pubSub.publish('commentAdded', { commentAdded: newComment });
  return newComment;
}

PubSub#publish方法接收一个triggerName(已经提到过,你可以把这个认为是一个事件主题的名称)作为第一个参数,以及事件负载作为第二个参数。如前所述,根据定义,订阅返回一个值并且该值具有特定数据结构。再次查看为我们的commentAdded订阅生成的SDL:

type Subscription {
  commentAdded(): Comment!
}

这告诉我们,订阅必须返回一个名为commentAdded且带有顶级属性的对象,它的值是一个Comment对象。需要重点注意的是,这个通过PubSub#publish方法监听的事件的负载的数据结构,必须与我们预期从订阅返回的值的数据结构相对应。所以,在上面的示例中,pubSub.publish('commentAdded', { commentAdded: newComment }) 这段代码表明发布了一个带有适当数据结构负载的commentAdded事件。如果这些数据结构不匹配,你的订阅将会在GraphQL验证阶段失败。

过滤订阅

要过滤特定的事件,需要将filter属性设置为一个过滤函数。此函数的作用类似于传递给数组filter的函数。它需要两个参数:payload包含事件负载(由事件发布者发送),variables接受在订阅请求期间传入的任何参数。它返回一个布尔值来决定是否该事件应该被发布到客户端监听者。

@Subscription(returns => Comment, {
  filter: (payload, variables) =>
    payload.commentAdded.title === variables.title,
})
commentAdded(@Args('title') title: string) {
  return pubSub.asyncIterator('commentAdded');
}

修改订阅负载

要修改已发布的事件负载,需要将resolve属性设置为函数。此函数接收事件负载(由事件发布者发送)并返回适当的值。

@Subscription(returns => Comment, {
  resolve: value => value,
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

注意
如果你使用resolve选项,你应该返回一个展开的负载(例如,在我们的例子中,直接返回一个newComment对象,而不是一个{ commentAdded: newComment }对象)

如果你需要访问注入的提供者(例如:使用一个外部服务来验证数据),可以用以下构造。

@Subscription(returns => Comment, {
  resolve(this: AuthorResolver, value) {
    // "this" refers to an instance of "AuthorResolver"
    return value;
  }
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

同样的构造也适用于过滤:

@Subscription(returns => Comment, {
  filter(this: AuthorResolver, payload, variables) {
    // "this" refers to an instance of "AuthorResolver"
    return payload.commentAdded.title === variables.title;
  }
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

模式优先

在Nest中创建等效订阅,我们将使用@Subscription() 装饰器。

const pubSub = new PubSub();

@Resolver('Author')
export class AuthorResolver {
  // ...
  @Subscription()
  commentAdded() {
    return pubSub.asyncIterator('commentAdded');
  }
}

基于上下文和参数来过滤指定事件,设置filter属性。

@Subscription('commentAdded', {
  filter: (payload, variables) =>
    payload.commentAdded.title === variables.title,
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

修改已发布的负载,我们可以使用一个resolve函数。

@Subscription('commentAdded', {
  resolve: value => value,
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

如果你要访问注入的提供者(例如:使用一个外部服务来验证数据),使用以下构造:

@Subscription('commentAdded', {
  resolve(this: AuthorResolver, value) {
    // "this" refers to an instance of "AuthorResolver"
    return value;
  }
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

同样的构造也适用于过滤:

@Subscription('commentAdded', {
  filter(this: AuthorResolver, payload, variables) {
    // "this" refers to an instance of "AuthorResolver"
    return payload.commentAdded.title === variables.title;
  }
})
commentAdded() {
  return pubSub.asyncIterator('commentAdded');
}

最后一步是更新类型定义文件。

type Author {
  id: Int!
  firstName: String
  lastName: String
  posts: [Post]
}

type Post {
  id: Int!
  title: String
  votes: Int
}

type Query {
  author(id: Int!): Author
}

type Comment {
  id: String
  content: String
}

type Subscription {
  commentAdded(title: String!): Comment
}

有了这些,我们就已经创建了一个commentAdded(title: String!): Comment 订阅了。你可以在此处找到示例代码的全部实现。

发布订阅

在上面我们已经实例化了一个本地的PubSub实例。首选的方式是将PubSub定义为提供者并通过构造器(使用@Inject() 装饰器)注入它。这允许我们在整个应用中重用该实例。例如,像下面这样定义一个提供者,然后在需要的地方注入'PUB_SUB'。

{
  provide: 'PUB_SUB',
  useValue: new PubSub(),
}

自定义订阅服务器

要自定义订阅服务器(例如,更改路径),使用subscriptions 选项属性。

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  subscriptions: {
    'subscriptions-transport-ws': {
      path: '/graphql'
    },
  }
}),

如果你使用graphql-ws包进行订阅,将subscriptions-transport-ws这键替换为 graphql-ws即可,如下所示:

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  subscriptions: {
    'graphql-ws': {
      path: '/graphql'
    },
  }
}),

WebSockets身份验证

检查用户是否已经验证,可以在onConnect回调函数中完成,你可以在subscriptions配置项中指定这个函数。

onConnect将接收从SubscriptionClient传入的connectionParams作为第一个参数(阅读更多)。

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  subscriptions: {
    'subscriptions-transport-ws': {
      onConnect: (connectionParams) => {
        const authToken = connectionParams.authToken;
        if (!isValid(authToken)) {
          throw new Error('Token is not valid');
        }
        // extract user information from token
        const user = parseToken(authToken);
        // return user info to add them to the context later
        return { user };
      },
    }
  },
  context: ({ connection }) => {
    // connection.context will be equal to what was returned by the "onConnect" callback
  },
}),

在本例中,authToken仅在首次建立连接时,由客户端发送一次。使用此连接进行的所有订阅都将具有相同的authToken,因此具有相同的用户信息。

注意
subscriptions-transport-ws中存在一个允许连接跳过onConnect阶段的错误(阅读更多)。你不能假定onConnect在用户开始订阅时总是被调用,并且总是检查上下文是否被填充。

如果你正在使用graphql-ws包,onConnect回调的签名会略有不同:

GraphQLModule.forRoot<ApolloDriverConfig>({
  driver: ApolloDriver,
  subscriptions: {
    'graphql-ws': {
      onConnect: (context: 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
        extra.user = { user: {} };
      },
    },
  },
  context: ({ extra }) => {
    // you can now access your additional context value through the extra field
  },
});

使用Mercurius驱动程序启用订阅

要启用订阅,设置subscription属性为true即可。

GraphQLModule.forRoot<MercuriusDriverConfig>({
  driver: MercuriusDriver,
  subscription: true,
}),

提示
你还可以传递选项对象来设置自定义发射器,验证传入连接等。在此阅读更多(查看subscription)。

代码优先

用代码优先的方式创建订阅,我们需使用@Subscription() 装饰器(从@nestjs/graphql 包导出)以及mercurius包中的PubSub类,它提供了一个简单的发布/订阅 API

以下订阅处理器通过调用 PubSub#asyncIterator 负责订阅事件。此方法接收一个参数,即triggerName,它对应一个事件主题名称。

@Resolver((of) => Author)
export class AuthorResolver {
  // ...
  @Subscription((returns) => Comment)
  commentAdded(@Context('pubsub') pubSub: PubSub) {
    return pubSub.subscribe('commentAdded');
  }
}

提示
所有装饰器都是从@nestjs/graphql包中导出的,但PubSub类是从mercurius包里导出的。

注意
PubSub是一个类,暴露了一个简单的publishsubscribe API。在此节中查看如何注册一个自定义PubSub类。

最终在SDL中生成GraphQL schema 的以下部分:

type Subscription {
  commentAdded(): Comment!
}

请注意,根据定义,订阅会返回一个具有单个顶级属性的对象,该属性的键是订阅的名称。这个名字要么是从订阅处理器方法(即:上面的commentAdded)的名字继承的,要么是通过将带有键名的选项作为第二个参数,传递给@Subscription() 装饰器来显式提供,如下所示。

@Subscription(returns => Comment, {
  name: 'commentAdded',
})
subscribeToCommentAdded(@Context('pubsub') pubSub: PubSub) {
  return pubSub.subscribe('commentAdded');
}

此构造生成与前面的代码示例相同的SDL,但允许我们将方法名和订阅分离。

发布

现在可以发布事件了,我们使用PubSub#publish方法。我们通常在突变中使用发布,当对象图的一部分发生更改时触发客户端更新。例如:

//posts/posts.resolver.ts

@Mutation(returns => Post)
async addComment(
  @Args('postId', { type: () => Int }) postId: number,
  @Args('comment', { type: () => Comment }) comment: CommentInput,
  @Context('pubsub') pubSub: PubSub,
) {
  const newComment = this.commentsService.addComment({ id: postId, comment });
  await pubSub.publish({
    topic: 'commentAdded',
    payload: {
      commentAdded: newComment
    }
  });
  return newComment;
}

如前所述,根据定义,订阅返回一个值并且该值具有特定数据结构。再次查看为我们的commentAdded订阅生成的SDL:

type Subscription {
  commentAdded(): Comment!
}

这告诉我们,订阅必须返回一个名为commentAdded且带有顶级属性的对象,它的值是一个Comment对象。需要重点注意的是,这个通过PubSub#publish方法监听的事件的负载的数据结构,必须与我们预期从订阅返回的值的数据结构相对应。所以,在上面的示例中,pubSub.publish(topic: 'commentAdded', { commentAdded: newComment }) 这段代码表明发布了一个带有适当数据结构负载的commentAdded事件。如果这些数据结构不匹配,你的订阅将会在GraphQL验证阶段失败。

过滤订阅

要过滤特定的事件,需要将filter属性设置为一个过滤函数。此函数的作用类似于传递给数组filter的函数。它需要两个参数:payload包含事件负载(由事件发布者发送),variables接受在订阅请求期间传入的任何参数。它返回一个布尔值来决定是否该事件应该被发布到客户端监听者。

@Subscription(returns => Comment, {
  filter: (payload, variables) =>
    payload.commentAdded.title === variables.title,
})
commentAdded(@Args('title') title: string, @Context('pubsub') pubSub: PubSub) {
  return pubSub.subscribe('commentAdded');
}

如果你需要访问注入的提供者(例如:使用一个外部服务来验证数据),可以用以下构造。

@Subscription(returns => Comment, {
  filter(this: AuthorResolver, payload, variables) {
    // "this" refers to an instance of "AuthorResolver"
    return payload.commentAdded.title === variables.title;
  }
})
commentAdded(@Args('title') title: string, @Context('pubsub') pubSub: PubSub) {
  return pubSub.subscribe('commentAdded');
}

模式优先

在Nest中创建等效订阅,我们将使用@Subscription() 装饰器。

const pubSub = new PubSub();

@Resolver('Author')
export class AuthorResolver {
  // ...
  @Subscription()
  commentAdded(@Context('pubsub') pubSub: PubSub) {
    return pubSub.subscribe('commentAdded');
  }
}

基于上下文和参数来过滤指定事件,设置filter属性。

@Subscription('commentAdded', {
  filter: (payload, variables) =>
    payload.commentAdded.title === variables.title,
})
commentAdded(@Context('pubsub') pubSub: PubSub) {
  return pubSub.subscribe('commentAdded');
}

如果你要访问注入的提供者(例如:使用一个外部服务来验证数据),使用以下构造:

@Subscription('commentAdded', {
  filter(this: AuthorResolver, payload, variables) {
    // "this" refers to an instance of "AuthorResolver"
    return payload.commentAdded.title === variables.title;
  }
})
commentAdded(@Context('pubsub') pubSub: PubSub) {
  return pubSub.subscribe('commentAdded');
}

最后一步是更新类型定义文件。

type Author {
  id: Int!
  firstName: String
  lastName: String
  posts: [Post]
}

type Post {
  id: Int!
  title: String
  votes: Int
}

type Query {
  author(id: Int!): Author
}

type Comment {
  id: String
  content: String
}

type Subscription {
  commentAdded(title: String!): Comment
}

发布订阅

在上面的示例中,我们使用的是默认的PubSub发射器(mqemitter),首选的方式(生产)是使用mqemmiter-redis。另外,也可以提供一个自定义的PubSub实现(在此了解更多)。

GraphQLModule.forRoot<MercuriusDriverConfig>({
  driver: MercuriusDriver,
  subscription: {
    emitter: require('mqemitter-redis')({
      port: 6579,
      host: '127.0.0.1',
    }),
  },
});

WebSockets身份验证

检查用户是否已经验证,可以在verifyClient回调函数中完成,你可以在subscriptions配置项中指定这个函数。

verifyClient将接收info对象作为第一个参数,你可以使用它来检索请求的头信息。

GraphQLModule.forRoot<MercuriusDriverConfig>({
  driver: MercuriusDriver,
  subscription: {
    verifyClient: (info, next) => {
      const authorization = info.req.headers?.authorization as string;
      if (!authorization?.startsWith('Bearer ')) {
        return next(false);
      }
      next(true);
    },
  }
}),