【问题标题】:Is graphql schema circular reference an anti-pattern?graphql 模式循环引用是反模式吗?
【发布时间】:2019-05-20 16:41:11
【问题描述】:

graphql 架构如下:

type User {
  id: ID!
  location: Location
}

type Location {
  id: ID!
  user: User
}

现在,客户端发送graphql 查询。理论上User和Location可以无限循环引用。

我认为这是一种反模式。据我所知,graphql 和 apollo 社区中都没有中间件或方法来限制查询的嵌套深度。

这个无限嵌套深度查询会占用我系统的大量资源,例如带宽、硬件、性能。不仅是服务器端,还有客户端。

所以,如果 graphql 模式允许循环引用,那么应该有一些中间件或方法来限制查询的嵌套深度。或者,为查询添加一些约束。

也许不允许循环引用是一个更好的主意?

我更喜欢发送另一个查询并在一个查询中执行多项操作。这要简单得多。

更新

我找到了这个库:https://github.com/slicknode/graphql-query-complexity。如果 graphql 不限制循环引用。该库可以保护您的应用免受资源耗尽和 DoS 攻击。

【问题讨论】:

    标签: graphql apollo


    【解决方案1】:

    以上答案为这个问题提供了很好的理论讨论。我想添加更多在软件开发中发生的实际考虑。

    正如@daniel-rearden 指出的那样,循环引用的结果是它允许多个查询文档检索相同的数据。根据我的经验,这是一种不好的做法,因为它使 GraphQL 请求的客户端缓存更难预测且更加困难,因为开发人员必须明确指定文档以不同的结构返回相同的数据。

    此外,在单元测试中,很难为字段/属性包含对父对象的循环引用的对象生成模拟数据。 (至少在 JS/TS 中;如果有开箱即用的语言支持这一点,我很乐意在评论中听到)

    维护清晰的数据层次结构似乎是可理解和可维护模式的明确选择。如果经常需要引用字段的父项,最好构建一个单独的查询。

    旁白:说实话,如果不是因为循环引用的实际后果,我很乐意使用它们。将数据结构表示为“数学上完美的”有向图将是美丽而令人惊奇的。

    【讨论】:

    • graphql 对象的客户端缓存对于查询的根元素以外的所有内容而言本质上是困难的,无论循环引用如何。
    【解决方案2】:

    TLDR; 循环引用是非速率限制的 GraphQL API 的反模式。具有速率限制的 API 可以安全地使用它们。

    长答案: 是的,真正的循环引用是更小/更简单 API 的反模式......但是当您达到限制 API 速率的地步时,您可以使用该限制来限制“一石两鸟”。

    在其他答案之一中给出了一个完美的例子:Github 的 GraphQL API 让你请求一个存储库,它的所有者,他们的存储库,他们的所有者......无限......或者你可能会想到架构。

    如果您查看 API (https://developer.github.com/v4/object/user/),您会发现它们的结构不是直接循环的:中间有类型。例如,User 不引用Repository,它引用RepositoryConnection。现在,RepositoryConnection 确实有一个RepositoryEdge,确实有一个nodes[Repository]类型的属性...

    ...但是当您查看 API 的 实现 时:https://developer.github.com/v4/guides/resource-limitations/ 您会看到类型背后的解析器是速率限制的(即每个不超过 X 个节点询问)。这可以防止请求过多的消费者(基于广度的问题)和无限请求的消费者(基于深度的问题)。

    每当用户在 GitHub 上请求资源时,它都可以允许循环引用,因为它会将不让资源循环的负担转嫁给消费者。如果消费者失败,则查询由于速率限制而失败。

    这让负责任的用户可以请求同一用户拥有的存储库的用户......如果他们真的需要那个......只要他们不继续要求该所有者拥有的存储库存储库,归 ...

    因此,GraphQL API 有两种选择:

    • 避免循环引用(我认为这是默认的“最佳实践”)
    • 允许循环引用,但限制每次调用可以查询的节点总数,因此不可能无限循环

    如果您不想限制速率,GraphQL 使用不同类型的方法仍然可以为您提供解决方案的线索。

    假设您有用户和存储库:您需要两种类型的用户和 UserLink(或 UserEdge、UserConnection、UserSummary ......随您选择),以及存储库和 RepositoryLink。

    每当有人通过根查询请求用户时,您都会返回用户类型。但是那种用户类型不会有:

    repositories: [Repository]
    

    它会:

    repositories: [RepositoryLink]
    

    RepositoryLink 将具有与 Repository 相同的“平面”字段,但没有任何潜在的圆形对象字段。不是owner: User,而是owner: ID。

    【讨论】:

      【解决方案3】:

      视情况而定。

      请记住,相同的解决方案在某些情况下可能是好的模式,而在其他情况下可能是反模式。解决方案的价值取决于您使用它的环境。 ——Martin Fowler

      循环引用可能会带来额外的挑战,这是一个有效的观点。正如您所指出的,它们是一个潜在的安全风险,因为它们使恶意用户能够制作可能非常昂贵的查询。根据我的经验,它们还使客户团队更容易无意中过度获取数据。

      另一方面,循环引用提供了更高级别的灵活性。如果我们假设以下架构,则使用您的示例运行:

      type Query {
        user(id: ID): User
        location(id: ID): Location
      }
      
      type User {
        id: ID!
        location: Location
      }
      
      type Location {
        id: ID!
        user: User
      }
      

      很明显,我们可能会进行两个不同的查询来有效地获取相同的数据:

      {
        # query 1
        user(id: ID) {
          id
          location {
            id
          }
        }
      
        # query 2
        location(id: ID) {
          id
          user {
            id
          }
        }
      }
      

      如果您的 API 的主要使用者是一个或多个致力于同一个项目的客户团队,这可能无关紧要。您的前端需要它获取的数据具有特定的形状,并且您可以围绕这些需求设计您的架构。如果客户端总是获取用户,可以通过这种方式获取位置并且不需要该上下文之外的位置信息,那么只有一个 user 查询并从 Location 类型中省略 user 字段可能是有意义的.即使您需要 location 查询,根据客户的需要,在其上公开 user 字段可能仍然没有意义。

      另一方面,假设您的 API 被大量客户端使用。也许你支持多个平台,或者多个应用程序做不同的事情但共享相同的 API 来访问你的数据层。或者,您可能正在公开一个旨在让第三方应用程序与您的服务或产品集成的公共 API。在这些情况下,您对客户需求的概念会更加模糊。突然间,公开各种查询基础数据的方法以满足当前客户和未来客户的需求变得更加重要。对于单个客户端的 API 也可以这样说,其需求可能会随着时间的推移而变化。

      始终可以按照您的建议“扁平化”您的架构并提供额外的查询,而不是实现关系字段。但是,这样做对客户来说是否“更简单”取决于客户。最好的方法可能是让每个客户选择适合他们需要的数据结构。

      与大多数架构决策一样,需要权衡取舍,适合您的解决方案可能与其他团队不同。

      如果您确实有循环引用,所有希望都不会丢失。一些实现具有用于限制查询深度的内置控件。 GraphQL.js 没有,但是有像 graphql-depth-limit 这样的库可以做到这一点。值得指出的是,breadth 可能与 depth 一样大 - 无论您是否有循环引用,您都应该考虑使用解析列表时的最大限制,以防止客户端可能一次请求数千条记录。

      正如@DavidMaze 指出的那样,除了限制客户端查询的深度之外,您还可以使用dataloader 来降低从数据层重复获取相同记录的成本。虽然dataloader 通常用于批处理请求以解决因延迟加载关联而引起的“n+1 问题”,但它在这里也可以提供帮助。除了批处理,dataloader 还会缓存加载的记录。这意味着同一记录(在同一请求内)的后续加载不会访问数据库,而是从内存中获取。

      【讨论】:

        【解决方案4】:

        您展示的模式对于“图表”来说是相当自然的,我认为它在 GraphQL 中并不特别令人沮丧。 GitHub GraphQL API 是我在想“人们如何构建更大的 GraphQL API”时经常看到的东西,并且那里通常有对象循环:Repository 有一个 RepositoryOwner,它可以是一个 User,其中有一个repositories 列表。

        至少graphql-ruby has a control to limit nesting depth。 Apollo 显然没有此控件,但您可以构建自定义 data source 或使用 DataLoader 库来避免重复获取您已有的对象。

        【讨论】:

        • dataloader 用于N+1 查询问题。我认为这是另一个问题。就个人而言,我不喜欢循环引用。
        • 就节点生态系统而言,有graphql-depth-limit :) 它提供了一个验证规则,您可以将其直接放入您的架构中,以防止获取超过指定的查询深度
        猜你喜欢
        • 2017-01-08
        • 2012-06-22
        • 2018-03-27
        • 2011-08-04
        • 2010-11-04
        • 2023-03-11
        • 1970-01-01
        相关资源
        最近更新 更多