Help us learn about your current experience with the documentation. Take the survey.

后端 GraphQL API 指南

本文档包含工程师实现 GitLab GraphQL API 后端的样式和技术指南。

与 REST API 的关系

参见 GraphQL 和 REST API 部分

版本控制

GraphQL API 是 无版本的

多版本兼容性

尽管 GraphQL API 是无版本的,但我们仍需关注 更新中的向后兼容性,以及它可能导致的故障,例如 某些用户的侧边栏未加载

缓解措施

为了降低故障风险,在 GitLab 自托管和 GitLab 专用版中,可以使用 @gl_introduced 指令来指示后端节点是在哪个 GitLab 版本中引入的。这样,当查询命中较旧的后端版本时,该未来节点会从查询中被剥离。

这无法解决 GitLab.com 上的问题。新的 GraphQL 字段仍需由后端部署到 GitLab.com,然后前端才能使用。

你可以在任何字段上使用 @gl_introduced 指令,例如:

查询 响应
fragment otherFieldsWithFuture on Namespace {
  webUrl
  otherFutureField @gl_introduced(version: "99.9.9")
}

query namespaceWithFutureFields {
  futureField @gl_introduced(version: "99.9.9")
  namespace(fullPath: "gitlab-org") {
    name
    futureField @gl_introduced(version: "99.9.9")
    ...otherFieldsWithFuture
  }
}
{
  "data": {
    "futureField": null,
    "namespace": {
      "name": "Gitlab Org",
      "futureField": null,
      "webUrl": "http://gdk.test:3000/groups/gitlab-org",
      "otherFutureField": null
    }
  }
}

你不应将指令用于:

  • 参数:可执行指令不支持参数。
  • 片段:相反,应在片段节点中使用该指令。
  • 单个未来字段,无论是在查询中还是在对象中:
查询 响应
  query fetchData {
    futureField @gl_introduced(version: "99.9.9")
  }
  {
    "errors": [
      {
        "graphQLErrors": [
          {
            "message": "字段必须有选择(查询 'fetchData' 返回 Query 但没有选择项。您是否 meant 'fetchData { ... }'?)",
            "locations": [
              {
                "line": 1,
                "column": 1
              }
            ],
            "path": [
              "query fetchData"
            ],
            "extensions": {
              "code": "selectionMismatch",
              "nodeName": "query 'fetchData'",
              "typeName": "Query"
            }
          }
        ],
        "clientErrors": [],
        "networkError": null,
        "message": "字段必须有选择(查询 'fetchData' 返回 Query 但没有选择项。您是否 meant 'fetchData { ... }'?)",
        "stack": "<REDACTED>"
      }
    ]
  }
  query fetchData {
    futureField @gl_introduced(version: "99.9.9") {
      id
    }
  }
  {
    "errors": [
      {
        "graphQLErrors": [
          {
            "message": "字段必须有选择(查询 'fetchData' 返回 Query 但没有选择项。您是否 meant 'fetchData { ... }'?)",
            "locations": [
              {
                "line": 1,
                "column": 1
              }
            ],
            "path": [
              "query fetchData"
            ],
            "extensions": {
              "code": "selectionMismatch",
              "nodeName": "query 'fetchData'",
              "typeName": "Query"
            }
          }
        ],
        "clientErrors": [],
        "networkError": null,
        "message": "字段必须有选择(查询 'fetchData' 返回 Query 但没有选择项。您是否 meant 'fetchData { ... }'?)",
        "stack": "<REDACTED>"
      }
    ]
  }
  query fetchData {
    project(fullPath: "gitlab-org/gitlab") {
      futureField @gl_introduced(version: "99.9.9")
    }
  }
{
  "errors": [
    {
      "graphQLErrors": [
        {
          "message": "字段必须有选择(字段'project'返回Project类型但没有选择项。您是否 meant 'project { ... }'?)",
          "locations": [
            {
              "line": 2,
              "column": 3
            }
          ],
          "path": [
            "query fetchData",
            "project"
          ],
          "extensions": {
            "code": "selectionMismatch",
            "nodeName": "field 'project'",
            "typeName": "Project"
          }
        }
      ],
      "clientErrors": [],
      "networkError": null,
      "message": "字段必须有选择(字段'project'返回Project类型但没有选择项。您是否 meant 'project { ... }'?)",
      "stack": "<REDACTED>"
    }
  ]
}
非空字段

当后台不存在时,未来字段会回退到null。这意味着当非空字段带有@gl_introduced指令时,前端仍需进行空值检查。

在GitLab学习GraphQL

希望学习GitLab GraphQL的后端工程师应结合阅读GraphQL Ruby gem指南。这些指南教授该gem的功能,其中信息通常不会在此处重复。

若想了解GraphQL本身的设计和功能,请阅读graphql.org上的指南,这是GraphQL规范中信息的简明版本。

深入探讨

2019年3月,Nick Thomas举办了一场深度解析(仅限GitLab团队成员:https://gitlab.com/gitlab-org/create-stage/issues/1),主题是GitLab GraphQL API,旨在与未来可能在该代码库此部分工作的人分享领域特定知识。你可以找到 YouTube上的录像,以及Google幻灯片PDF中的幻灯片。自那时起具体细节已有所变化,但它仍可作为良好的入门介绍。

GitLab如何实现GraphQL

我们使用由Robert Mosolgo编写的GraphQL Ruby gem。此外,我们还订阅了GraphQL Pro。详情请参阅GraphQL Pro订阅

所有GraphQL查询都指向单个端点(app/controllers/graphql_controller.rb#execute),该端点作为API端点暴露于/api/graphql

GraphiQL

GraphiQL是一个交互式的GraphQL API探索器,你可以在其中尝试现有的查询。在任何GitLab环境中均可通过https://<你的-gitlab站点.com>/-/graphql-explorer访问它。例如,GitLab.com的实例。

审查含GraphQL变更的合并请求

GraphQL框架有一些需要注意的具体陷阱,需要领域专业知识来确保满足它们。

如果你被要求审查修改任何GraphQL文件或添加端点的合并请求,请查看我们的GraphQL审查指南

读取GraphQL日志

请参阅读取GraphQL日志指南,了解如何检查GraphQL请求的日志并监控GraphQL查询的性能。

该页面包含如下提示:

  • 查看已弃用字段的用法。
  • 判断查询是否来自我们的前端。

身份验证

身份验证通过GraphqlController进行,目前这使用与Rails应用程序相同的身份验证方式。因此会话可以共享。

也可以向查询字符串添加private_token,或添加HTTP_PRIVATE_TOKEN头。

限制

多个限制适用于GraphQL API,其中一些可由开发者覆盖。

最大页大小

默认情况下,连接每页最多只能返回 app/graphql/gitlab_schema.rb中定义的最大记录数。

开发者在定义连接时可指定自定义最大页大小

最大复杂度

复杂度在我们的面向客户端API页面中有解释。

Fields 默认会向查询的复杂度得分添加 1,但开发者在定义字段时可以指定自定义复杂度

查询的复杂度得分本身也可以被查询到(详见获取开始)。

请求超时

请求在30秒后超时。

限制最大字段调用次数

在某些情况下,你可能希望阻止在多个父节点上评估特定字段,因为这会导致N+1查询问题且没有最佳解决方案。这应被视为最后的手段,仅在考虑了诸如预加载关联的前瞻使用批处理等方法后才使用。

例如:

# 这种用法是预期的。
query {
  project {
    environments
  }
}

# 这种用法是不预期的。

# 它会导致N+1查询问题。EnvironmentsResolver无法使用GraphQL批处理加载器来支持GraphQL分页。
query {
  projects {
    nodes {
      environments
    }
  }
}

为防止这种情况,你可以对字段使用 Gitlab::Graphql::Limit::FieldCallCount 扩展:

# 这允许最多1次调用 `environments` 字段。如果该字段在超过一个节点上被评估,
# 则会抛出错误。
field :environments do
        extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
      end

或者你可以在解析器类中应用扩展:

module Resolvers
  class EnvironmentsResolver < BaseResolver
    extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
    # ...
  end
end

当你添加此限制时,请确保受影响字段的 description 也相应更新。例如,

field :environments,
      description: '项目的环境。此字段在任何单个请求中只能为一个项目解析。'

破坏性变更

GitLab GraphQL API是无版本的(versionless),这意味着开发者必须熟悉我们的弃用和移除流程

破坏性变更是指:

  • 删除或重命名字段、参数、枚举值或变更操作。

  • 改变参数的类型或类型名称。参数的类型由客户端在使用变量时声明,若发生更改,使用旧类型名的查询会被API拒绝。

  • 改变字段或枚举值的标量类型,导致其序列化为JSON的方式发生变化。例如,从JSON字符串变为JSON数字,或改变字符串的格式方式。若改为另一个对象类型,只要该对象的所有标量类型字段仍以相同方式序列化,则可能被允许。

  • 提高复杂度或解析器中的复杂度乘数。

  • 将字段从非可空(null: false)改为可空(null: true),详情见可空字段

  • 将参数从可选(required: false)改为必选(required: true)。

  • 改变连接的最大页面大小

  • 降低查询复杂度和深度的全局限制。

  • 其他任何可能导致之前允许的查询命中限制的情况。

有关如何弃用项的内容,请参见弃用模式项部分。

破坏性变更豁免

请参阅GraphQL API破坏性变更豁免文档

全局ID

GitLab GraphQL API使用全局ID(即:"gid://gitlab/MyObject/123"),从不使用数据库主键ID。

全局ID是一种约定,用于客户端库中的缓存和获取。

另请参阅:

我们有一个自定义标量类型(Types::GlobalIDType),当值为 GlobalID 时,应将其用作输入和输出参数的类型。使用此类型而非 ID 的好处有:

  • 验证该值是否为 GlobalID

  • 在传递给用户代码前将其解析为 GlobalID

  • 可按对象的类型进行参数化(例如,GlobalIDType[Project]),提供更好的验证和安全性。

考虑对所有新参数和结果类型使用此类型。请记住,你可以通过关注点(concern)或超类型(supertype)对此类型进行参数化,如果你想接受更广泛的对象范围(例如 GlobalIDType[Issuable] 对比 GlobalIDType[Issue])。

优化

默认情况下,GraphQL 容易引入 N+1 问题,除非你主动尝试最小化它们。

为了稳定性和可扩展性,你必须确保我们的查询不会受到 N+1 性能问题的影响。

以下是帮助优化 GraphQL 代码的工具列表:

如何在开发中发现 N+1 问题

可以通过以下方式在功能开发期间发现 N+1 问题:

  • 在执行返回数据集合的 GraphQL 查询时,跟踪 development.logBullet 可能会有所帮助。
  • 如果在 GitLab UI 中执行查询,观察性能条
  • 添加一个请求规范,断言该功能不存在(或有限)N+1 问题。

字段

类型

我们采用代码优先的模式,并在 Ruby 中声明所有内容的类型。

例如,app/graphql/types/project_type.rb

graphql_name 'Project'

field :full_path, GraphQL::Types::ID, null: true
field :name, GraphQL::Types::String, null: true

我们给每个类型一个名称(在本例中为 Project)。

full_pathname标量 GraphQL 类型。
full_path 是一个 GraphQL::Types::ID(参见何时使用 GraphQL::Types::ID)。
name 是一个常规的 GraphQL::Types::String 类型。
你也可以为标量数据类型声明自定义 GraphQL 数据类型(例如 TimeType)。

当通过 GraphQL API 暴露模型时,我们在 app/graphql/types 中创建一个新的类型。

在暴露类型的属性时,确保定义内的逻辑尽可能简单。相反,考虑将任何逻辑移入展示器

class Types::MergeRequestType < BaseObject
  present_using MergeRequestPresenter

name 'MergeRequest'
end

可以使用现有的展示器,但也可以专门为 GraphQL 创建新的展示器。

展示器使用由字段解析的对象和上下文初始化。

可空字段

GraphQL 允许字段是“可空的”或“非可空的”。前者意味着可以返回 null 而不是指定类型的值。通常来说,你应该倾向于使用可空字段而非非可空字段,原因如下:

  • 数据经常会在必需和可选之间切换
  • 即使没有成为可选的前景,它在查询时也可能不可用
  • 例如,blob 的 content 可能需要从 Gitaly 中查找
  • 如果 content 是可空的,我们可以返回部分响应,而不是让整个查询失败
  • 使用无版本模式的架构时,从非可空字段改为可空字段很困难

仅当字段是必需的、未来不太可能变为可选的、并且易于计算时,才应使用非可空字段。一个例子是 id 字段。

非可空的 GraphQL 模式字段是一个对象类型后跟感叹号 !。以下是来自 gitlab_schema.graphql 文件的示例:

  id: ProjectID!

以下是 GraphQL 非可空数组的示例:


errors: [String!]!

进一步阅读:

暴露全局 ID

在与 GitLab 使用全局 ID 保持一致的情况下,当你暴露它们时,始终将数据库主键 ID 转换为全局 ID。

所有名为 id 的字段会自动转换为对象的 Global ID。

名为 id 以外的字段需要手动转换。我们可以使用 Gitlab::GlobalID.build,或者对混入了 GlobalID::Identification 模块的对象调用 #to_global_id 来实现。

Types::Notes::DiscussionType 为例:

field :reply_id, Types::GlobalIDType[Discussion]

def reply_id
  Gitlab::GlobalID.build(object, id: object.reply_id)
end

何时使用 GraphQL::Types::ID

当我们使用 GraphQL::Types::ID 时,该字段会变成 GraphQL 的 ID 类型,序列化为 JSON 字符串。然而,ID 对客户端有特殊意义。GraphQL 规范 指出:

ID 标量类型表示唯一标识符,常用于重新获取对象或作为缓存的键。

GraphQL 规范未明确 ID 唯一性的范围。在 GitLab 中,我们决定 ID 必须至少按类型名唯一。类型名是我们的 Types:: 类的 graphql_name,例如 ProjectIssue

据此:

  • Project.fullPath 应该是一个 ID,因为在整个 API 中不会有其他具有相同 fullPathProject,且该字段也是标识符。

  • Issue.iid 不应该是一个 ID,因为在整个 API 中可能有多个 Issue 具有相同的 iid。如果将其视为 ID,当客户端缓存了来自不同项目的 Issue 时会出现问题。

  • Project.id 通常符合条件,因为只有一个 Project 具有该 ID 值——不过我们对数据库 ID 值使用 Global ID types 而非 ID 类型,因此我们会将其类型设为 Global ID。

下表总结了这一点:

字段用途 使用 GraphQL::Types::ID
全路径 check-circle
数据库 ID dotted-circle
IID dotted-circle

markdown_field

markdown_field 是一个辅助方法,包装 field,应始终用于返回已渲染 Markdown 的字段。

此辅助方法使用现有的 MarkupHelper 渲染模型的 Markdown 字段,并使 GraphQL 查询的上下文可用于辅助方法。

让辅助方法访问上下文是为了遮蔽指向当前用户无权查看资源的链接。

由于渲染 HTML 可能导致查询,这些字段的复杂度会比默认值高 5。

Markdown 字段辅助方法可以这样使用:

markdown_field :note_html, null: false

这将生成一个字段,渲染模型的 note Markdown 字段。可以通过添加 method: 参数来覆盖此行为:

markdown_field :body_html, null: false, method: :note

该字段默认会有如下描述:

note 的 GitLab 风格 Markdown 渲染结果

可以通过传递 description: 参数来覆盖此描述。

连接类型

有关具体实现,请参阅 Pagination implementation

GraphQL 使用 基于游标的分页 来暴露项目集合。这为客户端提供了很大灵活性,同时也允许后端使用不同的分页模型。

要暴露资源集合,我们可以使用连接类型。它会用默认的分页字段包装数组。例如,针对项目管道(pipelines)的查询可能如下所示:

query($project_path: ID!) {
  project(fullPath: $project_path) {
    pipelines(first: 2) {
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
      edges {
        cursor
        node {
          id
          status
        }
      }
    }
  }
}

这将返回项目的头两个管道及其相关分页信息,按降序 ID 排序。返回的数据如下所示:

{
  "data": {
    "project": {
      "pipelines": {
        "pageInfo": {
          "hasNextPage": true,
          "hasPreviousPage": false
        },
        "edges": [
          {
            "cursor": "Nzc=",
            "node": {
              "id": "gid://gitlab/Pipeline/77",
              "status": "FAILED"
            }
          },
          {
            "cursor": "Njc=",
            "node": {
              "id": "gid://gitlab/Pipeline/67",
              "status": "FAILED"
            }
          }
        ]
      }
    }
  }
}

要获取下一页,可以传递最后一个已知元素的游标:

query($project_path: ID!) {
  project(fullPath: $project_path) {
    pipelines(first: 2, after: "Njc=") {
      pageInfo {
        hasNextPage
        hasPreviousPage
      }
      edges {
        cursor
        node {
          id
          status
        }
      }
    }
  }
}

为确保我们获得一致的排序结果,我们在主键上添加降序排列。主键通常是 id,因此我们在关联的末尾添加 order(id: :desc)。底层表必须存在主键。

快捷字段

有时实现一个“快捷字段”(当没有参数传递时,解析器返回集合中的第一个元素)可能看起来很简单。这些“快捷字段”不被鼓励,因为它们会增加维护成本。它们需要与规范字段保持同步,如果规范字段发生变化,也需要被弃用或修改。除非有充分的理由,否则应使用框架提供的功能。

例如,不要使用 latest_pipeline,而是使用 pipelines(last: 1)

页面大小限制

默认情况下,API 每页最多返回在 app/graphql/gitlab_schema.rb 中定义的最大记录数,这也是客户端未提供限制参数(first:last:)时每页返回的默认记录数。可以使用 max_page_size 参数为连接指定不同的页面大小限制。

最好更改前端客户端或产品需求,以避免每页需要大量记录,而不是提高 max_page_size,因为默认值是为了确保 GraphQL API 保持高性能而设置的。

例如:

field :tags,
  Types::ContainerRegistry::ContainerRepositoryTagType.connection_type,
  null: true,
  description: '容器仓库的标签',
  max_page_size: 20

字段复杂度

GitLab GraphQL API 使用“复杂度”评分来限制执行过于复杂的查询。有关复杂度的详细说明,请参阅我们的客户端文档中关于该主题的内容。复杂度限制在 app/graphql/gitlab_schema.rb 中定义。

默认情况下,字段会将 1 添加到查询的复杂度得分中。这可以通过为字段提供自定义 complexity来覆盖。

开发人员应为那些导致服务器执行更多工作才能返回数据的字段指定更高的复杂度。对于大多数情况下可以几乎无需工作就能返回的数据(例如 idtitle),可以将复杂度设为 0

calls_gitaly

具有在解析时可能执行Gitaly调用的潜在可能的字段,必须在定义时通过向 field 传递 calls_gitaly: true 来标记。

例如:

field :blob, type: Types::Snippets::BlobType,
      description: '片段 Blob',
      null: false,
      calls_gitaly: true

这会将字段的 复杂度得分 增加 1

如果解析器调用了 Gitaly,可以用 BaseResolver.calls_gitaly! 进行标注。这将把 calls_gitaly: true 传递给任何使用此解析器的字段。

例如:

class BranchResolver < BaseResolver
  type ::Types::BranchType, null: true
  calls_gitaly!

  argument name: ::GraphQL::Types::String, required: true

  def resolve(name:)
    object.branch(name)
  end
end

当我们使用它时,任何使用 BranchResolver 的字段都会拥有正确的 calls_gitaly: 值。

公开类型的权限

要公开当前用户对资源的权限,你可以调用 expose_permissions 并传入代表资源权限的单独类型。

例如:

module Types
  class MergeRequestType < BaseObject
    expose_permissions Types::MergeRequestPermissionsType
  end
end

权限类型继承自 BasePermissionType,其中包含一些辅助方法,允许将权限暴露为不可为空的布尔值:

class MergeRequestPermissionsType < BasePermissionType
  graphql_name 'MergeRequestPermissions'

  present_using MergeRequestPresenter

  abilities :admin_merge_request, :update_merge_request, :create_note

  ability_field :resolve_note,
                description: '表示用户可以在合并请求中解决讨论。'
  permission_field :push_to_source_branch, method: :can_push_to_source_branch?
end
  • permission_field:其作用与 graphql-rubyfield 方法相同,但设置了默认描述和类型并将它们设为非空。这些选项仍然可以通过将其作为参数添加来覆盖。

  • ability_field:暴露我们在策略中定义的能力。其行为与permission_field相同,并且可以覆盖相同的参数。

  • abilities:允许同时暴露我们在策略中定义的多个能力。这些字段的类型必须都是不可为空的布尔值,并带有默认描述。

功能开关

您可以在GraphQL中实现功能开关来切换:

  • 字段的返回值。
  • 参数或变更的行为。

这可以通过解析器、类型甚至模型方法来完成,具体取决于您的偏好和情况。

建议在功能开关后面时也将该条目标记为实验项。这向公共GraphQL API的使用者表明该字段尚未准备使用。 您还可以随时更改或删除实验性条目,而无需将其弃用。当移除标志后,“发布”模式项通过移除其experiment属性使其公开。

功能开关控制项的描述

当使用功能开关来切换模式项的值或行为时,该项目的description必须:

  • 说明值或行为可由功能开关切换。
  • 命名功能开关。
  • 说明当功能开关禁用时(或在更合适的情况下启用时)字段返回的内容或行为。

功能开关使用示例

功能开关控制的字段

字段的值根据功能开关状态切换。常见用法是在功能开关禁用时返回null

field :foo, GraphQL::Types::String, null: true,
      experiment: { milestone: '10.0' },
      description: '一些测试字段。如果`my_feature_flag`功能开关被禁用则返回`null`。'

def foo
  object.foo if Feature.enabled?(:my_feature_flag, object)
end

功能开关控制的参数

参数可根据功能开关状态被忽略或修改其值。常见用法是在功能开关禁用时忽略该参数:

argument :foo, type: GraphQL::Types::String, required: false,
         experiment: { milestone: '10.0' },
         description: '一些测试参数。如果`my_feature_flag`功能开关被禁用则被忽略。'

def resolve(args)
  args.delete(:foo) unless Feature.enabled?(:my_feature_flag, object)
  # ...
end

功能开关控制的变更

因功能开关状态无法执行的变更会被视为用户无关的错误。错误会在顶层返回:

description '变更对象。如果`my_feature_flag`功能开关被禁用则不会变更对象。'

def resolve(id: )
  object = authorized_find!(id: id)

  raise_resource_not_available_error! '`my_feature_flag`功能开关被禁用。' \
      if Feature.disabled?(:my_feature_flag, object)
  # ...
end

弃用模式项

GitLab GraphQL API是无版本的,这意味着我们每次更改都会维护与旧版本API的向后兼容性。

与其删除字段、参数、枚举值变更,不如将其标记为已弃用

然后可以在未来的版本中按照GitLab弃用流程移除模式的已弃用部分。

要在GraphQL中弃用一个模式项:

  1. 为该项目创建一个弃用问题
  2. 在模式中将该项标记为已弃用。

另请参阅:

创建弃用问题

每个GraphQL弃用都应创建一个弃用问题,使用Deprecations问题模板来跟踪其弃用和移除。

为弃用问题应用以下两个标签:

  • ~GraphQL
  • ~deprecation

将项标记为已弃用

字段、参数、枚举值和变更通过deprecated属性标记为已弃用。该属性的值为包含以下内容的Hash

  • reason - 弃用的原因。
  • milestone - 该字段被弃用的里程碑。

示例:

field :token, GraphQL::Types::String, null: true,
      deprecated: { reason: '基于令牌的登录已被移除', milestone: '10.0' },
      description: '用于登录的令牌。'

被弃用事物的原始 description 应该被保留,并且不应该更新以提及弃用。相反,reason 会附加到 description 中。

弃用原因样式指南

当弃用的原因是由于字段、参数或枚举值被替换时,reason 必须指明替换项。例如,以下是替换字段的 reason

Use `otherFieldName`

示例:

field :designs, ::Types::DesignManagement::DesignCollectionType, null: true,
      deprecated: { reason: 'Use `designCollection`', milestone: '10.0' },
      description: 'The designs associated with this issue.',
module Types
  class TodoStateEnum < BaseEnum
    value 'pending', deprecated: { reason: 'Use PENDING', milestone: '10.0' }
    value 'done', deprecated: { reason: 'Use DONE', milestone: '10.0' }
    value 'PENDING', value: 'pending'
    value 'DONE', value: 'done'
  end
end

如果被弃用的字段、参数或枚举值没有被替换,应给出描述性的弃用 reason

弃用全局 ID

我们使用 rails/globalid gem 来生成和解析 Global IDs,因此它们与模型名称耦合。当我们重命名模型时,其 Global ID 会发生变化。

如果 Global ID 在架构中的任何位置用作 参数 类型,那么 Global ID 的变化通常会构成破坏性变更。

为了继续支持使用旧 Global ID 参数的客户端,我们在 Gitlab::GlobalId::Deprecations 中添加了弃用。

如果 Global ID 仅 作为字段暴露,则我们不需要弃用它。我们认为 Global ID 在字段中表达方式的变化是向后兼容的。我们期望客户端不会解析这些值:它们应该被视为不透明的令牌,其中的任何结构都是偶然的,不应依赖。

示例场景

此示例场景基于这个 合并请求

一个名为 PrometheusService 的模型将被重命名为 Integrations::Prometheus。旧模型名用于创建一个 Global ID 类型,该类型用作突变的参数:

# Mutations::UpdatePrometheus:

argument :id, Types::GlobalIDType[::PrometheusService],
              required: true,
              description: "The ID of the integration to mutate."

客户端通过传递一个看起来像 "gid://gitlab/PrometheusService/1" 的 Global ID 字符串(命名为 PrometheusServiceID)作为 input.id 参数来调用突变:

mutation updatePrometheus($id: PrometheusServiceID!, $active: Boolean!) {
  prometheusIntegrationUpdate(input: { id: $id, active: $active }) {
    errors
    integration {
      active
    }
  }
}

我们将模型重命名为 Integrations::Prometheus,然后用新名称更新代码库。当我们更新突变时,将重命名的模型传递给 Types::GlobalIDType[]

# Mutations::UpdatePrometheus:

argument :id, Types::GlobalIDType[::Integrations::Prometheus],
              required: true,
              description: "The ID of the integration to mutate."

这会导致突变发生破坏性变更,因为 API 现在会拒绝传递 id 参数为 "gid://gitlab/PrometheusService/1" 的客户端,或者在查询签名中指定参数类型为 PrometheusServiceID

为了允许客户端继续无更改地与此突变交互,编辑 Gitlab::GlobalId::Deprecations 中的 DEPRECATIONS 常量,并向数组添加新的 Deprecation

DEPRECATIONS = [
  Gitlab::Graphql::DeprecationsBase::NameDeprecation.new(old_name: 'PrometheusService', new_name: 'Integrations::Prometheus', milestone: '14.0')
].freeze

之后按照常规的 弃用流程。若要稍后移除对前一种参数样式的支持,请删除 Deprecation

DEPRECATIONS = [].freeze

在弃用期间,API 接受以下任一格式的参数值:

  • "gid://gitlab/PrometheusService/1"
  • "gid://gitlab/Integrations::Prometheus/1"

API 还接受查询签名中参数的以下类型:

  • PrometheusServiceID
  • IntegrationsPrometheusID

尽管使用旧类型(本例中的 PrometheusServiceID)的查询被认为是有效的且可由 API 执行,但验证工具认为它们无效。它们被认为无效是因为我们正在弃用在 @deprecated 指令 之外使用自定义方法,因此验证器不知道支持情况。

文档提到旧的 Global ID 样式现已弃用。

标记模式项为实验项

你可以将 GraphQL 模式项(字段、参数、枚举值和变更)标记为实验项

被标记为实验项的项目不受弃用流程限制,可以在任何时间无通知地移除。当项目处于变化状态且未准备好公开使用时,将其标记为实验项。

仅对新项目标记为实验项。切勿对现有项目标记为实验项,因为它们已经是公开的。

要标记模式项为实验项,请使用 experiment: 关键字。你必须提供引入该实验项的 milestone:

例如:

field :token, GraphQL::Types::String, null: true,
      experiment: { milestone: '10.0' },
      description: '登录令牌。'

同样,你也可以通过更新变更在 app/graphql/types/mutation_type.rb 中的挂载位置来标记整个变更作为实验项:

mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, experiment: { milestone: '15.10' }

实验性的 GraphQL 项目是 GitLab 的自定义功能,利用了 GraphQL 弃用机制。实验项在 GraphQL 模式中显示为已弃用。与所有已弃用的模式项一样,你可以在交互式 GraphQL 探索器(GraphiQL)中测试实验字段。但是请注意,GraphiQL 自动完成编辑器不会建议已弃用的字段。

该项目在我们的生成 GraphQL 文档及其 GraphQL 模式描述中显示为 experiment

枚举

GitLab GraphQL 枚举定义在 app/graphql/types 中。定义新枚举时,适用以下规则:

  • 值必须为大写。
  • 类名必须以字符串 Enum 结尾。
  • graphql_name 不能包含字符串 Enum

例如:

module Types
  class TrafficLightStateEnum < BaseEnum
    graphql_name 'TrafficLightState'
    description '交通灯的状态'

value 'RED', description: '司机必须停车。'
    value 'YELLOW', description: '司机应在安全时停车。'
    value 'GREEN', description: '司机可以启动或继续行驶。'
  end
end

如果枚举用于 Ruby 中的类属性,且该属性不是大写字符串,则可以提供一个 value: 选项来适应大写的值。

在以下示例中:

  • GraphQL 输入 OPENED 转换为 \'opened\'
  • Ruby 值 \'opened\' 在 GraphQL 响应中转换为 "OPENED"
module Types
  class EpicStateEnum < BaseEnum
    graphql_name 'EpicState'
    description 'GitLab epic 的状态'

value 'OPENED', value: 'opened', description: '一个开放的 Epic。'
    value 'CLOSED', value: 'closed', description: '一个关闭的 Epic。'
  end
end

枚举值可以使用deprecated 关键字来弃用。

从 Rails 枚举动态定义 GraphQL 枚举

如果你的 GraphQL 枚举由Rails 枚举支持,那么考虑使用 Rails 枚举来动态定义 GraphQL 枚举值。这样做会将 GraphQL 枚举值绑定到 Rails 枚举定义,因此如果向 Rails 枚举添加值,GraphQL 枚举会自动反映这一更改。

示例:

module Types
  class IssuableSeverityEnum < BaseEnum
    graphql_name 'IssuableSeverity'
    description '事件严重程度'

::IssuableSeverity.severities.each_key do |severity|
      value severity.upcase, value: severity, description: "#{severity.titleize} 严重程度。"
    end
  end
end

JSON

当 GraphQL 返回的数据存储为JSON时,我们应尽可能继续使用 GraphQL 类型。除非返回的 JSON 数据确实是完全非结构化的,否则避免使用 GraphQL::Types::JSON 类型。

如果 JSON 数据的结构不同,但属于一组已知可能结构之一,请使用联合类型。为此目的使用联合类型的示例如!30129所示。

如果需要,可以使用 hash_key: 关键字将字段名称映射到哈希数据键。

例如,给定以下 JSON 数据:

{
  "title": "我的图表",
  "data": [
    { "x": 0, "y": 1 },
    { "x": 1, "y": 1 },
    { "x": 2, "y": 2 }
  ]
}

我们可以这样使用 GraphQL 类型:

module Types
  class ChartType < BaseObject
    field :title, GraphQL::Types::String, null: true, description: '图表标题。'
    field :data, [Types::ChartDatumType], null: true, description: '图表数据。'
  end
end

模块 Types class ChartDatumType < BaseObject field :x, GraphQL::Types::Int, null: true, description: ‘图表数据点的 X 轴值。’ field :y, GraphQL::Types::Int, null: true, description: ‘图表数据点的 Y 轴值。’ end end


## 说明

所有字段和参数  
[must have descriptions](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/16438)。

字段或参数的说明通过 `description:` 关键字提供。例如:

```ruby
field :id, GraphQL::Types::ID, description: '问题的 ID。'
field :confidential, GraphQL::Types::Boolean, description: '表示问题是否为机密。'
field :closed_at, Types::TimeType, description: '问题关闭时的时间戳。'

您可以在以下位置查看字段和参数的说明:

说明风格指南

语言和标点

尽可能使用 {x} of the {y} 描述字段和参数,其中 {x} 是要描述的项目,{y} 是其适用的资源。例如:

问题的 ID。
史诗的作者。

对于排序或搜索的参数,以适当动词开头。为简洁起见,可用 this 代替 the giventhe specified 表示指定值。例如:

按此条件对问题进行排序。

不要用 TheA 开头,以保证一致性和简洁性。

所有说明均以句号 (.) 结尾。

布尔值

对于布尔字段 (GraphQL::Types::Boolean),以描述其功能的动词开头。例如:

表示问题是机密的。

必要时提供默认值。例如:

将问题设为机密。默认值为 false。

排序枚举

用于排序的枚举 应具有描述 '用于排序 {x} 的值。'。例如:

用于排序容器仓库的值。

Types::TimeType 字段说明

对于 Types::TimeType GraphQL 字段,包含单词 timestamp。这让读者知道该属性的格式是 Time,而非仅为 Date

例如:

field :closed_at, Types::TimeType, description: '问题关闭时的时间戳。'

copy_field_description 辅助方法

有时需确保两个说明完全一致。例如,当类型字段说明与突变参数代表同一属性时,保持二者一致。

可使用 copy_field_description 辅助方法替代提供说明,传入类型和字段名以复制说明。

示例:

argument :title, GraphQL::Types::String,
          required: false,
          description: copy_field_description(Types::MergeRequestType, :title)

文档引用

有时需在说明中引用外部 URL。为简化操作并提供生成参考文档的正确标记,我们在字段上提供 see 属性。例如:

field :genus,
      type: GraphQL::Types::String,
      null: true,
      description: '一个分类学属。',
      see: { '维基百科上的属页面' => 'https://wikipedia.org/wiki/Genus' }

这将渲染为:

一个分类学属。参见:[维基百科上的属页面](https://wikipedia.org/wiki/Genus)

可提供多个文档引用。该属性语法为 HashMap,键是文本描述,值是 URL。

订阅层级徽章

若字段或参数比其他字段适用于更高订阅层级,请添加 内联可用性详情

例如:

description: '自定义模板的完整路径。仅限高级版和企业版。'

授权

参见:GraphQL 授权

解析器

我们通过存储在 app/graphql/resolvers 目录中的 解析器 定义应用程序如何响应请求。解析器提供检索相关对象的实际实现逻辑。

要在字段中显示对象,可向 app/graphql/resolvers 添加解析器。

参数可在解析器中以与突变相同方式定义。参见 参数 部分。

为限制执行查询的数量,可使用 BatchLoader

编写解析器

stage: Group group: Info title: 解析器最佳实践 description: 了解如何编写有效的GraphQL解析器

我们的代码应旨在成为围绕查找器和服务类的精简声明式包装器。你可以重复参数列表,或将其提取到关注点模块中。在大多数情况下,组合优于继承。将解析器视为控制器:解析器应是能组合其他应用抽象的领域特定语言(DSL)。

例如:

class PostResolver < BaseResolver
  type Post.connection_type, null: true
  authorize :read_blog
  description '博客文章(可选地按名称过滤)'
  argument :name, [::GraphQL::Types::String], required: false, as: :slug

  alias_method :blog, :object

  def resolve(**args)
    PostFinder.new(blog, current_user, args).execute
  end
end

虽然你可以在两个不同的地方使用同一个解析器类(例如在暴露相同对象的两个字段中),但你绝不应直接重用解析器对象。解析器具有复杂的生命周期,框架会协调其授权、就绪状态和解析过程,在每个阶段可以返回延迟值以利用批量处理的机会。切勿在应用代码中实例化解析器或变更操作。

相反,代码复用的单元与其他应用部分类似:

  • 查询中使用查找器来检索数据。
  • 变更操作中使用服务类来执行操作。
  • 查询特有的加载器(具备批量意识的查找器)。

在变更操作中没有任何理由使用批量处理。变更操作是串行执行的,因此没有批量处理的机会。所有值一旦被请求就会立即急切求值,所以批量处理是不必要的开销。如果你正在编写:

  • 一个Mutation,可以直接查找对象。
  • 一个ResolverBaseObject上的方法,则需要允许批量处理。

错误处理

解析器可能抛出错误,这些错误会被适当地转换为顶级错误。所有预期的错误都应被捕获并转换为适当的GraphQL错误(参见 Gitlab::Graphql::Errors)。任何未捕获的错误都会被抑制,客户端会收到消息“Internal service error”。

特殊情况是权限错误。在REST API中,我们对用户无权访问的任何资源返回404 Not Found。GraphQL中的等效行为是对所有缺失或未经授权的资源返回null。查询解析器不应因未经授权的资源而抛出错误

这样做的理由是,客户端必须无法区分记录不存在与存在但无访问权限的情况。若能做到这一点,则构成安全漏洞,因为我们希望隐藏的信息会被泄露。

在大多数情况下,你无需担心这一点——通过我们声明的authorize DSL调用,解析器字段的授权会正确处理。不过,如果需要更自定义的操作,请记住:如果在解析字段时遇到current_user无权访问的对象,则整个字段应解析为null

派生解析器

(包括BaseResolver.singleBaseResolver.last

对于某些用例,我们可以从其他解析器派生解析器。主要用例是一个用于查找所有项目的解析器,另一个用于查找特定项目。为此,我们提供了便捷方法:

  • BaseResolver.single:构造一个选择第一个项目的新解析器。
  • BaseResolver.last:构造一个选择最后一个项目的解析器。

正确的单数类型是从集合类型推断出来的,因此我们无需在此处定义type

在使用这些方法之前,考虑是否更简单的是:

  • 编写另一个定义自身参数的解析器。
  • 编写一个抽象出查询的关注点模块。

过度自由地使用BaseResolver.single是一种反模式。它可能导致无意义的字段,例如当未提供参数时,Project.mergeRequest字段仅返回第一个MR。每当我们从集合解析器派生单个解析器时,它必须有更严格的参数。

为实现这一点,使用when_single块来自定义单个解析器。每个when_single块必须:

  • 定义(或重新定义)至少一个参数。
  • 将可选过滤器设为必需。

例如,我们可以通过重新定义现有可选参数来实现这一点,更改其类型并将其设为必需:

class JobsResolver < BaseResolver
  type JobType.connection_type, null: true
  authorize :read_pipeline
  argument :name, [::GraphQL::Types::String], required: false

  when_single do
    argument :name, ::GraphQL::Types::String, required: true
  end

  def resolve(**args)
    JobsFinder.new(pipeline, current_user, args.compact).execute
  end
end

优化解析器

前瞻(Look-Ahead)

在执行期间,完整的查询提前已知,这意味着我们可以利用 lookahead 来优化我们的查询,并批量加载我们需要的关联数据。考虑在你的解析器中加入 lookahead 支持,以避免 N+1 性能问题。

为了支持常见的 lookahead 用例(当请求子字段时预加载关联),你可以包含 LooksAhead。例如:

# 假设一个模型 `MyThing` 拥有属性 `[child_attribute, other_attribute, nested]`,
# 其中 nested 有一个名为 `included_attribute` 的属性。
class MyThingResolver < BaseResolver
  include LooksAhead

# 而不是定义 `resolve(**args)`,我们实现:`resolve_with_lookahead(**args)`
  def resolve_with_lookahead(**args)
    apply_lookahead(MyThingFinder.new(current_user).execute)
  end

# 我们列出应始终预加载的内容:
  # 例如,如果 child_attribute 总是需要(可能在授权过程中),
  # 那么可以在此处包含它。
  def unconditional_includes
    [:child_attribute]
  end

# 我们列出如果在某个字段被选择时应包含的内容:
  def preloads
    {
        field_one: [:other_attribute],
        field_two: [{ nested: [:included_attribute] }]
    }
  end
end

默认情况下,preloads 中定义的字段会在查询中被选中时预加载。偶尔需要更精细的控制,比如避免预加载过多或不必要的内容。

扩展上述示例,如果我们希望在请求某些字段时预加载不同的关联,可以通过重写 #filtered_preloads 来实现:

class MyThingResolver < BaseResolver
  # ...

def filtered_preloads
    return [:alternate_attribute] if lookahead.selects?(:field_one) && lookahead.selects?(:field_two)

super
  end
end

LooksAhead 模块还提供了基于嵌套 GraphQL 字段定义的基本预加载支持。WorkItemsResolver 是这方面的一个很好的实际案例。你还可以定义 nested_preloads 方法来返回一个哈希,但与 preloads 不同,每个键对应的值是另一个哈希而非关联列表。因此,在之前的示例中,你可以这样覆盖 nested_preloads

class MyThingResolver < BaseResolver
  # ...

def nested_preloads
    {
      root_field: {
        nested_field1: :association_to_preload,
        nested_field2: [:association1, :association2]
      }
    }
  end
end

有关真实世界的用法示例,请参阅 ResolvesMergeRequests

before_connection_authorization

before_connection_authorization 钩子可以帮助解析器消除源于 类型授权 权限检查的 N+1 问题。

before_connection_authorization 方法接收已解析的节点和当前用户。在块中,使用 ActiveRecord::Associations::PreloaderPreloaders:: 类来为类型授权检查预加载数据。

示例:

class LabelsResolver < BaseResolver
  before_connection_authorization do |labels, current_user|
    Preloaders::LabelsPreloader.new(labels, current_user).preload_all
  end
end

批量加载(BatchLoading)

请参阅 GraphQL BatchLoader

正确使用 Resolver#ready?

解析器作为框架的一部分有两个公共API方法:#ready?(**args)#resolve(**args)

我们可以使用 #ready? 来执行设置或提前返回,而不调用 #resolve。 使用 #ready? 的良好原因包括:

  • 如果我们事先知道不可能有结果,则返回 Relation.none
  • 执行设置,例如初始化实例变量(尽管为此考虑延迟初始化的方法)。Resolver#ready?(**args) 的实现应返回 (Boolean, early_return_data),如下所示:
def ready?(**args)
  [false, 'have this instead']
  end

因此,每当您调用解析器时(主要在测试中,因为框架抽象不应将解析器视为可重用的,首选查找器),记得在调用 resolve 之前调用 ready? 方法并检查布尔标志!可以在我们的 GraphqlHelpers 中看到一个示例。 对于验证参数,validators 比使用 #ready? 更受青睐。

否定参数

否定过滤器可以过滤某些资源(例如,找到所有具有 bug 标签但没有分配 bug2 标签的问题)。not 参数是传递否定参数的首选语法:

issues(labelName: "bug", not: {labelName: "bug2"}) {
  nodes {
    id
    title
  }
}

您可以在类型或解析器中使用来自 Gitlab::Graphql::NegatableArgumentsnegated 帮助程序。例如:

extend ::Gitlab::Graphql::NegatableArguments

negated do
 argument :labels, [GraphQL::STRING_TYPE],
           required: false,
           as: :label_name,
           description: 'Array of label names. All resolved merge requests will not have these labels.'
end

元数据

使用解析器时,它们可以并且应该充当字段元数据的SSoT(单一事实来源)。所有字段选项(除字段名称外)都可以在解析器上声明。这些包括:

  • type(必需 - 所有解析器必须包含类型注释)
  • extras
  • description
  • Gitaly 注释(使用 calls_gitaly!) 示例:
module Resolvers
 MyResolver < BaseResolver
   type Types::MyType, null: true
   extras [:lookahead]
   description 'Retrieve a single MyType'
   calls_gitaly!
 end
end

将父对象传递给子 Presenter

有时您需要访问子上下文中的已解析查询父对象来计算字段。通常父对象仅在 Resolver 类中以 parent 形式可用。 要在您的 Presenter 类中找到父对象:

  1. 从解析器的 resolve 方法中将父对象添加到 GraphQL context 中:
  def resolve(**args)
    context[:parent_object] = parent
  end
  1. 声明您的解析器或字段需要 parent 字段上下文。例如:

    # in ChildType
    field :computed_field, SomeType, null: true,
          method: :my_computing_method,
          extras: [:parent], # Necessary
          description: 'My field description.'
    
    field :resolver_field, resolver: SomeTypeResolver
    
    # In SomeTypeResolver
    
    extras [:parent]
    type SomeType, null: true
    description 'My field description.'

1. 在您的 Presenter 类中声明字段的方法,并让它接受 `parent` 关键字参数。此参数包含父 **GraphQL 上下文**,因此您必须使用 `parent[:parent_object]` 或您在 `Resolver` 中使用的任何键来访问父对象:

```ruby
  # in ChildType
  field :computed_field, SomeType, null: true,
        method: :my_computing_method,
        extras: [:parent], # Necessary
        description: 'My field description.'

  field :resolver_field, resolver: SomeTypeResolver

  # In SomeTypeResolver

  extras [:parent]
  type SomeType, null: true
  description 'My field description.'

有关实际用例的示例,请查看 这个合并请求,它向 IterationPresenter 添加了 scopedPathscopedUrl

Mutations

Mutations 用于更改任何存储的值或触发操作。就像 GET 请求不应该修改数据一样,我们不能在常规 GraphQL 查询中修改数据。但是,我们可以在 mutation 中做到这一点。

构建 Mutations

Mutations 存储在 app/graphql/mutations 中,理想情况下按其正在变更的资源分组,类似于我们的服务。它们应继承 Mutations::BaseMutation。在 mutation 上定义的字段将作为 mutation 的结果返回。

更新 mutation 细粒度

GitLab 中的面向服务架构意味着大多数变更操作都会调用创建、删除或更新服务,例如 UpdateMergeRequestService

对于更新变更操作,你可能只想更新对象的某个方面,因此只需要一个细粒度的变更操作,例如 MergeRequest::SetDraft

可以同时存在细粒度和粗粒度的变更操作,但要注意过多的细粒度变更操作会导致可维护性、代码可读性和测试方面的组织挑战。
每个变更操作都需要一个新的类,这可能带来技术债务。
这也意味着模式变得非常大,可能让用户难以导航我们的模式。
由于每个新变更操作也需要测试(包括较慢的请求集成测试),添加变更操作会减慢测试套件的运行速度。

为尽量减少改动:

  • 当可用时,使用现有的变更操作,例如 MergeRequest::Update
  • 将现有服务暴露为粗粒度变更操作。

当细粒度变更操作可能更合适时:

  • 修改需要特定权限或其他专门逻辑的属性。
  • 暴露类似状态机的转换(锁定问题、合并 MR、关闭史诗等)。
  • 接受嵌套属性(我们接受子对象的属性)。
  • 变更操作的语义可以清晰简洁地表达。

详见 issue #233063 获取更多背景信息。

命名约定

每个变更操作都必须定义一个 graphql_name,这是 GraphQL 模式中变更操作的名称。

示例:

class UserUpdateMutation < BaseMutation
  graphql_name 'UserUpdate'
end

由于 graphql-ruby gem 的 1.13 版本的变化,graphql_name 应该是类的第一行,以确保类型名称正确生成。Graphql::GraphqlNamePosition cop 强制执行这一点。
详见 issue #27536 获取更多背景信息。

我们的 GraphQL 变更操作名称历史上不一致,但新的变更操作名称应遵循 '{资源}{动作}''{资源}{动作}{属性}' 的惯例。

创建新资源的变更操作应使用动词 Create
示例:

  • CommitCreate

更新数据的变更操作应使用:

  • 动词 Update
  • 如果更合适,使用领域特定的动词,如 SetAddToggle

示例:

  • EpicTreeReorder
  • IssueSetWeight
  • IssueUpdate
  • TodoMarkDone

移除数据的变更操作应使用:

  • 使用 Delete 而非 Destroy
  • 如果更合适,使用领域特定的动词,如 Remove

示例:

  • AwardEmojiRemove
  • NoteDelete

如果需要变更操作命名的建议,可在 Slack 的 #graphql 频道寻求反馈。

字段

在大多数情况下,变更操作会返回两个字段:

  • 被修改的资源
  • 错误列表,解释为何操作无法执行。若变更操作成功,此列表将为空。

通过继承任何新的变更操作自 Mutations::BaseMutationerrors 字段会自动添加。还会添加 clientMutationId 字段,可用于客户端在单个请求中执行多个变更操作时识别结果。

resolve 方法

类似于 编写解析器,变更操作的 resolve 方法应旨在成为围绕 服务 的薄声明式包装器。

resolve 方法以关键字参数形式接收变更操作的参数。从这里,我们可以调用修改资源的服务。

随后,resolve 方法应返回一个哈希,其中包含与变更操作定义的字段相同的名称,以及一个 errors 数组。例如,Mutations::MergeRequests::SetDraft 定义了一个 merge_request 字段:

field :merge_request,
      Types::MergeRequestType,
      null: true,
      description: "The merge request after mutation."

这意味着此变更操作的 resolve 返回的哈希应如下所示:

{
  # 被修改的 merge request,这将包裹在字段定义的类型中
  merge_request: merge_request,
  # 若授权后变更操作失败,则为字符串数组。`errors_on_object` 辅助方法收集 `errors.full_messages`
  errors: errors_on_object(merge_request)
}

挂载变更操作

要使变更操作可用,必须在存储于 graphql/types/mutation_type 的变更操作类型上定义它。mount_mutation 辅助方法根据变更操作的 GraphQL 名称定义一个字段:

module Types
  class MutationType < BaseObject
    graphql_name 'Mutation'

    include Gitlab::Graphql::MountMutation

    mount_mutation Mutations::MergeRequests::SetDraft
  end
end

生成一个名为 mergeRequestSetDraft 的字段,该字段由 Mutations::MergeRequests::SetDraft 解析。

授权资源

要在变更中授权资源,我们首先需要在变更上提供所需权限,示例如下:

module Mutations
  module MergeRequests
    class SetDraft < Base
      graphql_name 'MergeRequestSetDraft'
      authorize :update_merge_request
    end
  end
end

随后我们可以在 resolve 方法中调用 authorize!,传入要验证权限的资源。

alternatively,我们可以添加一个 find_object 方法来加载变更中的对象。这将允许你使用 authorized_find! 辅助方法。

当用户无权执行操作或对象未找到时,我们应该通过在 resolve 方法中调用 raise_resource_not_available_error! 来抛出 Gitlab::Graphql::Errors::ResourceNotAvailable 错误。

变更中的错误

我们鼓励遵循 errors as data 的实践来处理变更错误,该方法根据谁能处理错误来区分错误的归属。

关键点:

  • 所有变更响应都有一个 errors 字段。此字段应在失败时填充,成功时也可选择填充。
  • 考虑谁需要看到错误:用户开发者
  • 客户端执行变更时应始终请求 errors 字段。
  • 错误可报告给用户的位置有两种:$root.errors(顶级错误)或 $root.data.mutationName.errors(变更错误)。具体位置取决于错误的类型及包含的信息。
  • 变更字段 必须设置 null: true

以返回两个字段的变更 doTheThing 为例:errors: [String]thing: ThingType。由于此处关注的是错误,因此 thing 本身的具体性质与此处示例无关。

变更响应可能有三种状态:

成功

在理想情况下,即使一切顺利,也可能返回错误及预期负载;但如果完全成功,则 errors 应为空数组,因为我们无需告知用户任何问题。

{
  data: {
    doTheThing: {
      errors: [] // 若成功,此数组通常为空。
      thing: { .. }
    }
  }
}

失败(与用户相关)

影响 用户 的错误发生了。我们将此类错误称为 变更错误

创建 变更中,通常没有 thing 可返回。

更新 变更中,我们返回 thing 当前的真实状态。开发者可能需要对 thing 实例调用 #reset 以确保这一点。

{
  data: {
    doTheThing: {
      errors: ["你不能触碰这个事物"],
      thing: { .. }
    }
  }
}

此类错误的例子包括:

  • 模型验证错误:用户可能需要修改输入。
  • 权限错误:用户需知晓无法执行操作,可能需要请求权限或登录。
  • 应用程序状态问题阻碍用户操作(例如合并冲突或锁定资源)。

理想情况下应阻止用户到达这一步,但如果确实发生,需告知其出错原因,使其理解失败缘由及达成目标的方法(例如只需重试请求)。

可在返回变更数据的同时返回可恢复的错误。例如,若用户上传 10 个文件,其中 3 个失败、其余成功,可将失败的错误信息提供给用户,同时附带成功信息。

失败(与用户无关)

可在 顶层 返回一个或多个不可恢复的错误。这些问题用户几乎无法控制,主要属于系统或编程问题,需 开发者 关注。此时无 data 字段:

{
  errors: [
    {"message": "参数错误:期望整数,得到 null"},
  ]
}

这是因变更过程中抛出错误所致。在我们的实现中,参数错误和验证错误的提示会返回给客户端,其他 StandardError 实例会被捕获、记录并呈现给客户端,消息设为 "内部服务器错误"。详情请参见 GraphqlController

这类错误代表编程错误,例如:

  • GraphQL 语法错误:传入 Int 而非 String,或缺少必需参数。

  • 我们的模式中的错误,例如无法为非空字段提供值。

  • 系统错误:例如,Git存储异常或数据库不可用。

    用户不应在常规使用中导致此类错误。此类错误应视为内部错误,不向用户提供具体细节。

    当突变失败时,我们需要告知用户,但不需要告诉他们原因,因为他们不可能导致此问题,且他们无法修复,尽管我们可以提供重试该突变的选项。

错误分类

当我们编写突变时,需要意识到错误状态属于这两类中的哪一类(并与前端开发人员沟通以验证我们的假设)。这意味着区分_user的需求与_client的需求。

除非用户需要知道,否则不要捕获错误。

如果用户确实需要了解,请与前端的开发人员沟通,确保我们传递回的错误信息相关且有目的。

另见前端GraphQL指南

别名化与弃用突变

#mount_aliased_mutation助手允许我们在MutationType中将一个突变别名为另一个名称。

例如,要将名为FooMutation的突变别名为BarMutation

mount_aliased_mutation 'BarMutation', Mutations::FooMutation

这允许我们重命名一个突变并继续支持旧名称,结合deprecated参数使用。

示例:

mount_aliased_mutation 'UpdateFoo',
                        Mutations::Foo::Update,
                        deprecated: { reason: 'Use fooUpdate', milestone: '13.2' }

弃用的突变应添加到Types::DeprecatedMutations中,并在Types::MutationType的单元测试中进行测试。可以参考合并请求!34798,其中包含测试弃用别名突变的示例。

弃用EE突变

EE突变应遵循相同流程。有关合并请求过程的示例,请阅读合并请求 !42588

订阅

我们使用订阅向客户端推送更新。我们使用Action Cable实现通过WebSockets传递消息。

当客户端订阅某个订阅时,我们将它们的查询存储在Puma worker的内存中。然后当订阅被触发时,Puma worker会执行存储的GraphQL查询并将结果推送给客户端。

我们无法使用GraphiQL测试订阅,因为它们需要一个Action Cable客户端,而GraphiQL目前不支持这一点。

构建订阅

Types::SubscriptionType下的所有字段都是客户端可以订阅的订阅。这些字段需要一个订阅类,该类是Subscriptions::BaseSubscription的后代,并存储在app/graphql/subscriptions下。

订阅所需的参数以及返回的字段在订阅类中定义。如果多个字段具有相同的参数并返回相同的字段,则可以共享同一个订阅类。

此类在初始订阅请求和后续更新期间运行。你可以在GraphQL Ruby指南中了解更多信息。

授权

你应该实现订阅类的#authorized?方法,以便对初始订阅和后续更新进行授权。

当用户未被授权时,你应该调用unauthorized!助手,这样执行就会停止,用户也会被取消订阅。返回false会导致响应被编辑,但我们泄漏了某些更新正在发生的信息。这种泄漏是由于GraphQL gem中的一个bug造成的。

触发订阅

GraphqlTriggers模块下定义一个方法来触发订阅。不要在应用程序代码中直接调用GitlabSchema.subscriptions.trigger,这样我们就能有一个单一的事实来源,并且不会用不同的参数和对象触发订阅。

分页实现

更多信息,请参阅GraphQL分页

参数

参数通过argument为解析器或突变定义。

示例:

argument :my_arg, GraphQL::Types::String,
         required: true,
         description: "A description of the argument."

可空性

参数可以标记为 required: true,这意味着值必须存在且不能是 null。 如果一个必需参数的值可以是 null,请使用 required: :nullable 声明。

示例:

argument :due_date,
         Types::TimeType,
         required: :nullable,
         description: '期望的问题截止日期。如果为 null 则移除截止日期。'

在上述示例中,due_date 参数必须传入,但与 GraphQL 规范不同,其值可以为 null。 这使得在一次突变中“取消设置”截止日期成为可能,而不必创建新的删除截止日期的突变。

{ due_date: null } # => 合法
{ due_date: "2025-01-10" } # => 合法
{  } # => 无效(未传入)

可空性与 required: false

如果一个参数被标记为 required: false,客户端允许发送 null 作为值。 这通常是不理想的。

如果一个参数是可选的,但 null 不是允许的值,请使用验证来确保传入 null 时返回错误:

argument :name, GraphQL::Types::String,
         required: false,
         validates: { allow_null: false }

或者,如果您希望在 null 不被允许时允许 null,可以用默认值替换它:

argument :name, GraphQL::Types::String,
         required: false,
         default_value: "未提供名称",
         replace_null_with_default: true

详见 验证可空性默认值 获取更多细节。

互斥参数

参数可以被标记为互斥的,确保它们不会同时提供。 当列出多个参数中的超过一个被传入时,会添加顶级错误。

示例:

argument :user_id, GraphQL::Types::String, required: false
argument :username, GraphQL::Types::String, required: false

validates mutually_exclusive: [:user_id, :username]

当恰好需要一个参数时,可以使用 exactly_one_of 验证器。

示例:

argument :group_path, GraphQL::Types::String, required: false
argument :project_path, GraphQL::Types::String, required: false

validates exactly_one_of: [:group_path, :project_path]

关键词

每个定义的 GraphQL argument 都作为关键字参数传递给突变的 #resolve 方法。

示例:

def resolve(my_arg:)
  # 执行突变 ...
end

输入类型

graphql-ruby 将参数包装成一个 输入类型

例如,mergeRequestSetDraft 突变 定义了这些参数(一些通过 继承 获得):

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: "合并请求所属的项目。"

argument :iid, GraphQL::Types::String,
         required: true,
         description: "合并请求的 IID。"

argument :draft,
         GraphQL::Types::Boolean,
         required: false,
         description: <<~DESC
           是否将合并请求设为草稿状态。
         DESC

这些参数会自动生成名为 MergeRequestSetDraftInput 的输入类型,包含我们指定的 3 个参数以及 clientMutationId

对象标识符参数

用于识别对象的参数应为:

完整路径对象标识符参数

历史上我们在完整路径参数的命名上不一致,但现在倾向于这样命名参数:

  • project_path 表示项目完整路径
  • group_path 表示群组完整路径
  • namespace_path 表示命名空间完整路径

ciJobTokenScopeRemoveProject 突变 为例:

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: 'CI 作业令牌范围所属的项目。'

IID 对象标识符参数

结合父级 project_pathgroup_path 使用对象的 iid。例如:

argument :project_path, GraphQL::Types::ID,
         required: true,
         description: '问题所属的项目。'

argument :iid, GraphQL::Types::String,
         required: true,
         description: '问题的 IID。'

全局ID对象标识符参数

使用来自 discussionToggleResolve 变更 的示例:

argument :id, Types::GlobalIDType[Discussion],
         required: true,
         description: '讨论的全局ID。'

另见 废弃全局ID

排序参数

排序参数应尽可能使用 枚举类型 来描述可用排序值的集合。

该枚举可从 Types::SortEnum 继承以获取一些通用值。

枚举值需遵循 {属性}_{方向} 格式。例如:

TITLE_ASC

另见 排序枚举的描述风格指南

来自 ContainerRepositoriesResolver 的示例:

# Types::ContainerRegistry::ContainerRepositorySortEnum:
module Types
  module ContainerRegistry
    class ContainerRepositorySortEnum < SortEnum
      graphql_name 'ContainerRepositorySort'
      description '用于排序容器仓库的值'

value 'NAME_ASC', '按名称升序排列。', value: :name_asc
      value 'NAME_DESC', '按名称降序排列。', value: :name_desc
    end
  end
end

# Resolvers::ContainerRepositoriesResolver:
argument :sort, Types::ContainerRegistry::ContainerRepositorySortEnum,
          description: '按此条件排序容器仓库。',
          required: false,
          default_value: :created_desc

GitLab 自定义标量

Types::TimeType

Types::TimeType 必须作为所有处理 Ruby TimeDateTime 对象的字段及参数的类型。

该类型是 自定义标量,其作用包括:

  • 当作为我们的 GraphQL 字段类型时,将 Ruby 的 TimeDateTime 对象转换为标准化的 ISO - 8601 格式字符串。

  • 当作为我们的 GraphQL 参数类型时,将 ISO - 8601 格式的时间字符串转换为 Ruby Time 对象。

这使得我们的 GraphQL API 能以标准化方式呈现时间并处理时间输入。

示例:

field :created_at, Types::TimeType, null: true, description: '问题创建的时间戳。'

全局ID标量

我们所有的 全局ID 都是自定义标量。它们是从抽象标量类 Types::GlobalIDType 动态创建 而来的。

测试

只有 集成测试 能完全验证查询或变更是否执行并正确解析。

仅用 单元测试 静态验证模式的某些方面(例如类型是否有特定字段、变更是否有特定必需参数)。
不要对解析器进行单元测试,除非是静态验证字段或参数。

对于其他所有测试,使用 集成测试

编写集成测试

集成测试会检查 GraphQL 查询或变更的完整流程,并存储在 spec/requests/api/graphql 中。

我们使用集成测试来全面测试 所有执行阶段
只有完整的请求集成测试能验证以下内容:

  • 变更实际可在模式中查询(已挂载到 MutationType)。

  • 解析器或变更返回的数据与字段的 返回类型 匹配,且无错误地解析。

  • 参数在输入时正确强制转换,字段在输出时正确序列化。

  • 任何 参数预处理

  • 参数或标量的验证正确应用。

  • 参数的 default_value 正确应用。

  • 解析器或变更的 #ready? 方法 中的逻辑正确应用。

  • 对象成功解析,且无 N + 1 问题。

添加查询时,你可使用 a working graphql query that returns dataa working graphql query that returns no data 共享示例来测试查询是否生成有效结果。

使用 post_graphql 助手函数来进行 GraphQL 集成。

例如:

Good:

gql_query = %q(some query text…) post_graphql(gql_query, current_user: current_user)

or:

GitlabSchema.execute(gql_query, context: { current_user: current_user })

Deprecated: avoid

resolve(described_class, obj: project, ctx: { current_user: current_user })


你可以使用 `GraphqlHelpers#all_graphql_fields_for` 助手来构建包含所有可用字段的查询。这使得为查询添加测试渲染所有可能字段变得更加直接。

如果你正在向支持分页和排序的查询中添加一个字段,请访问 [Testing](graphql_guide/pagination.md#testing) 了解详情。

要测试GraphQL突变请求,`GraphqlHelpers` 提供了两个助手:`graphql_mutation` 接受突变的名称和一个包含突变输入的哈希。这会返回一个包含突变查询和准备好的变量的结构体。

然后,你可以将这个结构体传递给 `post_graphql_mutation` 助手,它会像GraphQL客户端一样发布带有正确参数的请求。

要访问突变的响应,你可以使用 `graphql_mutation_response` 助手。

使用这些助手,你可以构建如下规范:

```ruby
let(:mutation) do
  graphql_mutation(
    :merge_request_set_wip,
    project_path: 'gitlab-org/gitlab-foss',
    iid: '1',
    wip: true
  )
end

it 'returns a successful response' do
   post_graphql_mutation(mutation, current_user: user)

expect(response).to have_gitlab_http_status(:success)
   expect(graphql_mutation_response(:merge_request_set_wip)['errors']).to be_empty
end

测试技巧

  • 熟悉 GraphqlHelpers 支持模块中的方法。其中许多方法使编写GraphQL测试更容易。

  • 使用遍历助手如 GraphqlHelpers#graphql_data_atGraphqlHelpers#graphql_dig_at 来访问结果字段。例如:

  result = GitlabSchema.execute(query)

mr_iid = graphql_dig_at(result.to_h, :data, :project, :merge_request, :iid)
  • 使用 GraphqlHelpers#a_graphql_entity_for 来匹配结果。例如:
  post_graphql(some_query)

# 检查它是否是一个包含 { id => issue的全局ID } 的哈希
  expect(graphql_data_at(:project, :issues, :nodes))
    .to contain_exactly(a_graphql_entity_for(issue))

# 可以传递额外的字段,作为方法名或带值的形式
  expect(graphql_data_at(:project, :issues, :nodes))
    .to contain_exactly(a_graphql_entity_for(issue, :iid, :title, created_at: some_time))
  • 使用 GraphqlHelpers#empty_schema 创建空模式,而不是手动创建。例如:
  # good
  let(:schema) { empty_schema }

# bad
  let(:query_type) { GraphQL::ObjectType.new }
  let(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
  • 使用 GraphqlHelpers#query_double(schema: nil)double(\'query\', schema: nil)。例如:
  # good
  let(:query) { query_double(schema: GitlabSchema) }

# bad
  let(:query) { double(\'Query\', schema: GitlabSchema) }
  • 避免误报:

使用 current_user: 参数对 post_graphql 进行用户身份验证会在第一次请求时比同一用户的后续请求生成更多的查询。如果你正在使用 QueryRecorder 测试N+1查询,请为每个请求使用不同的用户。

以下示例展示了如何进行避免N+1查询的测试:

  RSpec.describe 'Query.project(fullPath).pipelines' do
    include GraphqlHelpers

let(:project) { create(:project) }

let(:query) do
      %(
        {
          project(fullPath: "#{project.full_path}") {
            pipelines {
              nodes {
                id
              }
            }
          }
        }
      )
    end

it 'avoids N+1 queries' do
      first_user = create(:user)
      second_user = create(:user)
      create(:ci_pipeline, project: project)

control_count = ActiveRecord::QueryRecorder.new do
        post_graphql(query, current_user: first_user)
      end

create(:ci_pipeline, project: project)

expect do
        post_graphql(query, current_user: second_user)  # 使用不同用户以避免来自认证查询的误报
      end.not_to exceed_query_limit(control_count)
    end
  end
  • 模拟 app/graphql/types 的文件夹结构:

例如,位于 app/graphql/types/ci/pipeline_type.rbTypes::Ci::PipelineType 字段上的测试应存储在 spec/requests/api/graphql/ci/pipeline_spec.rb 中,无论用于获取管道数据的查询是什么。

编写单元测试

仅使用单元测试来静态验证模式,例如断言以下内容:

  • 类型、突变或解析器具有特定的命名字段

  • 类型、突变或解析器具有特定的命名 authorize 权限(但通过 集成测试 测试授权)

Mutations 或解析器(resolvers)具有特定的命名参数,以及这些参数是否必需。

除了静态模式测试外,不要对解析器的解析方式或应用授权进行单元测试。相反,使用integration tests来测试执行的全阶段

关于查询流程和GraphQL基础设施的说明

GitLab的GraphQL基础设施位于lib/gitlab/graphql中。

Instrumentation 是在执行查询时包裹的功能。它被实现为一个使用 Instrumentation 类的模块。

示例:Present

module Gitlab
  module Graphql
    module Present
      #... 一些上面的代码...

def self.use(schema_definition)
        schema_definition.instrument(:field, ::Gitlab::Graphql::Present::Instrumentation.new)
      end
    end
  end
end

一个查询分析器包含一系列回调,用于在执行前验证查询。每个字段都可以通过分析器,最终值也对你可用。

Multiplex queries 允许多个查询在单个请求中发送。这减少了向服务器发送的请求数量。(GraphQL Ruby提供了自定义的Multiplex查询分析器和Multiplex instrumentation)。

查询限制

查询和变更受深度、复杂度和递归限制,以保护服务器资源免受过于雄心勃勃或恶意查询的影响。这些值可以作为默认值设置,并根据需要在特定查询中覆盖。复杂度值也可以按对象设置,最终查询复杂度基于返回的对象数量进行评估。这可用于昂贵的对象(例如需要Gitaly调用的情况)。

例如,解析器中的一个条件复杂度方法:

def self.resolver_complexity(args, child_complexity:)
  complexity = super
  complexity += 2 if args[:labelName]

complexity
end

更多关于复杂度:GraphQL Ruby文档

文档和模式

我们的模式位于 app/graphql/gitlab_schema.rb 中。有关详细信息,请参阅模式参考

当模式更改时,此生成的GraphQL文档需要更新。有关生成GraphQL文档和模式文件的信息,请参阅更新模式文档

为了帮助读者,您还应该在我们的GraphQL API文档中添加新页面。有关指导,请参阅GraphQL API页面。

包含changelog条目

所有面向客户端的更改必须包含changelog条目

惰性(Laziness)

GraphQL管理性能的一个重要独特技巧是使用lazy值。Lazy值表示结果的承诺,允许其操作稍后运行,从而能够在查询树的不同部分批量处理查询。我们代码中lazy值的主要示例是GraphQL BatchLoader

要直接管理lazy值,请阅读Gitlab::Graphql::Lazy,特别是Gitlab::Graphql::Laziness。其中包含#force#delay,有助于在需要时实现创建和消除惰性的基本操作。

对于不强制处理lazy值的情况,使用Gitlab::Graphql::Lazy.with_value