这篇文章的前提是你已经熟悉了 NestJS 和 GraphQL。
我们将要构建什么
本文我们要在 NestJS 应用中创建一个简单的 GraphQL API,来获取一个文章列表。
我们使用的 GraphQL 查询如下:
query GetPosts {
posts {
id
title
body
createdBy {
id
name
}
}
}
创建 NestJS 应用
nest new example-app
该命令会生成一个新的 NestJS 应用,代码结构如下:
我们把不需要的代码删除,只留下 app.module.ts 和 main.ts。
添加 users 模块
nest g module users
生成模块后,我们再添加 user.entity.ts 和 users.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.ts的exports数组中添加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.ts的providers数组中添加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]);
});
}
解释一下这里发生了什么:
- DataLoader 构造器接收了一个批处理函数作为参数。该批处理函数接受一个
ids(或 keys)的数组,并返回一个解析为值数组的 promise。这里要注意的要点是,这些值必须与ids参数的顺序完全相同。 - 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 上找到:
感谢阅读!
原文名:Using GraphQL DataLoaders with NestJS
原文作者:Filip Egeric 发表于2021年7月9日
原文地址:dev.to/filipegeric…