GraphQL 解析器应该有多懒惰?
How lazy should a GraphQL resolver be?
GraphQL 解析器应该有多懒惰?
对于某些上下文,这是我的架构的鸟瞰图:GraphQL -> Resolvers -> |Domain Boundary| -> 服务 -> 加载程序 -> 数据源 (Postgres/Redis/Elasticsearch)
在域边界之后,没有 GraphQL 特定的构造。 Services 表示域的各个维度,解析器简单地处理 SomeQueryInput,委托给适当的服务,然后用操作结果构造适当的 SomeQueryResult。所有业务规则,包括授权,都存在于域中。 Loaders 提供对具有数据源抽象的域对象的访问,有时使用 DataLoader 模式,有时不使用。
让我用一个场景来说明我的问题:假设有一个用户有一个项目,一个项目有很多文档。一个项目也有很多用户,有些用户可能无法查看所有文档。
让我们构建一个架构和一个查询来检索当前用户可以看到的所有文档。
type Query {
project(id:ID!): Project
}
type Project {
id: ID!
documents: [Document!]!
}
type Document {
id: ID!
content: String!
}
{
project(id: "cool-beans") {
documents {
id
content
}
}
}
Assume the user state is processed outside of the GraphQL context and injected into the context.
以及一些相应的基础设施代码:
const QueryResolver = {
project: (parent, args, ctx) => {
return projectService.findById({ id: args.id, viewer: ctx.user });
},
}
const ProjectResolver = {
documents: (project, args, ctx) => {
return documentService.findDocumentsByProjectId({ projectId: project.id, viewer: ctx.user })
}
}
const DocumentResolver = {
content: (parent, args, ctx) => {
let document = await documentLoader.load(parent.id);
return document.content;
}
}
const documentService => {
findDocumentsByProjectId: async ({ projectId, viewer }) {
/* return a list of document ids that the viewer is eligible to view */
return getThatData(`SELECT id FROM Documents where projectId = AND userCanViewEtc()`)
}
}
因此查询执行将进行:解析项目、获取查看者有资格查看的文档列表、解析文档并解析其内容。您可以想象 DocumentLoader 是超通用的并且不关心业务规则:它的唯一工作是尽快获取 ID 对象。
select * from Documents where id in
我的问题围绕着documentService.findDocumentsByProjectId。这里似乎有多种方法:服务,就像现在一样,有一些 GraphQL 知识融入其中:它 returns 所需对象的“存根”,知道它们将被解析为适当的对象。这加强了 GraphQL 域,但削弱了服务域。如果另一个服务调用这个服务,他们会得到一个无用的存根。
为什么不让 findDocumentsByProjectId 执行以下操作:
SELECT id, name, content FROM "Documents" JOIN permisssions, etc etc
现在服务更强大 returns 完整的业务对象,但 GraphQL 域变得更脆弱:您可以想象更复杂的场景,其中以服务不会查询的方式查询 GraphQL 模式预计,您最终会遇到损坏的查询和丢失的数据。您现在也可以只是……删除您编写的解析器,因为大多数服务器会简单地解析这些已经水合的对象。您已向 REST 端点方法退后一步。
此外,第二种方法可以利用用于特定目的的数据源索引,而 DataLoader 使用更强大的 WHERE IN 方法。
您如何平衡这些顾虑?我知道这可能是一个大问题,但这是我一直在思考的问题。领域模型是否缺少在这里可能有用的概念? DataLoader 查询是否应该比仅使用通用 ID 更具体?我很难找到一个优雅的平衡点。
现在,我的服务有两个:findDocumentStubs 和 findDocuments。第一个由解析器使用,第二个由其他内部服务使用,因为它们不能依赖 GraphQL 解析,但这也感觉不太对。即使有了DataLoader的批处理和缓存,还是感觉有人在做不必要的工作。
如果您正在编写这样的解析器
function resolveFullName ({ first_name, last_name }) => {
return `${first_name} ${last_name}`;
}
那么你可以说是做错了。
在这种情况下,您有效地做的是将域逻辑从您的域层中拉出并将其注入到您的 API 层中。如果您遵循设计数据库的良好做法,那么您的数据层 将 成为无法直接使用的规范化混乱。领域层的工作是应用您的业务规则并将该数据转换为可供应用程序其他部分使用的形状。
您写道:
You can also now just... erase the resolvers you wrote, as most servers will trivially resolve these already hydrated objects. You've taken a step back towards a REST-endpoint approach.
我不认为这是一个公平的评估。您仍在利用 GraphQL 将您的服务返回的各种域对象连接到一个图中。客户端应用程序仍然可以向您的 API 发出单个请求并获取它需要的所有数据 - REST-like 与您正在做的事情无关。
如果您关心的是优化数据库查询,那么您当然可以利用更复杂的 DataLoader 模式来实现该目标。您的服务公开的方法也可以接受一个字段数组作为参数,这会让您更 select 了解要 select 的列以及在“水合”域对象时要创建的连接. GraphQL 解析器可以很容易地从作为第四个参数传递的 GraphQLResolveInfo 对象派生这个字段数组。
(经过一些研究并综合了@Daniel 的一些建议后回答了我自己的问题)
让我试着解决您的核心问题,即获取符合某些条件的集合。您感受到的摩擦来自获取文档 ID 的集合,然后转身并进行类似的查询来解析这些文档中的其余字段。我认为一开始感觉这是重复的工作是合理的,尤其是 GraphQL 的新手:为什么不在第一次查询时急切地从数据库中获取所有需要的字段?有一个很好的理由:
假设我们急切地获取我们“知道”我们需要的文档数据:我们急切地获取 ProjectResolver 中的 id 列表,并在 DocumentResolver 中再次获取以解析文档,而是急切地获取所有内容ProjectResolver,然后让我们的 GraphQL 服务器简单地解析文档字段。这似乎工作正常,但我们已将文档解析的负担转移到项目解析器。让我们添加一个类型为 User 的字段 createdDocuments: [Document!]!.
type User {
id: ID!
name: String!
createdDocuments: [Document!]!
}
当您查询在 User 上创建的文档时会发生什么?没有任何帮助,除非我们也有 UserResolver 获取文档数据...通过允许 parent 成为其 children 的唯一数据源,我们强制所有未来 parents 做同样的事情。 这使得我们的 GraphQL API 脆弱并且难以维护和扩展。如果我们只是让 ProjectResolver 变得懒惰并且只有 return 最低限度,然后强制 DocumentResolver 完成与文档相关的所有工作,我们就没有这个问题。
那两次往返 DB 的感觉仍然很痒。您可以通过更多地使用 DataLoader 并使用缓存启动来获得 middle-path。 Facebook JS DataLoader 实现有一个名为 prime() 的方法,它允许您将数据播种到加载程序的缓存中。如果您使用一堆 DataLoader,您可能会有多个加载器在不同的上下文中引用相同的 objects。 (如果您使用 Apollo Client 进行 front-end 工作,这应该会很熟悉)。当您在一个上下文中获取一些 object 时,只需将其作为 post-processing 步骤填充到其他上下文中即可。
当您获取项目的文档列表时,继续并急切地获取内容,但使用其结果来启动 DocumentLoader。现在,当您的 DocumentResolver 启动时,它将准备好所有这些数据,但如果没有 pre-fetched 结果,它仍将是 self-sufficient。您必须根据应用程序的需要做出最佳判断。您也可以使用 Daniel Rearden 的建议并使用 GraphQLResolveInfo 来有条件地决定 pre-fetch 像这样,但确保不要陷入杂草中 micro-optimizations.
想象一个场景,您有两个 DataLoader:ProjectDocumentsLoader 和 DocumentLoader。 ProjectDocumentsLoader 可以将其结果作为 post-processing 步骤启动 DocumentLoader。我喜欢将我的 DataLoader 包装在一个轻量级的抽象中来处理 pre- 和 post-processing.
class Loader {
load(id) {
let results = await this.loader.load(id)
return this.postProcess(results);
}
postProcess(data) {
return data;
}
prime(key, value) {
this.dataLoader.prime(key, value);
}
}
class ProjectDocumentsLoader extends Loader {
constructor(context) {
this.context = context;
this.loader = new DataLoader(/* function to get collection of documents by project */);
}
postProcess(documents) {
documents.forEach(doc => this.context.documentLoader.prime(doc.id, doc));
return documents;
}
}
class DocumentLoader extends Loader {
constructor(context) {
this.context = context;
this.loader = new DataLoader(/* function to get documents by id */);
}
}
所以最后的答案:你的 GraphQL 解析器应该是超级懒惰的,可以选择 pre-fetching 只要它是一种优化而不是真相的来源。
GraphQL 解析器应该有多懒惰?
对于某些上下文,这是我的架构的鸟瞰图:GraphQL -> Resolvers -> |Domain Boundary| -> 服务 -> 加载程序 -> 数据源 (Postgres/Redis/Elasticsearch)
在域边界之后,没有 GraphQL 特定的构造。 Services 表示域的各个维度,解析器简单地处理 SomeQueryInput,委托给适当的服务,然后用操作结果构造适当的 SomeQueryResult。所有业务规则,包括授权,都存在于域中。 Loaders 提供对具有数据源抽象的域对象的访问,有时使用 DataLoader 模式,有时不使用。
让我用一个场景来说明我的问题:假设有一个用户有一个项目,一个项目有很多文档。一个项目也有很多用户,有些用户可能无法查看所有文档。
让我们构建一个架构和一个查询来检索当前用户可以看到的所有文档。
type Query {
project(id:ID!): Project
}
type Project {
id: ID!
documents: [Document!]!
}
type Document {
id: ID!
content: String!
}
{
project(id: "cool-beans") {
documents {
id
content
}
}
}
Assume the user state is processed outside of the GraphQL context and injected into the context.
以及一些相应的基础设施代码:
const QueryResolver = {
project: (parent, args, ctx) => {
return projectService.findById({ id: args.id, viewer: ctx.user });
},
}
const ProjectResolver = {
documents: (project, args, ctx) => {
return documentService.findDocumentsByProjectId({ projectId: project.id, viewer: ctx.user })
}
}
const DocumentResolver = {
content: (parent, args, ctx) => {
let document = await documentLoader.load(parent.id);
return document.content;
}
}
const documentService => {
findDocumentsByProjectId: async ({ projectId, viewer }) {
/* return a list of document ids that the viewer is eligible to view */
return getThatData(`SELECT id FROM Documents where projectId = AND userCanViewEtc()`)
}
}
因此查询执行将进行:解析项目、获取查看者有资格查看的文档列表、解析文档并解析其内容。您可以想象 DocumentLoader 是超通用的并且不关心业务规则:它的唯一工作是尽快获取 ID 对象。
select * from Documents where id in
我的问题围绕着documentService.findDocumentsByProjectId。这里似乎有多种方法:服务,就像现在一样,有一些 GraphQL 知识融入其中:它 returns 所需对象的“存根”,知道它们将被解析为适当的对象。这加强了 GraphQL 域,但削弱了服务域。如果另一个服务调用这个服务,他们会得到一个无用的存根。
为什么不让 findDocumentsByProjectId 执行以下操作:
SELECT id, name, content FROM "Documents" JOIN permisssions, etc etc
现在服务更强大 returns 完整的业务对象,但 GraphQL 域变得更脆弱:您可以想象更复杂的场景,其中以服务不会查询的方式查询 GraphQL 模式预计,您最终会遇到损坏的查询和丢失的数据。您现在也可以只是……删除您编写的解析器,因为大多数服务器会简单地解析这些已经水合的对象。您已向 REST 端点方法退后一步。
此外,第二种方法可以利用用于特定目的的数据源索引,而 DataLoader 使用更强大的 WHERE IN 方法。
您如何平衡这些顾虑?我知道这可能是一个大问题,但这是我一直在思考的问题。领域模型是否缺少在这里可能有用的概念? DataLoader 查询是否应该比仅使用通用 ID 更具体?我很难找到一个优雅的平衡点。
现在,我的服务有两个:findDocumentStubs 和 findDocuments。第一个由解析器使用,第二个由其他内部服务使用,因为它们不能依赖 GraphQL 解析,但这也感觉不太对。即使有了DataLoader的批处理和缓存,还是感觉有人在做不必要的工作。
如果您正在编写这样的解析器
function resolveFullName ({ first_name, last_name }) => {
return `${first_name} ${last_name}`;
}
那么你可以说是做错了。
在这种情况下,您有效地做的是将域逻辑从您的域层中拉出并将其注入到您的 API 层中。如果您遵循设计数据库的良好做法,那么您的数据层 将 成为无法直接使用的规范化混乱。领域层的工作是应用您的业务规则并将该数据转换为可供应用程序其他部分使用的形状。
您写道:
You can also now just... erase the resolvers you wrote, as most servers will trivially resolve these already hydrated objects. You've taken a step back towards a REST-endpoint approach.
我不认为这是一个公平的评估。您仍在利用 GraphQL 将您的服务返回的各种域对象连接到一个图中。客户端应用程序仍然可以向您的 API 发出单个请求并获取它需要的所有数据 - REST-like 与您正在做的事情无关。
如果您关心的是优化数据库查询,那么您当然可以利用更复杂的 DataLoader 模式来实现该目标。您的服务公开的方法也可以接受一个字段数组作为参数,这会让您更 select 了解要 select 的列以及在“水合”域对象时要创建的连接. GraphQL 解析器可以很容易地从作为第四个参数传递的 GraphQLResolveInfo 对象派生这个字段数组。
(经过一些研究并综合了@Daniel 的一些建议后回答了我自己的问题)
让我试着解决您的核心问题,即获取符合某些条件的集合。您感受到的摩擦来自获取文档 ID 的集合,然后转身并进行类似的查询来解析这些文档中的其余字段。我认为一开始感觉这是重复的工作是合理的,尤其是 GraphQL 的新手:为什么不在第一次查询时急切地从数据库中获取所有需要的字段?有一个很好的理由:
假设我们急切地获取我们“知道”我们需要的文档数据:我们急切地获取 ProjectResolver 中的 id 列表,并在 DocumentResolver 中再次获取以解析文档,而是急切地获取所有内容ProjectResolver,然后让我们的 GraphQL 服务器简单地解析文档字段。这似乎工作正常,但我们已将文档解析的负担转移到项目解析器。让我们添加一个类型为 User 的字段 createdDocuments: [Document!]!.
type User {
id: ID!
name: String!
createdDocuments: [Document!]!
}
当您查询在 User 上创建的文档时会发生什么?没有任何帮助,除非我们也有 UserResolver 获取文档数据...通过允许 parent 成为其 children 的唯一数据源,我们强制所有未来 parents 做同样的事情。 这使得我们的 GraphQL API 脆弱并且难以维护和扩展。如果我们只是让 ProjectResolver 变得懒惰并且只有 return 最低限度,然后强制 DocumentResolver 完成与文档相关的所有工作,我们就没有这个问题。
那两次往返 DB 的感觉仍然很痒。您可以通过更多地使用 DataLoader 并使用缓存启动来获得 middle-path。 Facebook JS DataLoader 实现有一个名为 prime() 的方法,它允许您将数据播种到加载程序的缓存中。如果您使用一堆 DataLoader,您可能会有多个加载器在不同的上下文中引用相同的 objects。 (如果您使用 Apollo Client 进行 front-end 工作,这应该会很熟悉)。当您在一个上下文中获取一些 object 时,只需将其作为 post-processing 步骤填充到其他上下文中即可。
当您获取项目的文档列表时,继续并急切地获取内容,但使用其结果来启动 DocumentLoader。现在,当您的 DocumentResolver 启动时,它将准备好所有这些数据,但如果没有 pre-fetched 结果,它仍将是 self-sufficient。您必须根据应用程序的需要做出最佳判断。您也可以使用 Daniel Rearden 的建议并使用 GraphQLResolveInfo 来有条件地决定 pre-fetch 像这样,但确保不要陷入杂草中 micro-optimizations.
想象一个场景,您有两个 DataLoader:ProjectDocumentsLoader 和 DocumentLoader。 ProjectDocumentsLoader 可以将其结果作为 post-processing 步骤启动 DocumentLoader。我喜欢将我的 DataLoader 包装在一个轻量级的抽象中来处理 pre- 和 post-processing.
class Loader {
load(id) {
let results = await this.loader.load(id)
return this.postProcess(results);
}
postProcess(data) {
return data;
}
prime(key, value) {
this.dataLoader.prime(key, value);
}
}
class ProjectDocumentsLoader extends Loader {
constructor(context) {
this.context = context;
this.loader = new DataLoader(/* function to get collection of documents by project */);
}
postProcess(documents) {
documents.forEach(doc => this.context.documentLoader.prime(doc.id, doc));
return documents;
}
}
class DocumentLoader extends Loader {
constructor(context) {
this.context = context;
this.loader = new DataLoader(/* function to get documents by id */);
}
}
所以最后的答案:你的 GraphQL 解析器应该是超级懒惰的,可以选择 pre-fetching 只要它是一种优化而不是真相的来源。