0%

Apollo入门引导(三):编写查询解析器

接上篇 —— Apollo 入门引导(二):连接数据源 —— 继续翻译 Apollo 的官网入门引导

学习 GraphQL 的查询是如何获取数据的。

Apollo 入门引导 - 目录:

  1. 介绍
  2. 构建 schema
  3. 连接数据源
  4. 编写查询解析器
  5. 编写变更解析器
  6. 连接 Apollo Studio
  7. 创建 Apollo 客户端
  8. 通过查询获取数据
  9. 通过变更修改数据
  10. 管理本地状态

完成时间:15 分钟

前一节已经设计了 schema 并配置了数据源,但是服务不知道如何使用其数据源来填充 schema 字段。为了解决这个问题,接下来将定义一个解析器(resolver)集合。

解析器的功能是负责为 schema 中的字段填充数据。每当客户端查询特定字段时,该字段的解析器都会从适当的数据源中获取请求的数据。

解析器函数返回以下之一:

  • 解析器对应 schema 字段所需的类型的数据(字符串,整数,对象等)
  • 满足所需类型数据的期约(promise)

解析器函数签名

在开始编写解析器之前,先介绍一下解析器函数的签名是什么样的。解析器函数接受四个可选参数:

1
fieldName: (parent, args, context, info) => data;
参数 描述
parent 这是该字段的父级的解析器的返回值(父级解析器始终在其子字段的子级解析器之前执行)。
args 该对象包含为此字段提供的所有GraphQL 参数
context 该对象在执行特定操作的所有解析器之间共享。使用此参数可以共享每个操作的状态,例如身份验证信息和对数据源的访问。
info 其中包含有关操作执行状态的信息(仅在高级情况下使用)

在这四个参数中,我们定义的解析器将主要使用 context 参数。它使我们的解析器可以共享 LaunchAPIUserAPI 数据源实例。要了解其工作原理,下面就开始创建一些解析器。

定义顶级解析器

如上所述,父字段的解析器始终在其子字段的子解析器之前执行。因此,先从一些顶级字段的解析器开始定义:Query 类型。

正如 src/schema.js 所示,我们 schema 的 Query 类型定义了三个字段:launcheslaunchme。要为这些字段定义解析器,请打开 src/resolvers.js 并粘贴以下代码:

1
2
3
4
5
6
7
8
9
module.exports = {
Query: {
launches: (_, __, { dataSources }) =>
dataSources.launchAPI.getAllLaunches(),
launch: (_, { id }, { dataSources }) =>
dataSources.launchAPI.getLaunchById({ launchId: id }),
me: (_, __, { dataSources }) => dataSources.userAPI.findOrCreateUser(),
},
};

如该代码所示,我们在映射(map)中定义了解析器,其中映射的键对应于 schema 的类型(Query)和字段(launcheslaunchme)。

关于上面的函数参数:

  • 所有三个解析器函数均将其第一个参数parent)分配给变量 _,以表示用不到该值。
  • 出于相同的原因,launchesme 函数将其 第二个 参数(args)分配给__
    • launch函数都用到了 args 参数,因为 schema 的 launch 字段带有 id 参数。)
  • 三个解析器函数都用到了第三个参数(context)。具体来说,将其解构以访问之前定义的 dataSources
  • 三个解析器函数都没用到第四个参数(info),所以也不需要包含它。

如你所见,这些解析器函数都很短!原因是它们所依赖的大多数逻辑是 LaunchAPIUserAPI 数据源定义的。使解析器变短是最佳实践,使你可以安全地重构支持逻辑,同时减少破坏 API 的可能性。

将解析器添加到 Apollo 服务

现在我们有了一些解析器,将它们添加到服务中。将 highlight 行添加到 src / index.js 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');
const { createStore } = require('./utils');
const resolvers = require('./resolvers'); // highlight-line

const LaunchAPI = require('./datasources/launch');
const UserAPI = require('./datasources/user');

const store = createStore();

const server = new ApolloServer({
typeDefs,
resolvers, // highlight-line
dataSources: () => ({
launchAPI: new LaunchAPI(),
userAPI: new UserAPI({ store }),
}),
});

server.listen().then(({ url }) => {
console.log(`🚀 Server ready at ${url}`);
});

通过像这样向 Apollo 服务提供解析器映射,它自己就知道如何根据需要调用解析器函数来完成传入的查询。

运行测试查询

下面在服务上运行测试查询!使用 npm start 启动它,并打开先前探索你的 schema章节提供的工具:

将以下查询粘贴到工具的编辑器面板中:

1
2
3
4
5
6
7
8
9
# 在之后的教程中将会学习查询结构的更多信息
query GetLaunches {
launches {
id
mission {
name
}
}
}

然后,单击 Run 按钮。服务的响应出现在右侧。看看响应对象的结构是如何与查询的结构匹配的?这种对应关系是 GraphQL 的基本特点。

现在尝试一个带有 GraphQL 参数 的测试查询。粘贴以下查询并运行:

1
2
3
4
5
6
7
8
9
query GetLaunchById {
launch(id: 60) {
id
rocket {
id
type
}
}
}

这个查询返回 id60Launch 对象的详细信息。

通过这些工具,无需像上面的查询那样对参数进行硬编码,而是可以为操作定义 变量(variable)。下面是使用变量而不是60的相同效果的查询:

1
2
3
4
5
6
7
8
9
query GetLaunchById($id: ID!) {
launch(id: $id) {
id
rocket {
id
type
}
}
}

现在,将以下内容粘贴到工具的 Variables 面板中:

1
2
3
{
"id": 60
}

在继续操作之前,可以随意运行查询和设置变量。

定义其他解析器

你可能已经注意到,上面运行的测试查询包含了几个我们还没有编写解析器的字段。但是这些查询仍然可以成功运行!这是因为 Apollo 服务为没有自定义解析器的字段定义了一个默认解析器

默认的解析器函数使用以下逻辑:

1
2
3
4
5
6
graph TB;
parent("Does the parent argument have a<br/>property with this resolver's exact name?");
parent--No-->null("Return undefined");
parent--Yes-->function("Is that property's value a function?");
function--No-->return("Return the property's value");
function--Yes-->call("Call the function and<br/>return its return value");

对于 schema 的大多数(但不是全部)字段,默认解析器完全可以实现想要的功能。接下来为 Mission.missionPatch 的 schema 字段定义一个自定义解析器。

该字段具有以下定义:

1
2
3
4
5
# 不需要复制这段代码
type Mission {
# 其他字段的定义...
missionPatch(size: PatchSize): String
}

Mission.missionPatch 的解析器应返回不同的值,具体取决于查询的 size 参数指定的是 LARGE 还是 SMALL

Query 属性下方的 src/resolvers.js 中,将以下内容添加到解析器映射中:

1
2
3
4
5
6
7
8
9
10
11
// Query: {
// ...
// },
Mission: {
// 默认的size为 'LARGE'
missionPatch: (mission, { size } = { size: 'LARGE' }) => {
return size === 'SMALL'
? mission.missionPatchSmall
: mission.missionPatchLarge;
},
},

这个解析器从 mission 获取一个大的或小的徽章,这是 schema 的 字段Launch.mission默认解析器返回的对象。

现在,我们知道了如何为 Query 之外的类型添加解析器,继续为 LaunchUser 类型的字段添加解析器。在Mission 下方将以下内容添加到解析器映射中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Mission: {
// ...
// },
Launch: {
isBooked: async (launch, _, { dataSources }) =>
dataSources.userAPI.isBookedOnLaunch({ launchId: launch.id }),
},
User: {
trips: async (_, __, { dataSources }) => {
// 通过user获得发射的id数组
const launchIds = await dataSources.userAPI.getLaunchIdsByUser();

if (!launchIds.length) return [];

// 通过id数组来查询launch数组
return (
dataSources.launchAPI.getLaunchesByIds({
launchIds,
}) || []
);
},
},

你可能想知道服务在调用 getLaunchIDsByUser 之类的函数时如何知道当前用户的身份。目前还不能知道!将在下一章中解决该问题。

分页结果

当前,Query.launches 返回一长串 Launch 对象。这通常比客户端一次需要的信息冗余太多,并且获取大量数据可能速度会很慢。可以通过实现分页来改善该字段的性能。

分页可确保服务分小块发送数据。建议对带编号的页面进行基于游标的分页(cursor-based pagination),因为它消除了跳过一条或多次显示同一条的可能性。在基于游标的分页中,常量指针(或游标)用于在获取下一组结果时,跟踪数据集的起始位置。

下面来设置基于游标的分页。在 src/schema.js 中,更新 Query.launches 以匹配以下内容,并添加一个名为LaunchConnection 的新类型,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Query {
launches( # 替换当前的launches查询
"""
要展示的结果数量,必须>= 1. Default = 20
"""
pageSize: Int
"""
如果在这里添加了一个游标,只返回游标_之后_的内容
"""
after: String
): LaunchConnection!
launch(id: ID!): Launch
me: User
}

"""
launches列表周围的简单包装,其中包含到列表中的最后一项的游标。
将此游标传到launches查询,以在这些之后获取结果。
"""
type LaunchConnection { # 作为一个可选类型,添加在Query类型下面
cursor: String!
hasMore: Boolean!
launches: [Launch]!
}

现在,Query.launches 接受两个参数(pageSizeafter)并返回一个 LaunchConnection 对象。LaunchConnection 包括:

  • launches 列表(查询请求到的实际数据)
  • cursor,“游标”指示数据集中当前位置
  • hasMore,布尔值,指示数据集是否除launches中包含的项之外还有更多项

打开 src/utils.js 并查看 paginateResults 函数。这是用于从服务分页数据的辅助函数。

现在,来更新解析器函数以适应分页。导入 paginateResults 并用以下代码替换 src/resolvers.js 中的 launches 解析器函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const { paginateResults } = require('./utils');

module.exports = {
Query: {
launches: async (_, { pageSize = 20, after }, { dataSources }) => {
const allLaunches = await dataSources.launchAPI.getAllLaunches();
// 希望按时间倒序排列
allLaunches.reverse();

const launches = paginateResults({
after,
pageSize,
results: allLaunches,
});

return {
launches,
cursor: launches.length ? launches[launches.length - 1].cursor : null,
// 如果分页结果末尾的游标与_所有_结果中的最后一项相同,则再之后就没有结果
hasMore: launches.length
? launches[launches.length - 1].cursor !==
allLaunches[allLaunches.length - 1].cursor
: false,
};
},
launch: (_, { id }, { dataSources }) =>
dataSources.launchAPI.getLaunchById({ launchId: id }),
me: async (_, __, { dataSources }) =>
dataSources.userAPI.findOrCreateUser(),
},
};

测试一下刚刚实现的基于游标的分页。使用 npm start 重新启动服务,并在 Explorer / GraphQL Playground 中运行以下查询:

1
2
3
4
5
6
7
8
9
10
query GetLaunches {
launches(pageSize: 3) {
launches {
id
mission {
name
}
}
}
}

由于我们实现了分页,服务应该只返回三个发射而不是完整列表。

这样就完成了 schema 查询的解析器!接下来,为它的变更编写解析器。


前端记事本,不定期更新,欢迎关注!


👆 全文结束,棒槌时间到 👇