GraphQL 解析器应该有多懒?

Posted

技术标签:

【中文标题】GraphQL 解析器应该有多懒?【英文标题】:How lazy should a GraphQL resolver be? 【发布时间】:2021-01-18 12:44:00 【问题描述】:

在某些情况下,这是我架构的鸟瞰图:GraphQL -> 解析器 -> |域边界| -> 服务 -> 加载器 -> 数据源(Postgres/Redis/Elasticsearch)

在域边界之后,没有 GraphQL 特定的构造。 Services 代表了领域的各个维度,解析器简单地处理 SomeQueryInput,委托给适当的服务,然后用操作结果构造一个适当的 SomeQueryResult。所有业务规则,包括授权,都存在于域中。 加载器通过对数据源的抽象提供对域对象的访问,有时使用 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 = $1 AND userCanViewEtc()`)
  

因此查询执行将是:解析项目、获取查看者有资格查看的文档列表、解析文档并解析其内容。您可以想象 DocumentLoader 非常通用且不关心业务规则:它唯一的工作就是尽可能快地获取 ID 对象。

select * from Documents where id in $1

我的问题围绕 documentService.findDocumentsByProjectId。这里似乎有多种方法:现在的服务包含一些 GraphQL 知识:它返回所需对象的“存根”,知道它们将被解析为适当的对象。这加强了 GraphQL 领域,但削弱了服务领域。如果另一个服务调用此服务,他们会得到一个无用的存根。

为什么不让 findDocumentsByProjectId 执行以下操作:

SELECT id, name, content FROM "Documents" JOIN permisssions, etc etc

现在服务更强大了,可以返回整个业务对象,但是 GraphQL 领域变得更加脆弱:你可以想象更复杂的场景,其中 GraphQL 模式以服务不期望的方式查询,你最终会得到损坏的查询和丢失的数据。您现在也可以...删除您编写的解析器,因为大多数服务器将轻松解析这些已经水合的对象。您已向 REST 端点方法退了一步。

此外,第二种方法可以利用用于特定目的的数据源索引,而 DataLoader 使用更暴力的 WHERE IN 方法。

您如何平衡这些担忧?我知道这可能是一个大问题,但这是我一直在思考的问题。域模型是否缺少在这里可能有用的概念? DataLoader 查询是否应该比仅使用通用 ID 更具体?我很难找到一个优雅的平衡。

现在,我的服务同时具有:findDocumentStubs 和 findDocuments。第一个由解析器使用,第二个由其他内部服务使用,因为它们不能依赖 GraphQL 解析,但这也不太正确。即使使用 DataLoader 批处理和缓存,仍然感觉有人在做不必要的工作。

【问题讨论】:

【参考方案1】:

如果你正在编写这样的解析器

function resolveFullName ( first_name, last_name ) => 
  return `$first_name $last_name`;

那么你可能做错了。

在这种情况下,您实际上正在做的是将域逻辑从域层中提取出来并将其注入到 API 层中。如果您在设计数据库时遵循良好实践,那么您的数据层变成无法直接使用的标准化混乱。应用业务规则并将数据转换为可供应用程序其他部分使用的形状是您领域层的工作。

你写道:

您现在也可以...删除您编写的解析器,因为大多数服务器将轻松解析这些已经水合的对象。您已向 REST 端点方法退了一步。

我不认为这是一个公平的评估。您仍在利用 GraphQL 将您的服务返回的各种域对象连接到一个图形中。客户端应用程序仍然可以向您的 API 发出单个请求并获取它需要的所有数据——您正在做的事情与 REST 完全不同。

如果您关心的是优化数据库查询,那么您当然可以利用更复杂的 DataLoader 模式来实现该目标。您的服务公开的方法还可以接受字段数组作为参数,这将使您在“水合”您的域对象时更有选择性地选择哪些列以及要进行哪些连接。 GraphQL 解析器可以轻松地从作为其第四个参数传递的 GraphQLResolveInfo 对象中派生出这个字段数组。

【讨论】:

我没有做你在第一段中描述的事情,所以我会跳过它。对于倒数第二段,我的意思更多的是解析器越不懒惰,它的子解析器就越依赖于父解析器是否正确。似乎最灵活的 graphql API 将是每个解析器都知道如何获取域对象并提取所需字段的 API,但是您获得的粒度越细,优化查询似乎就越困难。我会玩一下我的数据获取层。 也许你可以用一个更详细的例子来更新你的问题【参考方案2】:

(在一些研究和综合@Daniel 的一些建议后回答我自己的问题)

让我尝试解决您的核心问题,即获取符合某些标准的集合。您感觉到的摩擦来自于获取文档 ID 的集合,然后转身进行类似的查询以解析这些文档上的其余字段。我认为一开始觉得这是重复的工作是合理的,尤其是刚接触 GraphQL:你为什么不急切地在第一次查询时从数据库中获取所有需要的字段?有一个很好的理由:

假设我们急切地获取我们“知道”我们需要的文档数据:我们急切地获取 ProjectResolver 中的所有内容,而不是在 ProjectResolver 中获取 id 列表,然后在 DocumentResolver 中再次获取来解析文档,然后让我们的 GraphQL 服务器轻松解析 Document 字段。这似乎工作正常,但我们已将文档解析的负担转移到项目解析器。让我们添加一个带有字段 createdDocuments 的 User 类型:[Document!]!。

type User 
  id: ID!
  name: String!
  createdDocuments: [Document!]!

当您在用户上查询创建的文档时会发生什么?没有任何帮助,除非我们也让 UserResolver 获取文档数据...通过允许父母成为他们孩子的唯一数据来源,我们迫使所有未来的父母都这样做。这使得我们的 GraphQL API 脆弱且难以维护和扩展。如果我们只是让 ProjectResolver 变得懒惰,只返回最低限度,然后强制 DocumentResolver 完成与 Documents 相关的所有工作,我们就不会有这个问题。

从那两次往返 DB 中仍然有痒的感觉。您可以通过更多地使用 DataLoaders 并使用缓存启动来采取中间路径。 Facebook JS DataLoader 实现有一个名为 prime() 的方法,它允许您将数据播种到加载程序的缓存中。如果您使用一堆 DataLoader,您可能会有多个加载器在不同的上下文中引用相同的对象。 (如果您使用 Apollo Client 进行前端工作,这应该感觉很熟悉)。当您在一个上下文中获取某个对象时,只需为其他上下文准备好它作为后处理步骤。

当您获取项目的文档列表时,请继续并急切地获取内容,但使用其结果来启动 DocumentLoader。现在,当您的 DocumentResolver 启动时,它会为它准备好所有这些数据,但如果没有预取的结果,它仍然是自给自足的。您必须根据应用程序的需求在何时执行此操作时做出最佳判断。您也可以使用 Daniel Rearden 的建议并使用 GraphQLResolveInfo 来有条件地决定像这样预取,但请确保不要在进行微优化时陷入困境。

假设您有两个 DataLoader:ProjectDocumentsLoader 和 DocumentLoader。 ProjectDocumentsLoader 可以将其结果作为后处理步骤启动 DocumentLoader。我喜欢将我的 DataLoaders 包装在一个轻量级的抽象中,以处理预处理和后处理。


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 解析器应该是超级懒惰的,只要它是一种优化而不是事实的来源,就可以选择预取。

【讨论】:

以上是关于GraphQL 解析器应该有多懒?的主要内容,如果未能解决你的问题,请参考以下文章

NodeJs 中的 GraphQl - 对象类型的解析器

带有 Spring 的 GraphQL-java - 解析器 vd datafetcher

在graphql中为用户类型中的字段编写字段级解析器

GraphQL:从兄弟解析器访问另一个解析器/字段输出

graphql-yoga - GraphQL 解析器参数在哪里定义和记录?

GraphQL 解析器问题