GraphQL DataLoaders 在 NestJS 框架中的应用

546 阅读8分钟

这篇文章的前提是你已经熟悉了 NestJS 和 GraphQL。

我们将要构建什么

本文我们要在 NestJS 应用中创建一个简单的 GraphQL API,来获取一个文章列表。

我们使用的 GraphQL 查询如下:

query GetPosts {
  posts {
    id
    title
    body
    createdBy {
      id
      name
    }
  }
}

创建 NestJS 应用

nest new example-app

该命令会生成一个新的 NestJS 应用,代码结构如下:

df6hcrybqg2w76okwxdm.png

我们把不需要的代码删除,只留下 app.module.tsmain.ts

添加 users 模块

nest g module users

生成模块后,我们再添加 user.entity.tsusers.service.ts

user.entity.ts

export class User {
  id: number;
  name: string;
}

users.service.ts

import { Injectable } from '@nestjs/common';

import { delay } from '../util';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  private users: User[] = [
    { id: 1, name: 'John' },
    { id: 2, name: 'Jane' },
    { id: 3, name: 'Alex' },
    { id: 4, name: 'Anna' },
  ];

  async getUsers() {
    console.log('Getting users...');
    await delay(3000);
    return this.users;
  }
}

在我们使用 getUsers 方法 返回用户之前,我们先用 3000ms 延时来模拟一个数据库延迟。

不要忘了在 users.module.tsexports 数组中添加 UserService

添加 posts 模块

添加的方式和 users 模块一模一样:

post.entity.ts

export class Post {
  id: string;
  title: string;
  body: string;
  userId: number;
}

posts.service.ts

import { Injectable } from '@nestjs/common';

import { delay } from '../util';
import { Post } from './post.entity';

@Injectable()
export class PostsService {
  private posts: Post[] = [
    { id: 'post-1', title: 'Post 1', body: 'Lorem 1', userId: 1 },
    { id: 'post-2', title: 'Post 2', body: 'Lorem 2', userId: 1 },
    { id: 'post-3', title: 'Post 3', body: 'Lorem 3', userId: 2 },
  ];

  async getPosts() {
    console.log('Getting posts...');
    await delay(3000);
    return this.posts;
  }
}

目前的代码对于核心逻辑应该已经够了。接着我们来添加 GraphQL 相关的代码。

添加 GraphQL

我们将使用 代码优先 模式。

安装依赖包

npm i @nestjs/graphql graphql-tools graphql apollo-server-express

AppModule 中添加 GraphQLModule

import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';

import { PostsModule } from './posts/posts.module';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    UsersModule,
    PostsModule,
    GraphQLModule.forRoot({
      autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
    }),
  ],
  controllers: [],
  providers: [],
})
export class AppModule {}

通过声明 autoSchemaFile 属性,NestJS 会从代码中声明的 types 自动生成 GraphQL schema。如果当我们运行 npm run start:dev 时还没有任何声明,就会报错。

我们可以通过在代码中声明 GraphQL types 来解决这个问题。为此我们需要在 entity 类中添加一些装饰器:

user.entity.ts

import { Field, Int, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class User {
  @Field(() => Int)
  id: number;

  @Field()
  name: string;
}

此时问题还没有解决,报错依然存在。接着我们添加 resolver 来解决这个问题:

users.resolver.ts

import { Query, Resolver } from '@nestjs/graphql';

import { User } from './user.entity';
import { UsersService } from './users.service';

@Resolver(User)
export class UsersResolver {
  constructor(private readonly usersService: UsersService) {}

  @Query(() => [User])
  getUsers() {
    return this.usersService.getUsers();
  }
}

别忘了在 users.module.tsproviders 数组中添加 UsersResolver

添加 UsersResolver 后错误小时了,并且生成了一个新的文件:

schema.gql

# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------

type User {
  id: Int!
  name: String!
}

type Query {
  getUsers: [User!]!
}

我们来测试一下。打开 GraphQL playground(通常地址是http://localhost:3000/graphql)执行以下查询:

query GetUsers {
  users {
    id
    name
  }
}

3 秒之后我们应该可以看到以下结果:

{
  "data": {
    "users": [
      {
        "id": 1,
        "name": "John"
      },
      {
        "id": 2,
        "name": "Jane"
      },
      {
        "id": 3,
        "name": "Alex"
      },
      {
        "id": 4,
        "name": "Anna"
      }
    ]
  }
}

使用同样的方式为 posts 添加装饰器和解析器:

post.entity.ts

import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export class Post {
  @Field()
  id: string;

  @Field()
  title: string;

  @Field()
  body: string;

  userId: number;
}

posts.resolver.ts

import { Query, Resolver } from '@nestjs/graphql';

import { Post } from './post.entity';
import { PostsService } from './posts.service';

@Resolver(Post)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}

  @Query(() => [Post], { name: 'posts' })
  getPosts() {
    return this.postsService.getPosts();
  }
}

添加关联关系

这就是 GraphQL 的全部意义所在:查询被关联的数据。

现在我们在 post.entity.ts 中添加 createdBy

post.entity.ts

@Field(() => User)
createdBy?: User;

然后我们应该就可以在上文提到的 GraphQL playground 中运行 GetPosts 查询了。但是它报错了:

Cannot return null for non-nullable field Post.createdBy.

为此我们需要在 posts.resolver.ts 中解析 createdBy 字段。可以通过以下方式来添加:

posts.resolver.ts

@ResolveField('createdBy', () => User)
getCreatedBy(@Parent() post: Post) {
  const { userId } = post;
  return this.usersService.getUser(userId);
}

users.service.ts

async getUser(id: number) {
  console.log(`Getting user with id ${id}...`);
  await delay(1000);
  return this.users.find((user) => user.id === id);
}

另外我们还必须从 UserModule 中导出 UsersService 然后在 PostsModule 中导入 UsersModule

现在我们终于可以继续运行 GetPosts 查询了,我们将得到以下结果:

{
  "data": {
    "posts": [
      {
        "id": "post-1",
        "title": "Post 1",
        "body": "Lorem 1",
        "createdBy": {
          "id": 1,
          "name": "John"
        }
      },
      {
        "id": "post-2",
        "title": "Post 2",
        "body": "Lorem 2",
        "createdBy": {
          "id": 1,
          "name": "John"
        }
      },
      {
        "id": "post-3",
        "title": "Post 3",
        "body": "Lorem 3",
        "createdBy": {
          "id": 2,
          "name": "Jane"
        }
      }
    ]
  }
}

因为所有方法都设置了延迟,所以返回需要花些时间。

但是,如果我们查看控制台,应该应该会看到以下内容:

Getting posts...
Getting user with id 1...
Getting user with id 1...
Getting user with id 2...

在真实世界的场景中,每一行都意味着对一次数据库的单独查询。这就是著名的 N+1 问题。

这意味着对于第一个“查询”返回的每篇文章,即使所有文章都是由同一个人创建的,我们也必须为其创建者做一次单独的查询(如上所示我们查询了两次 id 为 1 的用户)。

这正是 DataLoader 可以提供帮助的地方。

什么是 DataLoader

根据官方的文档:

Dataloader 是一个通用实用的程序,可用作应用程序数据获取层的一部分,通常批处理和缓存为各种远程数据源(如数据库或 Web 服务)提供简化且一致的 API。

创建 users loader

首先需要安装它:

npm i dataloader

users.loader.ts

import * as DataLoader from 'dataloader';

import { mapFromArray } from '../util';
import { User } from './user.entity';
import { UsersService } from './users.service';

function createUsersLoader(usersService: UsersService) {
  return new DataLoader<number, User>(async (ids) => {
    const users = await usersService.getUsersByIds(ids);

    const usersMap = mapFromArray(users, (user) => user.id);

    return ids.map((id) => usersMap[id]);
  });
}

解释一下这里发生了什么:

  1. DataLoader 构造器接收了一个批处理函数作为参数。该批处理函数接受一个ids(或 keys)的数组,并返回一个解析为值数组的 promise。这里要注意的要点是,这些值必须与 ids 参数的顺序完全相同。
  2. userMap 是一个简单的对象数组,他们的 keys 是用户的 ids,而 values 则是实际的用户对象:
{
  1: {id: 1, name: "John"},
  ...
}

让我们看看如何使用它:

const usersLoader = createUsersLoader(usersService);

const user1 = await usersLoader.load(1)
const user2 = await usersLoader.load(2);

这实际上将使用我们之前定义的批处理函数发出一个“数据库请求”,并同时获取用户 1 和 2。

这对 GraphQL 有何帮助

基本思想是在每个 HTTP 请求上创建新的 users loader,并在多个解析器中使用它。在 GraphQL 中,单个请求在解析器之间共享相同的上下文对象,因此,我们应该能够将 users loader “附加”到上下文,然后在解析器中使用它。

把值附加到 GraphQL 上下文

如果我们使用 Apollo Server,我们只需要按以下方法把值附加在上下文中:

// Constructor
const server = new ApolloServer({
  typeDefs,
  resolvers,
  context: ({ req }) => ({
    authScope: getScope(req.headers.authorization)
  })
}));

// Example resolver
(parent, args, context, info) => {
  if(context.authScope !== ADMIN) throw new AuthenticationError('not admin');
  // Proceed
}

但是在NestJS 应用中,我们没有显式地实例化 ApolloServer,因此我们应该在声明 GraphQLModule 时声明上下文函数。在我们的例子中,它在 app.module.ts 中:

GraphQLModule.forRoot({
  autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
  context: () => ({
    randomValue: Math.random(),
  }),
}),

接下来我们要做的就是在一个解析器中访问上下文,在 @nestjs/graphql 中有一个装饰器:

post.reslover.ts

@Query(() => [Post], { name: 'posts' })
getPosts(@Context() context: any) {
  console.log(context.randomValue);
  return this.postsService.getPosts();
}

@ResolveField('createdBy', () => User)
getCreatedBy(@Parent() post: Post, @Context() context: any {
  console.log(context.randomValue);
  const { userId } = post;
  return this.usersService.getUser(userId);
}

现在当我们运行 GetPosts 查询时,我们应该可以看到控制台中的内容如下所示:

0.858156868751532
Getting posts...
0.858156868751532
Getting user with id 1...
0.858156868751532
Getting user with id 1...
0.858156868751532
Getting user with id 2...

所有解析器的值都相同,为了证明它对每个 HTTP 请求都是唯一的,我们可以再次运行查询闭关检查 randomValue 是否更改。

我们可以通过将字符串传递给 Context 装饰器来使它变得更优雅:

@Query(() => [Post], { name: 'posts' })
getPosts(@Context('randomValue') randomValue: number) {
  console.log(randomValue);
  return this.postsService.getPosts();
}

@ResolveField('createdBy', () => User)
getCreatedBy(@Parent() post: Post, @Context('randomValue') randomValue: number) {
  console.log(randomValue);
  const { userId } = post;
  return this.usersService.getUser(userId);
}

现在我们已经了解了如何将值附加到 GraphQL 上下文,我们可以继续尝试将 data loaders 附加到上下文中。

将 DataLoaders 附加到 GraphQL 上下文

app.module.ts

GraphQLModule.forRoot({
  autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
  context: () => ({
    randomValue: Math.random(),
    usersLoader: createUsersLoader(usersService),
  }),
}),

如果我们尝试像上面那样将 usersLoader 添加进来就会报错,因为 usersService 没有被定义。为解决这个问题,我们需要将 GraphQLModule 的定义修改为 forRootAsync 方法:

app.module.ts

GraphQLModule.forRootAsync({
  useFactory: (usersService: UsersService) => ({
    autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
    context: () => ({
      randomValue: Math.random(),
      usersLoader: createUsersLoader(usersService),
    }),
  }),
}),

现在也许可以编译,但是仍然不能真正工作。我们需要在 useFactory 下方添加 inject 属性:

useFactory: ...,
inject: [UsersService],

目前为止仍会报错,因此我们需要在某些地方给 GraphQLModule 提供 UsersService ,而且要在 GraphQLModule 中导入 UsersModule

imports: [UsersModule],
useFactory: ...

有了这些,我们现在就已经成功地将 usersLoader 附加到 GraphQL 的上下文对象中。现在让我们看下如何使用它。

在解析器中使用 usersLoader

现在我们去解析器中用 usersLoader 替换 randomValue

posts.resolver.ts

import { Context, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import * as DataLoader from 'dataloader';

import { User } from '../users/user.entity';
import { Post } from './post.entity';
import { PostsService } from './posts.service';

@Resolver(Post)
export class PostsResolver {
  constructor(private readonly postsService: PostsService) {}

  @Query(() => [Post], { name: 'posts' })
  getPosts() {
    return this.postsService.getPosts();
  }

  @ResolveField('createdBy', () => User)
  getCreatedBy(
    @Parent() post: Post,
    @Context('usersLoader') usersLoader: DataLoader<number, User>,
  ) {
    const { userId } = post;
    return usersLoader.load(userId);
  }
}

最后,当我们再次运行 GetPosts 查询,控制台将会输出以下内容:

Getting posts...
Getting users with ids (1,2)

在真实场景中这意味着无论文章或者用户的数量有多少,都只有两次数据库查询,这样我们就解决了 N+1 问题

总结

所有这些设置都有点复杂,但好处是只需要完成一次,之后我们就可以添加更多 loaders 并在解析器中使用他们了。

译者注:

实际上最核心的思路还是enqueuePostPromiseJob, 通过这种方式巧妙地将一批单次的数据查询(GetSingleUserById)转化为一次批量的数据查询(GetBatchUsersByIds), 大大减少了数据库I/O的次数, 使得你的GraphQL API性能一下有了明显提升.

使用DataLoader并不一定能提升你的接口请求耗时,只有在数据量级达到一定程度时, 才有可能带来明显的RT提升, 在数据量级较小时, 它反而可能带来反作用.

完整代码可在 GitHub 上找到:

github.com/filipegeric…

感谢阅读!

原文名:Using GraphQL DataLoaders with NestJS

原文作者:Filip Egeric 发表于2021年7月9日

原文地址:dev.to/filipegeric…