跳至主要内容

Grafast 简介

信息

本 Grafast 简介假设您对 GraphQL 有基本了解,包括解析器的概念。我们强烈建议您阅读 GraphQL 简介,尤其是 GraphQL 执行,然后再阅读本文档。

GraphQL 规范描述了如何执行 GraphQL 操作,使用“解析器”逐层解析数据。但需要注意的是,规范开头有一句话:

符合要求 [...] 可以通过 [...] 任何方式实现,只要感知到的结果是等效的。
https://spec.graphql.net.cn/draft/#sec-Conforming-Algorithms

解析器相对容易理解,但如果在实现时过于简单,很容易导致严重的性能问题。 DataLoader 是解决“N+1 问题”的建议方法之一,但这只是简单的 GraphQL 架构可能面临的最严重性能问题——还有其他问题,例如服务器端过度获取和获取不足以及相关问题,这些问题随着你的架构和操作变得更加复杂而不断累积。

Grafast 从一开始就被设计为消除这些问题以及更多问题,同时为开发者提供友好的 API。为此,除了支持传统字段的解析器外,Grafast 还倾向于采用一种规划策略,该策略对传入操作进行整体理解,并释放了以前无法实现的重大优化潜力,除非付出巨大的努力。

请注意,Grafast 不绑定到任何特定的存储或业务逻辑层——任何有效的 GraphQL 架构都可以用 Grafast 实现,而 Grafast 架构可以查询 Node.js 可以查询的任何数据源、服务或业务逻辑。

信息

目前 Grafast 是用 TypeScript 实现的,但我们正在努力制定规范,希望将 Grafast 的执行方法扩展到其他编程语言。如果你有兴趣在 JavaScript 以外的语言中实现 Grafast 的执行算法,请联系我们!

计划解析器

这只是一个概述,完整的文档请参见 计划解析器

在传统的 GraphQL 架构中,每个字段都有一个解析器。在 Grafast 架构中,虽然仍然支持解析器,但建议你使用 计划 解析器。这些计划解析器通常是小型函数,就像常规解析器应该那样,但它们不是在执行期间被多次调用并处理具体的运行时值,而是在规划时只被调用一次,它们构建和操作步骤,这些步骤是 执行计划 的构建块,该计划详细说明了满足 GraphQL 请求所需的所有操作。

假设我们有以下 GraphQL 架构

type Query {
currentUser: User
}
type User {
name: String!
friends: [User!]!
}

graphql-js 中,你可能会有这些解析器

const resolvers = {
Query: {
async currentUser(_, args, context) {
return context.userLoader.load(context.currentUserId);
},
},
User: {
name(user) {
return user.full_name;
},
async friends(user, args, context) {
const friendships = await context.friendshipsByUserIdLoader.load(user.id);
const friends = await Promise.all(
friendships.map((friendship) =>
context.userLoader.load(friendship.friend_id),
),
);
return friends;
},
},
};

在 Grafast 中,我们使用 计划解析器,它可能看起来像这样

const planResolvers = {
Query: {
currentUser() {
return userById(context().get("currentUserId"));
},
},
User: {
name($user) {
return $user.get("full_name");
},
friends($user) {
const $friendships = friendshipsByUserId($user.get("id"));
const $friends = each($friendships, ($friendship) =>
userById($friendship.get("friend_id")),
);
return $friends;
},
},
};

正如你所见,逻辑的形状非常相似,但 Grafast 计划解析器是同步的。Grafast 在两个阶段运行:计划(同步)和执行(异步);计划解析器在计划阶段被调用。

查看工作示例

如果你想探索上面两个代码块,并在包含其依赖项的上下文中查看它们,请查看 "用户和朋友"示例

计划解析器的任务不是检索数据,而是详细说明检索数据的步骤。计划解析器无法访问任何运行时数据,它们必须描述如何处理任意未来的数据。例如,User.friends Grafast 计划解析器不能像解析器示例中那样使用 map 函数循环遍历运行时数据(因为还没有任何数据可以循环),而是使用 each 步骤 描述计划,详细说明如何处理稍后提供的每个项目。

美元约定

按照惯例,当变量代表 Grafast 步骤时,变量将以 $(美元符号)开头命名。

步骤

步骤是 Grafast 计划的基本构建块;它们是步骤类的实例,通过计划解析器中的函数调用构建。步骤类描述如何执行特定操作,并通过**生命周期方法**帮助计划如何更有效地执行操作。Grafast 为常见需求提供优化的内置步骤;通常您可以只使用这些步骤来开始,但随着您进一步优化模式,您将需要构建自己的步骤类,就像您在基于解析器的 GraphQL API 中构建 DataLoaders 一样。

如果我们要对上面的 Grafast 模式发出以下查询

{
currentUser {
name
friends {
name
}
}
}

​Grafast 将为该操作构建一个操作计划。对于上面的查询,一个计划图表示此操作计划的执行部分是

%%{init: {'themeVariables': { 'fontSize': '12px'}}}%% flowchart TD classDef path fill:#eee,stroke:#000,color:#000 classDef plan fill:#fff,stroke-width:1px,color:#000 classDef itemplan fill:#fff,stroke-width:2px,color:#000 classDef unbatchedplan fill:#dff,stroke-width:1px,color:#000 classDef sideeffectplan fill:#fcc,stroke-width:2px,color:#000 classDef bucket fill:#f6f6f6,color:#000,stroke-width:2px,text-align:left %% plan dependencies Access7{{"Access[7∈0]<br />ᐸ3.currentUserIdᐳ"}}:::plan __Value3["__Value[3∈0]<br />ᐸcontextᐳ"]:::plan __Value3 --> Access7 Load8[["Load[8∈0]<br />ᐸuserByIdᐳ"]]:::plan Access7 --> Load8 Load11[["Load[11∈0]<br />ᐸfriendshipsByUserIdᐳ"]]:::plan Access7 --> Load11 __Value0["__Value[0∈0]"]:::plan __Value5["__Value[5∈0]<br />ᐸrootValueᐳ"]:::plan __Item15[/"__Item[15∈3]<br />ᐸ11ᐳ"\]:::itemplan Load11 ==> __Item15 Access17{{"Access[17∈3]<br />ᐸ15.friend_idᐳ"}}:::plan __Item15 --> Access17 Load18[["Load[18∈3]<br />ᐸuserByIdᐳ"]]:::plan Access17 --> Load18 %% define steps classDef bucket0 stroke:#696969 class Bucket0,__Value0,__Value3,__Value5,Access7,Load8,Load11 bucket0 classDef bucket1 stroke:#00bfff class Bucket1 bucket1 classDef bucket3 stroke:#ffa500 class Bucket3,__Item15,Access17,Load18 bucket3 classDef bucket4 stroke:#0000ff class Bucket4 bucket4

此图中的每个节点都代表操作计划中的一个步骤,箭头显示数据如何在这些步骤之间流动。

计划可以重复用于多个请求

当再次看到相同的操作时,其现有计划可以(通常)重复使用;这就是为什么为了从 Grafast 获得最佳性能,您应该使用静态 GraphQL 文档并在运行时传递变量。

批量执行

大多数步骤的主要关注点是执行。在 Grafast 中,所有执行都是批量的,因此在 GraphQL 查询或变异期间,操作计划中的每个节点最多执行一次。这是与传统 GraphQL 执行相比的主要区别之一;使用传统的解析器,处理是在逐层、逐项的方式进行的,需要使用诸如 DataLoader 之类的变通方法来帮助减少 N+1 问题的实例。

当需要执行操作计划时,Grafast 会自动填充名称以 __ 开头的步骤(例如上下文和变量值),然后开始执行每个步骤,直到所有依赖项都准备就绪,并继续执行,直到所有步骤都完成。

在计划时,步骤可以通过 const depId = this.addDependency($otherStep); 在另一个步骤上添加依赖项。此 depId 是步骤在执行时用于检索关联值的 值元组 中的索引。

当步骤执行时,其 execute 方法将传递 执行详细信息,其中包括

  • count — 要执行的批次的大小
  • values值元组,步骤添加的每个依赖项的值
  • indexMap(callback) — 通过对批次中的每个索引 i(从 0count-1)调用 callback(i) 来返回数组的方法

execute 方法必须返回一个长度为 count 的列表(或对列表的承诺),其中此列表中的每个条目都与 values 中的对应条目相关联 — 这对于任何以前编写过 DataLoader 的人来说都应该至少有点熟悉。

当计划开始执行时,它总是以 1 的批次大小 (count) 开始;但是,许多事情可能会影响后续步骤的批次大小 — 例如,在处理列表中的项目时,批次必须增长以包含每个项目(通过 __Item 步骤)。Grafast 在内部为您处理所有这些复杂性,因此您通常不需要考虑它们。

一元步骤

“一元步骤”是一个常规步骤,系统已确定该步骤始终代表一个值。代表请求级数据的系统步骤(例如上下文、变量和参数值)始终是一元步骤,并且 Grafast 会自动确定哪些其他步骤也是一元步骤。

有时您需要确保您的步骤类依赖的一个或多个步骤在运行时将只有一个值;为此,您可以使用 this.addUnaryDependency($step) 而不是 this.addDependency($step)。这确保了给定的依赖项始终是一元步骤,并且主要在远程服务请求的参数需要对批次中的所有条目都相同的情况下有用;通常,对于排序、分页和访问控制而言,情况将是如此。例如,如果您要从每个朋友那里检索前 N 个宠物,您可能希望向 SQL 查询添加 limit N — 通过将 N 添加为一元依赖项,您可以保证每个执行都只有一个 N 值,并且可以相应地构建 SQL 查询(请参阅下面的示例中的 limitSQL)。

SQL 示例

这是一个步骤类,它从 SQL 数据库中的给定表中检索与给定列匹配的记录(例如:WHERE columnName = $columnValue)。可选地,您可以请求限制为前 $first 个结果。

export class RecordsByColumnStep extends ExecutableStep {
constructor(tableName, columnName, $columnValue) {
super();
this.tableName = tableName;
this.columnName = columnName;
this.columnValueDepIdx = this.addDependency($columnValue);
}

setFirst($first) {
this.firstDepId = this.addUnaryDependency($first);
}

async execute({ indexMap, values }) {
// Retrieve the values for the `$columnValue` dependency
const columnValueDep = values[this.columnValueDepIdx];

// We may or may not have added a `$first` limit:
const firstDep =
this.firstDepId !== undefined ? values[this.firstDepId] : undefined;

// firstDep, if it exists, is definitely a unary dep (!firstDep.isBatch), so
// we can retrieve its value directly:
const first = firstDep ? parseInt(firstDep.value, 10) : null;

// Create a `LIMIT` clause in our SQL if the user specified a `$first` limit:
const limitSQL = Number.isFinite(first) ? `limit ${first}` : ``;

// Create placeholders for each entry in our batch in the SQL:
const placeholders = indexMap(() => "?");
// The value from `$columnValue` for each index `i` in the batch
const columnValues = indexMap((i) => columnValueDep.at(i));

// Build the SQL query to execute:
const sql = `\
select *
from ${this.tableName}
where ${this.columnName} in (${placeholders.join(", ")})
${limitSQL}
`;

// Execute the SQL query once for all values in the batch:
const rows = await executeSQL(sql, columnValues);

// Figure out which rows relate to which batched inputs:
return indexMap((i) =>
rows.filter((row) => row[this.columnName] === columnValues[i]),
);
}
}

function petsByOwnerId($ownerId) {
return new RecordsByColumnStep("pets", "owner_id", $ownerId);
}

请注意,此步骤的执行方法中只有一个 await 调用,并且我们已经知道该步骤每个请求只执行一次;将此单个异步操作与使用 DataLoader 时需要创建的 promise 数量进行比较。

不仅仅是数据库!

execute 方法只是 JavaScript;它可以与 Node.js 本身可以与之通信的任何数据源进行通信。虽然示例显示了 SQL,但您可以用 fetch() 或任何其他任意 JavaScript 函数替换 executeSQL() 调用来实现您的目标。

简化示例

上面的代码是为了成为一个简单的示例而编写的;虽然它有效(查看使用它的完整解决方案),但它远没有它可能那么好——例如,它不跟踪访问的列,因此只检索这些列,也不使用生命周期方法来确定更优化的执行方式。

(另一件事:它直接将 tableNamecolumnName 值传递到 SQL 中——在这些值周围使用 escapeIdentifier() 调用会更安全。)

步骤生命周期

您在上面看到的执行计划图是计划的最终形式,它已经经历了许多中间状态才能达到这种最优形式,这得益于 Grafast 的生命周期方法。

信息

有关了解计划图的更多信息,请参阅计划图

有关上述模式的完整工作实现,请参阅“用户和朋友”示例

这只是一个概述,有关完整文档,请参阅生命周期

所有计划生命周期方法都是可选的,由于 Grafast 计划的始终批处理性质,您无需使用任何方法即可获得良好的性能(性能通常与可靠使用 DataLoader 相当)。但是,如果您利用生命周期方法,您的性能可以从“良好”提升到✨惊人🚀。

Grafast 设计的一大优势在于,您无需从一开始就构建这些优化;您可以在后期实现它们,使您的模式更快,而无需更改您的业务逻辑您的计划解析器!

作为非常概括的概述

  • 一旦一个字段被计划,我们就会去重每个新步骤
  • 一旦执行计划完成,我们就会优化每个步骤
  • 最后,我们完成每个步骤

去重

去重允许一个步骤指示其同行(由 Grafast 定义)中的哪些与它等效。然后,如果可能,这些同行之一可以替换新步骤,从而减少计划中的步骤数量(并允许在计划树中更深层次的代码路径更优化)。

优化

优化有两个目的。

第一个目的是优化允许一个步骤与其祖先“对话”,通常是为了告诉它们将需要哪些数据,以便它们可以主动获取这些数据。这不会改变祖先的观察行为(例如,您不应该使用它来对祖先应用过滤器——这可能会与 GraphQL 规范相矛盾!),但它可以用来要求祖先获取更多数据。

第二个目的是优化可以用来用替代(可能更优化)的步骤替换正在优化的步骤。这可能会导致多个步骤从计划图中删除,因为“树摇动”。当步骤告诉祖先获取更多数据并且步骤随后可以将自身替换为简单的“访问”步骤时,这可能会被使用。它也可以用来处理计划专用步骤,这些步骤在计划时有意义,但在执行时没有行为。

在上面的“朋友”示例中,这被用来将 DataLoader 样式的select * from ...查询更改为更优化的select id, full_name from ...查询。在更高级的计划中(例如通过@dataplan/pg提供的计划),优化可以走得更远,例如将它的数据需求内联到父级并用简单的“重新映射键”函数替换自身。

Finalize

Finalize 是在步骤上调用的最后一个方法,它让步骤有机会执行一些通常只需要执行一次的操作;例如,一个向远程服务器发出 GraphQL 查询的步骤可能会利用这个机会来构建一次 GraphQL 查询字符串。将元组转换为对象的步骤可能会构建一个优化的函数来执行此操作。

进一步优化

​Grafast 不仅帮助您的模式执行更少、更高效的步骤,它还优化了数据确定后的输出方式。这意味着,即使不更改现有的 GraphQL 模式(即不采用计划),通过 Grafast 而不是 graphql-js 运行它也应该会带来适度的加速,尤其是在需要将结果输出为字符串(例如通过网络套接字/HTTP)的情况下。

说服了吗?

如果您还没有被说服,请通过 Graphile Discord 联系我们,我们很乐意改进此页面和 Grafast 本身!

如果您被说服了,为什么不继续使用下面的导航按钮呢?