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

构建和部署实时视图组件

GitLab 通过独立的视图组件提供交互式用户体验,这些组件接受用户输入并将状态变化反映给用户。例如,在合并请求(Merge Request)页面上,用户可以批准、发表评论、与 CI/CD 管道交互等。

然而,GitLab 通常不能及时反映状态更新。这意味着页面的某些部分会显示过时数据,只有在用户刷新页面后才会更新。

为此,GitLab 引入了技术和编程 API,允许视图组件通过 WebSocket 实时接收状态更新。

以下文档将告诉您如何构建和部署从 GitLab Ruby on Rails 服务器实时接收更新的视图组件。

Action Cable 和 GraphQL 订阅仍在开发中,处于积极开发阶段。开发者必须评估他们的使用场景,检查这些是否是合适的工具。如果您不确定,请在 #f_real-time 内部 Slack 频道 寻求帮助。

安全使用 WebSockets

WebSockets 在 GitLab 中是一项相对较新的技术,您在使用 WebSocket 连接时应进行防御性编程。

向后兼容性

将连接视为临时性的,并确保您构建的功能具有向后兼容性。确保当 WebSocket 连接不可用时,关键功能能够优雅降级。

您可以同时处理前端和后端,因为如果没有必要的后端代码,很难模拟通过 WebSockets 进行的更新。

但是,始终应该先部署后端更改。强烈建议将后端和前端更改打包在单独的发布中,或使用功能标志(Feature Flag)进行管理,特别是在引入新连接时。

这确保当前端开始订阅事件时,后端已经准备好处理这些事件。

大规模下的新连接

在大型系统中引入新的 WebSocket 连接特别有风险。如果您需要在网站的新区域建立连接,在继续之前,请执行"引入新的 WebSocket 连接"部分中详细说明的步骤。

构建实时视图组件

先决条件:

阅读以下内容:

要在 GitLab 上构建实时视图组件,您必须:

  • 在 GitLab 前端将 Vue 组件与 Apollo 订阅集成。
  • 从 GitLab Ruby on Rails 后端添加和触发 GraphQL 订阅。

将 Vue 组件与 Apollo 订阅集成

我们当前的实时堆栈假设客户端代码使用 Vue 作为渲染层,Apollo 作为状态和网络层构建。如果您正在处理尚未迁移到 Vue + Apollo 的 GitLab 前端部分,请先完成该任务。

考虑一个观察并渲染 GitLab Issue 数据的假设性 IssueView Vue 组件。为简单起见,我们假设它只渲染问题的标题和描述:

import issueQuery from '~/issues/queries/issue_view.query.graqhql';

export default {
  props: {
    issueId: {
      type: Number,
      required: false,
      default: null,
    },
  },
  apollo: {
    // Apollo 查询对象的名称。必须与 `data` 绑定的字段名匹配。
    issue: {
      // 用于初始获取的查询。
      query: issueQuery,
      // 绑定用于初始获取查询的参数。
      variables() {
        return {
          iid: this.issueId,
        };
      },
      // 将响应数据映射到视图属性。
      update(data) {
        return data.project?.issue || {};
      },
    },
  },
  // 响应式 Vue 组件数据。当查询返回或订阅触发时,Apollo 会更新这些数据。
  data() {
    return {
      issue: {}, // 在视图加载时返回初始状态是良好实践。
    };
  },
};

// 为简洁起见,省略了 <template> 代码,因为它与此讨论无关。

查询应该:

  • 定义在 app/assets/javascripts/issues/queries/issue_view.query.graqhql

  • 包含以下 GraphQL 操作:

    query gitlabIssue($iid: String!) {
      # 我们在这里硬编码路径仅用于说明。实践中不要这样做。
      project(fullPath: "gitlab-org/gitlab") {
        issue(iid: $iid) {
          title
          description
        }
      }
    }

到目前为止,这个视图组件只定义了初始获取查询来填充自身数据。这是一个普通的 GraphQL query 操作,作为 HTTP POST 请求发送,由视图发起。服务器上的任何后续更新都会使该视图过时。为了接收来自服务器的更新,您必须:

  1. 添加 GraphQL 订阅定义。
  2. 定义 Apollo 订阅钩子。

添加 GraphQL 订阅定义

订阅也定义了一个 GraphQL 查询,但它被包装在 GraphQL subscription 操作中。这个查询由后端发起,并通过 WebSocket 推送到视图组件中。

与初始获取查询类似,您必须:

  • app/assets/javascripts/issues/queries/issue_updated.subscription.graqhql 定义订阅文件。

  • 在文件中包含以下 GraphQL 操作:

    subscription issueUpdatedSubscription($iid: String!) {
      issueUpdated($issueId: IssueID!) {
        issue(issueId: $issueId) {
          title
          description
        }
      }
    }

添加新订阅时,请使用以下命名指南:

  • Subscription 结束订阅的操作名称,如果是 GitLab EE 专有的,则使用 SubscriptionEE。例如,issueUpdatedSubscriptionissueUpdatedSubscriptionEE
  • 在订阅的事件名称中使用"已发生"的动作动词。例如,issueUpdated

虽然订阅定义看起来与普通查询相似,但有一些重要的区别需要理解:

  • query
    • 源自前端。
    • 使用内部 ID(iid,数字),这是实体在 URL 中通常引用的方式。因为内部 ID 相对于封闭的命名空间(在本例中是 project),您必须将查询嵌套在 fullPath 下。
  • subscription
    • 是前端向后端请求接收未来更新的请求。
    • 包括:
      • 描述订阅本身的操作名称(本例中为 issueUpdatedSubscription)。
      • 嵌套的事件查询(本例中为 issueUpdated)。嵌套的事件查询:
        • 在运行同名 GraphQL 触发器 时执行,因此订阅中使用的事件名称必须与后端使用的触发字段匹配。
        • 使用全局 ID 字符串而不是数字内部 ID,这是在 GraphQL 中标识资源的首选方式。有关更多信息,请参见 GraphQL 全局 ID

定义 Apollo 订阅钩子

定义订阅后,使用 Apollo 的 subscribeToMore 属性将其添加到视图组件中:

import issueQuery from '~/issues/queries/issue_view.query.graqhql';
import issueUpdatedSubscription from '~/issues/queries/issue_updated.subscription.graqhql';

export default {
  // 如前所述。
  // ...
  apollo: {
    issue: {
      // 如前所述。
      // ...
      // 这个 Apollo 钩子启用实时推送。
      subscribeToMore: {
        // 返回未来更新的订阅操作。
        document: issueUpdatedSubscription,
        // 绑定用于订阅操作的参数。
        variables() {
          return {
            iid: this.issueId,
          };
        },
        // 实现此方法以返回 true|false 是否应禁用订阅。
        // 在使用功能标志时很有用。
        skip() {
          return this.shouldSkipRealTimeUpdates;
        },
      },
    },
  },
  // 如前所述。
  // ...
  computed: {
    shouldSkipRealTimeUpdates() {
      return false; // 可能在这里检查功能标志。
    },
  },
};

现在您可以通过 Apollo 启用视图组件通过 WebSocket 连接接收更新。接下来,我们将介绍如何从后端触发事件以启动对前端的推送更新。

触发 GraphQL 订阅

编写一个能够通过 WebSocket 接收更新的视图组件只是故事的一半。在 GitLab Rails 应用程序中,我们需要执行以下步骤:

  1. 实现 GraphQL::Schema::Subscription 类。此类:
    • graphql-ruby 用来解析前端发送的 subscription 操作。
    • 定义订阅接受的参数和返回给调用者的负载(如果有)。
    • 运行任何必要的业务逻辑,确保调用者有权创建此订阅。
  2. Types::SubscriptionType 类添加一个新的 field。该字段将 集成 Vue 组件 时使用的事件名称映射到 GraphQL::Schema::Subscription 类。
  3. GraphqlTriggers 添加一个与事件名称匹配的方法,该方法运行相应的 GraphQL 触发器。
  4. 使用服务或 Active Record 模型类作为域逻辑的一部分执行新的触发器。

实现订阅

如果您订阅的事件已经实现为 GraphQL::Schema::Subscription,则此步骤是可选的。否则,在 app/graphql/subscriptions/ 下创建一个新类来实现新订阅。对于响应 Issue 更新的 issueUpdated 事件的示例,订阅实现如下:

module Subscriptions
  class IssueUpdated < BaseSubscription
    include Gitlab::Graphql::Laziness

    payload_type Types::IssueType

    argument :issue_id, Types::GlobalIDType[Issue],
              required: true,
              description: '问题的 ID。'

    def authorized?(issue_id:)
      issue = force(GitlabSchema.find_by_gid(issue_id))

      unauthorized! unless issue && Ability.allowed?(current_user, :read_issue, issue)

      true
    end
  end
end

创建这个新类时:

  • 确保每个订阅类型都继承自 Subscriptions::BaseSubscription
  • 使用适当的 payload_type 来指示订阅的查询可以访问哪些数据,或者定义您想要暴露的单独 field
  • 您还可以定义自定义的 subscribeupdate 钩子,每次客户端订阅或事件触发时都会调用这些方法。有关如何使用这些方法,请参阅官方文档
  • 实现 authorized? 来执行任何必要的权限检查。这些检查在每次调用 subscribeupdate 时都会执行。

有关 GraphQL 订阅类的更多信息,请参阅官方文档

连接订阅

如果您没有实现新的订阅类,请跳过此步骤。

实现新的订阅类后,您必须将其映射到 SubscriptionType 上的 field 才能执行。打开 Types::SubscriptionType 类并添加新字段:

module Types
  class SubscriptionType < ::Types::BaseObject
    graphql_name 'Subscription'

    # 现有字段
    # ...

    field :issue_updated,
      subscription: Subscriptions::IssueUpdated, null: true,
      description: '当问题被更新时触发。'
  end
end

如果您连接的是 EE 订阅,请更新 EE::Types::SubscriptionType

确保 :issue_updated 参数与前端发送的 subscription 请求中使用的驼峰式名称(issueUpdated)匹配,否则 graphql-ruby 不知道应该通知哪些订阅者。现在可以触发事件了。

添加新触发器

如果您可以重用现有触发器,请跳过此步骤。

我们在 GitlabSchema.subscriptions.trigger 周围使用了一个外观(facade)来简化事件触发。将新触发器添加到 GraphqlTriggers

module GraphqlTriggers
  # 现有触发器
  # ...

  def self.issue_updated(issue)
    GitlabSchema.subscriptions.trigger(:issue_updated, { issue_id: issue.to_gid }, issue)
  end
end

如果触发器用于 EE 订阅,请更新 EE::GraphqlTriggers

  • 第一个参数 :issue_updated 必须与上一步中使用的 field 名称匹配。
  • 参数哈希指定了我们发布事件的问题。GraphQL 使用此哈希来识别它应该发布事件的主题。

最后一步是调用这个触发器函数。

执行触发器

此步骤的实现取决于您构建的具体内容。在问题字段变化的示例中,我们可以扩展 Issues::UpdateService 来调用 GraphqlTriggers.issue_updated

实时视图组件现在可以正常工作了。对问题的更新现在应该立即传播到 GitLab UI。

发布实时组件

重用现有的 WebSocket 连接

重用现有连接的功能带来的风险最小。建议使用功能标志发布,以便为自托管客户提供更多控制。但是,没有必要按百分比发布,或为 GitLab.com 估算新连接。

引入新的 WebSocket 连接

任何向 GitLab 应用程序部分引入 WebSocket 连接的更改都会带来一些可扩展性风险,这既影响负责维护开放连接的节点,也影响下游服务,如 Redis 和主数据库。

估算峰值连接

GitLab.com 上第一个完全启用的实时功能是实时指派人。通过将问题页面的峰值吞吐量与峰值同时 WebSocket 连接进行比较,可以粗略估计每秒 1 个页面请求大约会增加 4200 个 WebSocket 连接。

要了解新功能可能产生的影响,请汇总其来源页面的峰值吞吐量(RPS)(n),并应用以下公式:

(n * 4200) / peak_active_connections

这个计算是粗略的,应该随着新功能的部署而修订。它给出了必须支持的容量占现有容量的比例的粗略估计。

当前的活动连接可以在此 Grafana 图表上看到。

分阶段发布

根据当前的饱和度和所需新连接的比例,可能需要配置新容量来支持您的更改。虽然 Kubernetes 在大多数情况下使这相对容易,但对下游服务仍然存在风险。

为此,确保建立新 WebSocket 连接的代码被功能标志标记,并默认设置为 off。谨慎的基于百分比的发布功能标志确保可以在WebSocket 仪表板上观察效果。

  1. 创建一个功能标志发布问题。
  2. 我们期望发生什么部分下添加所需的新连接估算。
  3. 邀请计划和扩展团队的成员来制定基于百分比的发布计划。

GitLab.com 上的实时基础设施

在 GitLab.com 上,WebSocket 连接由专用基础设施提供服务,完全独立于常规 Web 队列,并使用 Kubernetes 部署。这限制了处理请求节点的风险,但不影响共享服务。有关 WebSockets Kubernetes 部署的更多信息,请参见这个 epic

深入了解 GitLab 实时堆栈

由于由服务器发起的推送需要在网络中传播,并在没有任何用户交互的情况下触发客户端视图更新,因此只有通过查看包括前端和后端的整个堆栈,才能理解实时功能。

出于历史原因,响应客户端轮询更改的控制器路由被称为 realtime_changes。它们使用条件 GET 请求,与本指南涵盖的实时行为无关。

任何推送到客户端的实时更新都源自 GitLab Rails 应用程序。我们使用以下技术来发起和处理这些更新:

在 GitLab Rails 后端:

  • Redis PubSub 处理订阅状态。
  • Action Cable 处理 WebSocket 连接和数据传输。
  • graphql-ruby 实现 GraphQL 订阅和触发器。

在 GitLab 前端:

  • Apollo Client 处理 GraphQL 请求、路由和缓存。
  • Vue.js 定义和渲染实时更新的视图组件。

下图说明了数据在这些层之间的传播方式。

sequenceDiagram
    participant V as Vue Component
    participant AP as Apollo Client
    participant P as Rails/GraphQL
    participant AC as Action Cable/GraphQL
    participant R as Redis PubSub
    AP-->>V: injected
    AP->>P: HTTP GET /-/cable
    AC-->>P: Hijack TCP connection
    AC->>+R: SUBSCRIBE(client)
    R-->>-AC: channel subscription
    AC-->>AP: HTTP 101: Switching Protocols
    par
        V->>AP: query(gql)
        Note over AP,P: Fetch initial data for this view
        AP->>+P: HTTP POST /api/graphql (initial query)
        P-->>-AP: initial query response
        AP->>AP: cache and/or transform response
        AP->>V: trigger update
        V->>V: re-render
    and
        Note over AP,AC: Subscribe to future updates for this view
        V->>AP: subscribeToMore(event, gql)
        AP->>+AC: WS: subscribe(event, query)
        AC->>+R: SUBSCRIBE(event)
        R-->>-AC: event subscription
        AC-->>-AP: confirm_subscription
    end
    Note over V,R: time passes
    P->>+AC: trigger event
    AC->>+R: PUBLISH(event)
    R-->>-AC: subscriptions
    loop For each subscriber
        AC->>AC: run GQL query
        AC->>+R: PUBLISH(client, query_result)
        R-->>-AC: callback
        AC->>-AP: WS: push query result
    end
    AP->>AP: cache and/or transform response
    AP->>V: trigger update
    V->>V: re-render

在后续部分中,我们将详细解释此堆栈的每个元素。

Action Cable 和 WebSockets

Action Cable 是一个为 Ruby on Rails 添加 WebSocket 支持的库。WebSocket 是作为 HTTP 友好解决方案而开发的,旨在通过单个 TCP 连接为现有的基于 HTTP 的服务器和应用程序添加双向通信。

客户端首先向服务器发送一个普通的 HTTP 请求,要求将连接升级为 WebSocket。当成功时,相同的 TCP 连接随后可以由客户端和服务器用于双向发送和接收数据。

由于 WebSocket 协议没有规定传输数据的编码或结构方式,我们需要像 Action Cable 这样的库来处理这些问题。Action Cable:

  • 处理从 HTTP 到 WebSocket 协议的初始连接升级。随后使用 ws:// 方案的请求由 Action Cable 服务器处理,而不是 Action Pack。
  • 定义通过 WebSocket 传输的数据的编码方式。Action Cable 将其指定为 JSON。这允许应用程序提供 Ruby Hash 数据,Action Cable 将其序列化和反序列化为 JSON。
  • 提供回调钩子来处理客户端连接或断开连接以及客户端认证。
  • 提供 ActionCable::Channel 作为开发者的抽象层来实现发布/订阅和远程过程调用。

Action Cable 支持不同的实现来跟踪哪个客户端订阅了哪个 ActionCable::Channel。在 GitLab 中,我们使用 Redis 适配器,它使用 Redis PubSub 通道作为分布式消息总线。需要共享存储,因为不同的客户端可能从不同的 Puma 实例连接到同一个 Action Cable 通道。

不要将 Action Cable 通道与 Redis PubSub 通道混淆。Action Cable Channel 对象是一个编程抽象,用于分类和处理通过 WebSocket 连接传输的各种数据。在 Action Cable 中,底层的 PubSub 通道被称为广播(broadcasting),客户端与广播之间的关联被称为订阅(subscription)。特别是,每个 Action Cable Channel 可以有许多广播(PubSub 通道)和订阅。

>

因为 Action Cable 允许我们通过其 Channel API 表达不同类型的行为,并且任何 Channel 的更新都可以使用相同的 WebSocket 连接,所以我们只需要为每个 GitLab 页面建立一个 WebSocket 连接,以在该页面上增强视图组件的实时行为。

要在 GitLab 页面上实现实时更新,我们不编写单独的 Channel 实现。相反,我们提供 GraphqlChannel,所有需要基于推送更新的 GitLab 页面都订阅此通道。

GraphQL 订阅:后端

GitLab 支持 GraphQL,让客户端能够使用 GraphQL 查询从服务器请求结构化数据。有关我们采用 GraphQL 的原因,请参阅 GitLab GraphQL 概述

GitLab 后端的 GraphQL 支持由 graphql-ruby gem 提供。

通常,GraphQL 查询是客户端发起的 HTTP POST 请求,遵循标准的请求-响应周期。对于实时功能,我们使用 GraphQL 订阅,这是发布/订阅模式的实现。在这种方法中,客户端首先向 GraphqlChannel 发送订阅请求,包含:

  • 订阅 field 的名称(事件名称)。
  • 此事件触发时要运行的 GraphQL 查询。

服务器使用此信息创建一个表示此事件流的 topic。主题是从订阅参数和事件名称派生的唯一名称,用于标识所有需要被告知事件触发的订阅者。多个客户端可以订阅同一个主题。例如,issuableAssigneesUpdated:issuableId:<hashed_id> 可能是客户端订阅的主题,他们希望在给定 ID 问题的指派人发生变化时得到更新。

后端负责触发订阅,通常响应于域事件,如"问题添加到史诗"或"用户分配到问题"。在 GitLab 中,这可能是服务对象或 ActiveRecord 模型对象。

触发器通过使用相应的事件名称和参数调用 GitlabSchema.subscriptions.trigger 来执行,从中 graphql-ruby 派生主题。然后它找到该主题的所有订阅者,为每个订阅者执行查询,并将结果推回所有主题订阅者。

因为我们使用 Action Cable 作为 GraphQL 订阅的底层传输,所以主题被实现为 Action Cable 广播,如上所述,这代表 Redis PubSub 通道。这意味着对于每个订阅者,使用两个 PubSub 通道:

  • 每个主题一个 graphql-event:<namespace>:<topic> 通道。此通道用于跟踪哪个客户端订阅了哪个事件,并在所有潜在客户端之间共享。使用 namespace 是可选的,可以为空。
  • 每个客户端一个 graphql-subscription:<subscription-id> 通道。此通道用于将查询结果传回相应的客户端,因此不能在不同客户端之间共享。

下一节描述 GitLab 前端如何使用 GraphQL 订阅实现实时更新。

GraphQL 订阅:前端

因为 GitLab 前端执行的是 JavaScript 而不是 Ruby,我们需要不同的 GraphQL 实现来将 GraphQL 查询、变更和订阅从客户端发送到服务器。我们使用 Apollo 来实现这一点。

Apollo 是 JavaScript 中 GraphQL 的全面实现,分为 apollo-serverapollo-client 以及其他实用程序模块。因为我们运行 Ruby 后端,所以我们使用 apollo-client 而不是 apollo-server

它简化了:

  • 网络、连接管理和请求路由。
  • 客户端状态管理和响应缓存。
  • 使用桥接模块将 GraphQL 与视图组件集成。

阅读 Apollo Client 文档时,它假设使用 React.js 进行视图渲染。我们在 GitLab 中不使用 React.js。我们使用 Vue.js,它使用 Vue.js 适配器 与 Apollo 集成。

>

Apollo 提供函数和钩子,您可以用它们定义:

  • 视图如何发送查询、变更或订阅。
  • 应该如何处理响应。
  • 如何缓存响应数据。

入口点是 ApolloClient,这是一个 GraphQL 客户端对象:

  • 在单个页面的所有视图组件之间共享。
  • 所有视图组件内部使用它与服务器通信。

为了决定不同类型的请求应该如何路由,Apollo 使用 ApolloLink 抽象。具体来说,它使用 ActionCableLink 将实时服务器订阅与其他 GraphQL 请求分开。这:

  • 建立 Action Cable 的 WebSocket 连接。
  • 将服务器推送映射到客户端中的 Observable 事件流,视图可以订阅这些事件流以更新自身。

有关 Apollo 和 Vue.js 的更多信息,请参阅 GitLab GraphQL 开发指南