跳至主要内容

步骤类

步骤详细说明了执行 GraphQL 请求时需要执行的特定操作或转换。每个步骤都是特定步骤类的实例,在规划字段时生成。每个步骤可能依赖于 0 个或多个其他步骤,并且通过这些依赖关系最终形成一个有向无环图,我们将其称为执行计划。因此,步骤是执行计划的构建块。

您可以使用一系列适度的标准步骤类;但如果这些还不够,建议您编写自己的步骤类(或从 npm 或类似的平台下载第三方步骤类)。

步骤类扩展了ExecutableStep类,唯一需要定义的方法是execute,但您也可以实现各种生命周期方法,或者添加您自己的方法,以便更轻松地编写计划解析器

/** XKCD-221 step class @ref https://xkcd.com/221/ */
class GetRandomNumberStep extends ExecutableStep {
execute({ count }) {
return new Array(count).fill(4); // chosen by fair dice roll.
// guaranteed to be random.
}
}

function getRandomNumber() {
return new GetRandomNumberStep();
}
对自定义字段/方法使用前缀。

如果您在步骤类中添加了任何自定义字段或方法,我们建议您使用您的姓名首字母或组织名称作为前缀,以避免命名冲突。

不要子类化步骤。

不要子类化步骤,这会让您感到非常困惑。始终直接从ExecutableStep继承。

内置方法

您的自定义步骤类将可以访问作为ExecutableStep一部分的所有内置方法。

addDependency

当您的步骤需要另一个步骤的值才能执行(大多数步骤都是这种情况!)时,它必须通过this.addDependency($otherStep)方法添加依赖项。此方法将返回一个数字,该数字是execute值元组中代表此步骤的索引。

通常在构造函数中执行此操作,但也可以在其他阶段执行,例如在优化阶段,步骤的后代可能会要求它执行其他工作,而该工作可能依赖于另一个步骤。

入门指南中,我们看到了AddStep步骤类的构造函数添加了两个依赖项。

class AddStep extends ExecutableStep {
constructor($a, $b) {
super();
this.addDependency($a); // Returns 0
this.addDependency($b); // Returns 1
}
步骤是短暂的,永远不要存储对步骤的引用。

您绝不能在步骤类中直接(或间接)存储对另一个步骤的引用。在规划过程中,步骤会频繁地出现和消失——由于去重、优化或树状抖动生命周期事件而被删除。引用一个不再存在的步骤可能会导致您的程序出现非常意外的行为和/或崩溃。

在极不可能的情况下,您需要引用另一个步骤,但它不是依赖项,请使用它的id——您可以在以后查找与该id关联的步骤;如果它存在,它可能与您记忆中的步骤不同,但它应该起到相同的作用。但是,它可能由于树状抖动而被删除——如果这会导致问题,那么该步骤可能应该成为依赖项?

addUnaryDependency

有时您希望确保您的步骤类依赖的一个或多个步骤在运行时将恰好有一个值;为此,您可以使用this.addUnaryDependency($step)而不是this.addDependency($step)。这断言给定的依赖项是一个一元步骤(一个常规步骤,系统已确定它将始终代表一个值),并且主要在远程服务请求的参数需要对批次中的所有条目相同的情况下有用;通常,这将是排序、分页和访问控制的情况。

谨慎使用。

如果给定的$step不是一元步骤,则this.addUnaryDependency($step)将在规划期间引发错误,因此您在使用它时应非常小心。如有疑问,请改用this.addDependency($step)

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

通常,addUnaryDependency旨在用于参数及其派生项;它也可以与context派生值一起使用,但在发生变异时存在复杂性,因为context是可变的(而输入值不是)。

getDep

传入依赖项的编号(0 表示第一个依赖项,1 表示第二个依赖项,依此类推),Grafast 将返回相应的步骤。这应该只在 optimize 阶段之前或期间使用。

例如,在上面的 AddStep 示例中,我们可能会有

const $a = this.getDep(0);
const $b = this.getDep(1);

getDepDeep

实验性

getDep 相似,但会跳过 __ItemStep 和类似的内置中间步骤,以尝试获取原始来源。通常在您有一个步骤表示集合中的条目(例如数据库“行”)并且您想要获取表示整个集合的步骤(例如数据库 SELECT 语句)时很有用。

toString

步骤的漂亮格式。

console.log("$a = " + $a.toString());

toStringMeta

您可以覆盖此方法以在 toString 方法中添加更多数据(三角括号之间出现的数据)。

生命周期方法

execute

execute(details: ExecutionDetails): PromiseOrDirect<GrafastResultsList>
// These are simplified types
interface ExecutionDetails {
count: number;
values: [...ExecutionValue[]];
indexMap<T>(callback: (i: number) => T): ReadonlyArray<T>;
indexForEach(callback: (i: number) => any): void;
extra: ExecutionExtra;
}

type ExecutionValue<TData> =
| { at(i: number): TData; isBatch: true; entries: ReadonlyArray<TData> }
| { at(i: number): TData; isBatch: false; value: TData };

type GrafastResultsList<T> = ReadonlyArray<PromiseOrDirect<T>>;

execute 是您的步骤类必须定义的唯一方法,它有非常严格的规则。

它传递一个参数,即“执行细节”,这是一个包含以下内容的对象

  • count — 正在处理的批次的大小(以及必须返回的列表的长度)
  • values — “值元组”,一个 n 元组(具有 n 个条目的元组),其中 n 是步骤的依赖项数量。元组中的每个条目都将是一个“执行值”,包含与相应依赖项相关的数据
  • indexMap(callback) - 一个辅助函数,通过对批次中的每个索引(从 0count-1)调用 callback 来构建一个长度为 count 的数组;等效于 Array.from({ length: count }, (_, i) => callback(i))
  • indexForEach(callback) - 一个辅助函数,对批次中的每个索引(从 0count-1)调用 callback,但不返回任何内容
  • extra — 目前处于实验阶段,请自行承担使用风险(并查看源代码以获取文档)

“执行值” dep 是一个包含给定依赖项数据的对象。它要么是“批次”值 (dep.isBatch === true),在这种情况下 dep.entries 将是一个包含 count 个条目的数组(其顺序很重要),要么是“一元”值 (dep.isBatch === false),在这种情况下 dep.value 将是批次中所有条目中此依赖项的公共值。无论哪种方式,dep.at(i) 都将返回与批次中第 i 个条目相对应的此依赖项的值 (dep.at(i) 等效于 dep.isBatch ? dep.entries[i] : dep.value)。

执行必须返回一个大小为 count 的列表(或指向列表的 Promise),其中此列表中的第 i 个条目对应于“值元组”中每个 depdep.at(i) 值。execute 的结果可能是一个 Promise 也可能不是,结果列表中的每个条目可能是一个 Promise 也可能不是。

如果您的步骤没有依赖项

如果步骤没有依赖项,则 values 将是一个 0 元组(空元组),但这并不意味着批次为空或大小为 1,count 可以是任何正整数。因此,建议您在绝大多数情况下使用 indexMap 来生成结果

return indexMap((i) => 42);
信息

您可能想知道为什么values输入是一个执行值的元组,而不是一个元组列表。原因归结为效率,通过使用执行值的元组,Grafast只需要构建一个新的数组(元组),并且可以在该数组中插入之前执行步骤的结果,而无需修改。如果它提供一个元组列表,那么它将需要构建N+1个新数组,其中N是正在处理的值的数量,这很容易达到数千个。

提示

如果您希望其中一个条目抛出错误,但其他条目不应该抛出错误,那么实现此目的的一种简单方法是将结果列表中的相应条目设置为Promise.reject(new Error(...))。即使您没有对其他任何值使用承诺,甚至即使您的execute方法没有标记为async,您也可以这样做。如果您已将步骤类标记为isSyncAndSafe = true,则**不能**这样做。

示例

入门指南中,我们构建了一个AddStep步骤类,它将两个数字加在一起。它的execute方法看起来像这样

  execute({ indexMap, values: [aDep, bDep] }) {
return indexMap((i) => {
const a = aDep.at(i);
const b = bDep.at(i);
return a + b;
});
}

想象一下,在运行时,Grafast需要对三(count = 3)对值执行此操作:[1, 2][3, 4][5, 6]。通过aDep.get(i)访问的$a的值将是135;通过bDep.get(i)访问的$b的值将是246。然后,execute 方法按相同的顺序返回相同数量的结果:[3, 7, 11]

此方法是可选的。

stream(details: StreamDetails): PromiseOrDirect<GrafastResultStreamList>
interface StreamDetails extends ExecutionDetails {
streamOptions: {
initialCount: number;
};
}

type GrafastResultStreamList<T> = ReadonlyArray<
PromiseOrDirect<AsyncIterable<PromiseOrDirect<T>> | null>
>;

待办事项:记录流。(它类似于执行,但它返回一个异步迭代器列表。)

去重

此方法是可选的。

deduplicate(
peers: readonly ExecutableStep[]
): readonly ExecutableStep[]

在字段完全规划后,Grafast将在每个新步骤上调用此方法,当草案执行计划中存在多个步骤具有相同的步骤类和相同的依赖项时。这些“对等方”(包括步骤本身)将传递到去重方法中,并且此方法应返回与当前步骤等效(或可以廉价地变得等效)的对等方列表。

为了使您的步骤类永远不会被去重,要么不要实现此方法,要么简单地return [];

您不应该在此方法期间修改您的同伴或自身,而是使用deduplicatedWith方法来应用副作用。

deduplicatedWith

此方法是可选的。

deduplicatedWith(
replacement: ExecutableStep
): void

如果Grafast确定此特定步骤实例应由其同伴之一替换(感谢来自上面deduplicate的结果),Grafast将在被替换的步骤上调用deduplicatedWith,并将被替换的步骤作为第一个参数传递。这使您的步骤有机会将任何必要的信息传递给同伴,以使同伴等效。

信息

很少需要此功能,因此让我们来处理一个假设。

假设步骤$select1表示SQL查询SELECT id, name FROM users,步骤$select2表示SELECT id, avatar_url FROM users

让我们进一步假设我们已经优化了我们的SQL处理步骤类,以便$select1$select2都从它们的deduplicate方法返回[$select1, $select2](因为它们可以“廉价地”变得等效)。

假设Grafast选择保留$select1并“去重”(删除)$select2,Grafast将调用$select2.deduplicateWith($select1)。这将使$select2有机会通知$select1,为了完全等效,它还必须选择avatar_url

在这种情况下,在去重结束时,只有$select1会保留,它将表示SQL查询SELECT id, name, avatar_url FROM users

optimize

此方法是可选的。

optimize(
options: { stream: StepStreamOptions | null }
): ExecutableStep

此方法在优化生命周期事件期间的每个步骤上都会被调用。它使步骤有机会请求其祖先执行更多工作,以及/或者用另一个步骤(新步骤或旧步骤)替换自身。如果它不想被替换,它可以简单地返回自身:return this;

此方法解锁了Grafast效率改进的很大一部分。以下是它可以用于的一些常见用例

Optimize: inlining

optimize通常用于将此步骤的要求“内联”到祖先中,然后(可选地)用一个简单的accessremapKeys步骤替换自身。这减少了请求需要执行的异步任务数量,并且可以实现更高效的数据获取。

Optimize: planning-time only steps

optimize 的另一个用例是通过用父步骤或其他步骤替换计划时间步骤来使它们“消失”。

loadMany 步骤通过 LoadedRecordStep 实例表示每个记录,该实例可用于 .get(attr) 获取命名属性。然后存储此引用,在优化时,LoadedRecordStep 可以告诉 LoadStep 请求此属性(这样 loadMany 回调就不需要执行等效于 SELECT * 的操作 - 它可以更具选择性)。但是,由于 LoadedRecordStep 没有运行时行为(只有计划时行为),因此它可以在 optimize 期间简单地用其父步骤(通常是 __ItemStep)替换自身。

内置的 each 步骤使用 optimize 将自身替换为尽可能的底层列表。

优化:简化

另一个用例是简化。

例如,表示 access(access(access($a, 'b'), 'c'), 'd') 的步骤可以简化为 access($a, ['b', 'c', 'd']),从而减少操作计划中的步骤数量。

类似地,first(list([$a, $b])) 可以简化为 $a

完成

此方法是可选的。

finalize(): void

在完成生命周期事件期间,此方法会在每个步骤上调用。它使每个步骤有机会为执行做好准备,执行任何只需要执行一次的操作。处理数据库的步骤可能会预编译其 SQL,转换对象的步骤可能会构建一个优化的函数来执行此操作,还有许多其他操作可以使用此步骤来执行。

危险

步骤在 finalize() 步骤结束时调用 super.finalize() 至关重要。

finalize() {
// ... your code here ...

super.finalize();
}
信息

重要的是,在此步骤期间,步骤应该只关注自身问题,而不应尝试与其祖先或后代通信 - 它们可能不是它所记住的步骤,因为它们可能在 optimize 期间被替换掉了!如果步骤需要与其祖先通信,它应该使用 optimize 方法来执行此操作。

其他属性

id

每个步骤由 Grafast 赋予一个唯一的 id。这个 id 可以是字符串、数字或符号 - 将其视为不透明的。

注意

目前该值是 number,但 Grafast 可能会在次要版本中将其更改为字符串或符号,因此您不应该依赖其数据类型。但是,您可以依赖 String(id) 在整个操作计划中是唯一的。

hasSideEffects

如果该步骤具有副作用(即导致变异),则将其设置为 true - 如果为 true,则 Grafast 不会在树形抖动期间删除此步骤,并将确保即使该步骤在任何输出中似乎没有被使用,也会执行该步骤。

isSyncAndSafe

危险

这是一个非常危险的优化,只有在您 100% 确定您知道自己在做什么时才使用它!

将此设置为 true 是一个性能优化,但它附带了严格的规则;我们不会测试您是否遵守这些规则(因为这会抵消性能提升),但如果您违反了这些规则,行为将是未定义的(并且,基本上,模式可能不再符合 GraphQL 规范)。

除非以下条件成立,否则不要将此设置为 true

  • execute 方法必须是常规(非异步)函数
  • execute 方法绝不能返回 Promise
  • execute 返回的列表中的值绝不能包含 Promise
  • 调用 execute 的结果在 step.hasSideEffects 执行后不应有所不同(即它应该是纯的,只依赖于其依赖项,并且不使用任何外部状态)

如果 execute 方法需要抛出异常,这是可以接受的。

这种优化适用于大多数内置计划,并允许引擎在无需解析任何 Promise 的情况下执行,从而节省宝贵的事件循环时间片。

isOptimized

在步骤被优化后,此属性被设置为 true

allowMultipleOptimizations

如果您的计划的 optimize 方法可以被调用第二次,请将此属性设置为 true。

您的依赖项可能会更改类!

在这种情况下,您的依赖项(或其依赖项)很可能不是您期望的(例如,PgSelectSingleStep 可能会由于被优化而变成 AccessStep)。这就是为什么它默认情况下未启用,以及它很少需要的原因。

metaKey

实验性

您可以选择设置此属性以指示要用于传递给 executemeta 对象的键(通常用于缓存)。为了使其对您的步骤实例唯一,在构造函数中调用 super() 后,将其设置为 this.metaKey = this.id;。如果您想在给定类的所有步骤之间共享相同的 meta 对象,该类可以将 metaKey 设置为类的名称。您甚至可以将其设置为多个步骤类(步骤类“族”)之间的共享值,如果这样做有意义的话。默认情况下,不会设置 metaKey,因此您的类将没有 meta 对象。

灵感

loadManyloadOne 标准步骤利用此键来优化值缓存,您可能想查看它们以获取更多灵感。

步骤函数

按照惯例,我们总是定义一个函数来构造我们类的实例,这样我们就可以在计划解析函数中看不到new调用或冗余的Step文本。

这个函数通常以相应的步骤类命名,但首字母小写,并且省略了Step后缀,例如AddStep将变为add

function add($a, $b) {
return new AddStep($a, $b);
}

这样做有很多原因,一个简单的原因是使计划代码更容易阅读:我们不会在计划解析函数中看到new调用,也不会看到冗余的Step措辞,从而提高信噪比。更重要的是,这层间接的小层允许我们在传递给类构造函数之前进行一些小的操作,并使 API 更具未来可扩展性,因为我们可以让函数在将来返回不同的内容,而无需重构模式中的计划。请记住,这种成本只在计划时产生(通常是缓存的,并且可以重复用于类似的未来请求),并且每个字段只计划一次,因此额外函数调用的开销可以忽略不计。