跳到主要内容

GraphQL 字段集合

在本节,我们继续GraphQL 示例,解释 Ent 如何为我们的 GraphQL 架构实现 GraphQL 字段集合Specification),并在解析器中解决 “N+1 问题”

克隆代码(可选)

此教程的代码托管在 github.com/a8m/ent-graphql-example,每一步均已使用 Git 进行打标签。若想跳过基本设置,直接开始 GraphQL 服务器的初始版本,可以按以下方式克隆仓库:

git clone git@github.com:a8m/ent-graphql-example.git
cd ent-graphql-example
go run ./cmd/todo/

问题

“N+1 问题”指的是当服务可以避免时,却仍执行多余的数据库查询来获取节点关联(即边缘)。可能会执行的查询数量(N+1)取决于根查询返回的节点数、它们的关联以及递归层级的后续关联。换言之,这个数字可能会非常大(远远大于 N+1)。

下面用一个查询来说明:

query {
users(first: 50) {
edges {
node {
photos {
link
}
posts {
content
comments {
content
}
}
}
}
}
}

在上面的查询中,我们想获取前 50 个用户,以及他们的照片和帖子,包括评论。

在朴素实现中,服务器会首先一次性查询前 50 个用户,然后为每个用户执行一次查询获取他们的照片(共 50 次),再为每个用户执行一次查询获取他们的帖子(共 50 次)。假设每位用户恰好有 10 篇帖子,那么每篇帖子(每位用户的)又会再执行一次查询获取其评论(共 500 次)。总计将执行 1+50+50+500=601 次查询。

gql 请求树

Ent 解决方案

Ent 对字段集合的扩展为关联(即边缘)提供了自动 GraphQL 字段集合(使用预加载)支持。若查询请求节点及其边缘,entgql 会自动在根查询中添加 With<E> 步骤,从而使客户端仅需执行常数数量的数据库查询 —— 并且递归适用。

在上述 GraphQL 查询中,客户端将执行 1 次查询获取用户,1 次查询获取照片,另外 2 次查询分别获取帖子及其评论 (共 4 次!)。此逻辑同时适用于根查询/解析器以及节点 API。

示例

为了演示,先 禁用自动字段集合,将 ent.ClientTodos 解析器中改为调试模式,并重启 GraphQL 服务器:

ent.resolvers.go
func (r *queryResolver) Todos(ctx context.Context, after *ent.Cursor, first *int, before *ent.Cursor, last *int, orderBy *ent.TodoOrder) (*ent.TodoConnection, error) {
- return r.client.Todo.Query().
+ return r.client.Debug().Todo.Query().
Paginate(ctx, after, first, before, last,
ent.WithTodoOrder(orderBy),
)
}

我们从分页教程中执行 GraphQL 查询,并在结果中添加 parent 边缘:

query {
todos(last: 10, orderBy: {direction: DESC, field: TEXT}) {
edges {
node {
id
text
parent {
id
}
}
cursor
}
}
}

查看进程输出,你会看到服务器执行了 11 次数据库查询:1 次获取最近的 10 条 todo 项,随后 10 次查询获取每条项的父项:

SELECT DISTINCT `todos`.`id`, `todos`.`text`, `todos`.`created_at`, `todos`.`status`, `todos`.`priority` FROM `todos` ORDER BY `id` ASC LIMIT 11
SELECT DISTINCT `todos`.`id`, `todos`.`text`, `todos`.`created_at`, `todos`.`status`, `todos`.`priority` FROM `todos` JOIN (SELECT `todo_parent` FROM `todos` WHERE `id` = ?) AS `t1` ON `todos`.`id` = `t1`.`todo_parent` LIMIT 2
SELECT DISTINCT `todos`.`id`, `todos`.`text`, `todos`.`created_at`, `todos`.`status`, `todos`.`priority` FROM `todos` JOIN (SELECT `todo_parent` FROM `todos` WHERE `id` = ?) AS `t1` ON `todos`.`id` = `t1`.`todo_parent` LIMIT 2
...(省略多行)

让我们看看 Ent 如何自动解决问题:在定义 Ent 边缘时,entgql 自动绑定至 GraphQL 中的使用,并在 gql_edge.go 文件中为节点生成边缘解析器:

ent/gql_edge.go
func (t *Todo) Children(ctx context.Context) ([]*Todo, error) {
if fc := graphql.GetFieldContext(ctx); fc != nil && fc.Field.Alias != "" {
result, err = t.NamedChildren(graphql.GetFieldContext(ctx).Field.Alias)
} else {
result, err = t.Edges.ChildrenOrErr()
}
if IsNotLoaded(err) {
result, err = t.QueryChildren().All(ctx)
}
return result, err
}

再次检查进程输出,此时 不禁用字段集合,你会看到服务器仅执行了两次数据库查询:一次获取最近的 10 条 todo 项,另一次获取返回给第一条查询的所有父项:

SELECT DISTINCT `todos`.`id`, `todos`.`text`, `todos`.`created_at`, `todos`.`status`, `todos`.`priority`, `todos`.`todo_parent` FROM `todos` ORDER BY `id` DESC LIMIT 11
SELECT DISTINCT `todos`.`id`, `todos`.`text`, `todos`.`created_at`, `todos`.`status`, `todos`.`priority` FROM `todos` WHERE `todos`.`id` IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

如你在运行此示例遇到困难,请先转到 第一部分(#clone-the-code-optional),克隆代码后再运行。

字段映射

entgql.MapsTo 允许你在 Ent 模式与 GraphQL 模式之间添加自定义字段/边缘映射。当你希望在 GraphQL 模式中使用不同名称(或多名称)公开字段或边缘时,这非常有用。例如:

// 一对一映射。
field.Int("priority").
Annotations(
entgql.OrderField("PRIORITY_ORDER"),
entgql.MapsTo("priorityOrder"),
)

// 多个 GraphQL 字段可映射到同一个 Ent 字段。
field.Int("category_id").
Annotations(
entgql.MapsTo("categoryID", "category_id", "categoryX"),
)

为解析器字段收集

entgql.CollectedFor 注解允许你指定在查询某些 GraphQL 解析器字段(扩展字段)时应自动收集的字段。对依赖底层 Ent 字段值的解析器字段非常有用:

field.String("name").
Optional().
Annotations(
entgql.CollectedFor("uppercaseName"),
)
备注

若 Ent 对解析器字段与其底层 Ent 字段之间的映射不了解,并在查询中遇到未知字段,它将查询数据库中的所有字段,以确保解析器拥有所需的数据。


干得好!通过为我们的 Ent 模式定义使用自动字段集合,我们显著提高了应用中 GraphQL 查询的效率。在下一节中,我们将学习如何使 GraphQL 变更操作事务化。