リアルタイムビューコンポーネントのビルドとデプロイ
GitLab は、ユーザーからの入力を受け付け、状態の変更をユーザーに反映する個々のビューコンポーネントを通して、インタラクティブなユーザー体験を提供します。例えば、マージリクエストページでは、ユーザーは承認したり、コメントを残したり、CI/CDパイプラインとやり取りしたりすることができます。
しかし、GitLab は状態の更新をタイムリーに反映しないことがよくあります。これは、ページの一部が古いデータを表示し、ユーザーがページをリロードした後にのみ更新されることを意味します。
このアドレスに対応するため、GitLabはWebSocketを介してビューコンポーネントがリアルタイムで状態の更新を受け取ることができる技術とプログラミングAPIを導入しました。
以下のドキュメントでは、GitLab Ruby on Railsサーバからリアルタイムで更新を受け取るビューコンポーネントをビルドしてデプロイする方法を説明します。
#f_real-time
内部 Slack チャンネルで助けを求めてください。リアルタイムビューコンポーネントの構築
前提条件:
を読んでください:
- GraphQL開発ガイド。
- Vue開発ガイド。
GitLabでリアルタイム・ビュー・コンポーネントを構築するには、以下の手順が必要です:
- VueコンポーネントをGitLabフロントエンドのApolloサブスクリプションとインテグレーションします。
- GitLab Ruby on RailsバックエンドからGraphQLサブスクリプションを追加し、トリガーします。
VueコンポーネントとApolloサブスクリプションのインテグレーション
GitLabIssue
のデータを観察し、レンダリングするIssueView
Vue コンポーネントを仮想的に考えてみましょう。ここでは簡単のため、イシューのタイトルと説明をレンダリングするだけだと仮定します:
import issueQuery from '~/issues/queries/issue_view.query.graqhql';
export default {
props: {
issueId: {
type: Number,
required: false,
default: null,
},
},
apollo: {
// Name of the Apollo query object. Must match the field name bound by `data`.
issue: {
// Query used for the initial fetch.
query: issueQuery,
// Bind arguments used for the initial fetch query.
variables() {
return {
iid: this.issueId,
};
},
// Map response data to view properties.
update(data) {
return data.project?.issue || {};
},
},
},
// Reactive Vue component data. Apollo updates these when queries return or subscriptions fire.
data() {
return {
issue: {}, // It is good practice to return initial state here while the view is loading.
};
},
};
// The <template> code is omitted for brevity as it is not relevant to this discussion.
クエリは次のようにします:
-
app/assets/javascripts/issues/queries/issue_view.query.graqhql
で定義されていること。 -
次の GraphQL オペレーションをコンテナします:
query gitlabIssue($iid: String!) { # We hard-code the path here only for illustration. Don't do this in practice. project(fullPath: "gitlab-org/gitlab") { issue(iid: $iid) { title description } } }
これまでのところ、このビューコンポーネントはそれ自体にデータを入力するための最初のフェッチクエリのみを定義しています。これは、ビューによって開始される HTTP POST リクエストとして送信される通常の GraphQLquery
オペレーションです。その後のサーバーでの更新は、このビューを古いものにしてしまいます。このビューがサーバーからの更新を受け取るには、次のことが必要です:
- GraphQLサブスクリプション定義を追加します。
- Apolloサブスクリプションフックを定義します。
GraphQLサブスクリプション定義の追加
サブスクリプションは同様にGraphQLクエリを定義しますが、GraphQLsubscription
オペレーションの内部にラップされます。このクエリはバックエンドによって開始され、その結果は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
を付けてください。例えば、issueUpdatedSubscription
、またはissueUpdatedSubscriptionEE
。 - サブスクリプションのイベント名に “has happened” アクション動詞を使用してください。例えば、
issueUpdated
。
サブスクリプション定義は通常のクエリと似ていますが、理解しておくべき重要な違いがいくつかあります:
-
query
:- フロントエンドから発信されています。
- 内部 ID (
iid
, 数値) を使用します。これは、エンティティが通常 URL で参照される方法です。内部 ID は囲むネームスペース (この例ではproject
) からの相対なので、クエリはfullPath
の下に入れ子にする必要があります。
-
subscription
:- フロントエンドからバックエンドへの、将来のアップデートを受け取るためのリクエストです。
- で構成されます:
- サブスクリプション自身を記述するオペレーション名( この例では
issueUpdatedSubscription
)。 - ネストされたイベントクエリ( この例では
issueUpdated
)。ネストされたイベントクエリ:- サブスクリプションで使用されるイベント名は、バックエンドで使用されるトリガーフィールドと一致しなければなりません。
- GraphQLでリソースを識別するための望ましい方法である、数値の内部IDの代わりにグローバルID文字列を使用します。詳細については、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 {
// As before.
// ...
apollo: {
issue: {
// As before.
// ...
// This Apollo hook enables real-time pushes.
subscribeToMore: {
// Subscription operation that returns future updates.
document: issueUpdatedSubscription,
// Bind arguments used for the subscription operation.
variables() {
return {
iid: this.issueId,
};
},
// Implement this to return true|false if subscriptions should be disabled.
// Useful when using feature-flags.
skip() {
return this.shouldSkipRealTimeUpdates;
},
},
},
},
// As before.
// ...
computed: {
shouldSkipRealTimeUpdates() {
return false; // Might check a feature flag here.
},
},
};
これで、ビューコンポーネントがApolloを通じてWebSocket接続で更新を受信できるようになります。次に、バックエンドからイベントをトリガーして、フロントエンドへのプッシュ更新を開始する方法について説明します。
GraphQLサブスクリプションのトリガー
WebSocketから更新を受け取れるビューコンポーネントを書くことは、物語の半分に過ぎません。GitLab Railsアプリケーションでは、以下のステップを実行する必要があります:
-
GraphQL::Schema::Subscription
クラスを実装します。このクラスは- フロントエンドから送られた
subscription
オペレーションを解決するためにgraphql-ruby
によって使われます。 - サブスクリプションが受け取る引数と、もしあれば呼び出し元に返されるペイロードを定義します。
- 呼び出し元がこのサブスクリプションを作成する権限があることを確認するために必要なビジネスロジックを実行します。
- フロントエンドから送られた
-
Types::SubscriptionType
クラスに新しいfield
を追加します。このフィールドは、VueコンポーネントをGraphQL::Schema::Subscription
クラスにインテグレーションする際に使用するイベント名をマッピングします。 - イベント名に一致するメソッドを
GraphqlTriggers
に追加し、対応する GraphQL トリガーを実行します。 - サービスまたは 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 of the issue.'
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
を定義してください。 - また、クライアントがサブスクライブしたりイベントが発生したりするたびに呼び出されるカスタムフック
subscribe
やupdate
を定義することもできます。これらのメソッドの使用方法については、公式ドキュメントを参照してください。 -
authorized?
を実装して、必要な権限チェックを実行します。これらのチェックは、subscribe
またはupdate
を呼び出すたびに実行されます。
公式ドキュメントでGraphQLサブスクリプションクラスの詳細をお読みください。
サブスクリプションのフックアップ
新しいサブスクリプションクラスを実装しなかった場合、このステップはスキップします。
新しいサブスクリプション・クラスを実装したら、そのクラスを実行する前にSubscriptionType
上のfield
にマップする必要があります。Types::SubscriptionType
クラスを開き、新しいフィールドを追加します:
module Types
class SubscriptionType < ::Types::BaseObject
graphql_name 'Subscription'
# Existing fields
# ...
field :issue_updated,
subscription: Subscriptions::IssueUpdated, null: true,
description: 'Triggered when an issue is updated.'
end
end
EE::Types::SubscriptionType
を更新します。:issue_updated
graphql-ruby
引数が、フロントエンドからキャメルケースで送信されたsubscription
リクエストで使用された名前 (issueUpdated
) と一致していることを確認してください。イベントがトリガーできるようになりました。
新しいトリガーを追加します。
既存のトリガを再利用できる場合は、このステップはスキップしてください。
イベントのトリガを簡単にするために、GitlabSchema.subscriptions.trigger
のファサードを使います。新しいトリガーをGraphqlTriggers
に追加します:
module GraphqlTriggers
# Existing triggers
# ...
def self.issue_updated(issue)
GitlabSchema.subscriptions.trigger(:issue_updated, { issue_id: issue.to_gid }, issue)
end
end
EE::GraphqlTriggers
。- 最初の引数
:issue_updated
は、前のステップで使用されたfield
の名前と一致しなければなりません。 - 引数の hash は、イベントを発行するイシューを指定します。GraphQL はこのハッシュを使用して、イベントをパブリッシュするトピックを識別します。
最後のステップは、このトリガー関数を呼び出すことです。
トリガの実行
このステップの実装は、構築するものによって異なります。イシューのフィールドが変更される例では、Issues::UpdateService
を拡張してGraphqlTriggers.issue_updated
を呼び出すことができます。
これで、リアルタイムビューコンポーネントが機能するようになりました。これで、イシューの更新が GitLab UI に即座に反映されるようになりました。
リアルタイムビューコンポーネントのデプロイ
WebSocketはGitLabでは比較的新しい技術であり、大規模なサポートにはいくつかの課題があります。そのため、新機能の導入は以下の手順で行ってください。
リアルタイムコンポーネントの出荷
WebSocket経由の更新は、必要なバックエンドのコードが配置されていないとシミュレーションが難しいため、フロントエンドとバックエンドを同時に作業することができます。
しかし、別々のマージリクエストで変更を送信し、バックエンドの変更を最初にデプロイするほうが安全です。こうすることで、フロントエンドがイベントの購読を開始したときに、バックエンドがそのイベントに対応できるようになります。
既存の 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ダッシュボードで効果を確認することができます。
- 機能フラグのロールアウトイシューを作成します。
- What are we expected to happenセクションに、新たに必要となる接続の見積もりを追加してください。
- 計画チームとスケーラビリティ・チームのメンバーをコピーして、パーセンテージ・ベースのロールアウト計画を見積もります。
下位互換性
機能フラグのロールアウト期間中およびその後無期限は、リアルタイム機能に下位互換性があるか、少なくともグレースフルに劣化する必要があります。すべての顧客がアクション・ケーブルを有効にしているわけではなく、アクション・ケーブルをデフォルトで有効にするには、さらなる作業が必要です。
リアルタイムを要件とすることは、大きな変更を意味するため、次の機会はバージョン15.0です。
GitLab.comのリアルタイム・インフラストラクチャ
GitLab.comでは、WebSocket接続は通常のWebフリートとは完全に分離された専用インフラから提供され、Kubernetesでデプロイされます。これにより、リクエストを処理するノードに対するリスクは制限されますが、共有サービスに対するリスクは制限されません。WebSockets Kubernetesデプロイの詳細については、こちらのエピックをご覧ください。
GitLabリアルタイムスタックの詳細
サーバーから開始されたプッシュはネットワークを伝搬し、ユーザーとのインタラクションなしにクライアントのビュー更新をトリガーする必要があります。
realtime_changes
と呼ばれます。これらは条件付きGETリクエストを使用し、このガイドで扱うリアルタイムの動作とは無関係です。クライアントにプッシュされるリアルタイムの更新は、すべて GitLab Rails アプリケーションから発信されます。これらの更新を開始し、サービスを提供するために、私たちは以下の技術を使っています:
GitLab Railsバックエンド:
- Redis PubSubでサブスクリプションの状態を処理します。
- WebSocket接続とデータ転送を処理するアクションケーブル。
-
graphql-ruby
GraphQL サブスクリプションとトリガーを実装します。
GitLabフロントエンドで:
- GraphQLリクエスト、ルーティング、キャッシュを処理するApolloクライアント。
- リアルタイムで更新されるビューコンポーネントを定義してレンダリングするVue.js。
次の図は、これらのレイヤー間でデータがどのように伝搬するかを示しています。
以降のセクションでは、このスタックの各要素について詳しく説明します。
アクションケーブルと WebSocket
アクションケーブルは、Ruby on RailsにWebSocketサポートを追加するライブラリです。WebSocketは、既存のHTTPベースのサーバやアプリケーションを、単一のTCPコネクションを介した双方向通信で強化するHTTPフレンドリーなソリューションとして開発されました。クライアントはまずサーバに通常のHTTPリクエストを送信し、代わりに接続をWebSocketにアップグレードするよう依頼します。成功すると、クライアントとサーバーの両方が同じTCP接続を使用して、双方向でデータを送受信できます。
WebSocket プロトコルは、送信データのエンコード方法や構造化方法を規定していないため、Action Cable のようなライブラリが必要です。アクション・ケーブル
- HTTP から WebSocket プロトコルへの最初の接続アップグレードを処理します。その後、
ws://
スキームを使用するリクエストは、Action Pack ではなく Action Cable サーバーによって処理されます。 - WebSocket 経由で送信されるデータのエンコード方法を定義します。アクションケーブルでは、JSON を指定します。これにより、アプリケーションはデータを Ruby Hash として提供し、Action Cable はそれを JSON から(または JSON へ)シリアライズします。
- クライアントの接続や切断、クライアント認証を処理するコールバック・フックを提供します。
- 開発者がパブリッシュ/サブスクライブやリモートプロシージャコールを実装するための抽象化として
ActionCable::Channel
を提供します。
アクション・ケーブルは、どのクライアントがどのActionCable::Channel
を購読しているかを追跡するためのさまざまな実装をサポートしています。GitLabでは、分散メッセージバスとしてRedis PubSubチャンネルを使用するRedisアダプターを使用しています。異なるクライアントが異なるPumaインスタンスから同じAction Cableチャネルに接続する可能性があるため、共有ストレージが必要です。
Channel
オブジェクトは、WebSocket接続を介して送信されるさまざまな種類のデータを分類して処理するためのプログラミング抽象化 Channel
です。Channel
Action Cableでは、基礎となるPubSubチャネルは代わりにブロードキャストと呼ばれ、クライアントとブロードキャストの関連付けはサブスクリプションと呼ばれます。特に、各アクションケーブルには多くのブロードキャスト(PubSubチャネル)とサブスクリプションが存在する可能性が Channel
あります。アクションケーブルは、そのChannel
APIを通じて Channel
さまざまな種類の動作を表現することができChannel
、どの更新も Channel
同じWebSocket接続を使用することができるため、GitLabページごとに単一のWebSocket接続を確立するだけで、そのページ上のビューコンポーネントをリアルタイムの動作で拡張することができます。
GitLabページにリアルタイム更新を実装するために、Channel
の実装を個別に書くことはしません。その代わりに、GitLabでプッシュベースの更新を必要とするすべてのページがサブスクライブするGraphqlChannel
。
GraphQLサブスクリプション:バックエンド
GitLabはGraphQLをサポートしており、クライアントはGraphQLクエリを使ってサーバーに構造化データをリクエストすることができます。GraphQLを採用した理由についてはGitLab GraphQL overviewを参照してください。GitLabバックエンドのGraphQLサポートはgraphql-ruby
gemによって提供されています。
通常、GraphQLクエリはクライアント主導のHTTP POSTリクエストで、標準的なリクエスト-レスポンスサイクルに従います。リアルタイムの機能のために、私たちは代わりにGraphQLサブスクリプションを使用し、これはpublish/subscribeパターンの実装です。このアプローチでは、クライアントはまずGraphqlChannel
にサブスクリプション リクエストを送信します:
- サブスクリプションの名前
field
(イベント名)。 - このイベントがトリガーされたときに実行するGraphQLクエリ。
この情報は、サーバーがこのイベントストリームを表すtopic
を作成するために使用されます。トピックは、サブスクリプションの引数とイベント名から派生した一意の名前で、イベントがトリガーされた場合に通知される必要があるすべてのサブスクライバーを識別するために使用されます。複数のクライアントが同じトピックをサブスクライブすることができます。例えば、issuableAssigneesUpdated:issuableId:<hashed_id>
は、指定されたIDを持つイシューの担当者が変更されるたびに更新を受けたい場合に、クライアントがサブスクライブするトピックとなります。
バックエンドは、”issue added to epic “や “user assigned to issue “のようなドメインイベントに応じてサブスクリプションを開始します。GitLabでは、これはサービスオブジェクトかActiveRecordモデルオブジェクトになります。トリガーは、GitlabSchema.subscriptions.trigger
にそれぞれのイベント名と引数を指定して呼び出すことで実行され、graphql-ruby
からトピックが導出されます。そして、このトピックのすべてのサブスクライバを見つけ、各サブスクライバに対してクエリを実行し、その結果をすべてのトピックサブスクライバにプッシュバックします。
GraphQLサブスクリプションの基礎となるトランスポートとしてAction Cableを使用するため、トピックはAction Cableブロードキャストとして実装されます。つまり、各サブスクライバに対して2つのPubSubチャネルが使用されます:
- 各トピックにつき、
graphql-event:<namespace>:<topic>
。このチャネルは、どのクライアントがどのイベントをサブスクライブしているかを追跡するために使用され、すべての潜在的なクライアント間で共有されます。namespace
の使用はオプションで、空白にすることもできます。 - 各クライアントにつき1つの
graphql-subscription:<subscription-id>
チャネル。このチャネルはクエリ結果をそれぞれのクライアントに送り返すために使用され、したがって異なるクライアント間で共有することはできません。
次のセクションでは、GitLabフロントエンドがGraphQLサブスクリプションを使用してリアルタイム更新を実装する方法について説明します。
GraphQL サブスクリプションフロントエンド
GitLab フロントエンドは Ruby ではなく JavaScript を実行するので、GraphQL クエリや変異、サブスクリプションをクライアントからサーバーに送信するには別の GraphQL 実装が必要です。そのためにApolloを使います。
ApolloはJavaScriptにおけるGraphQLの包括的な実装であり、apollo-server
andやapollo-client
追加のユーティリティモジュールに分かれて apollo-client
apollo-server
います。apollo-server
apollo-client
私たちはRubyバックエンドを実行しているため apollo-client
apollo-server
、apollo-server
.NET Frameworkの代わりに apollo-server
.NET Frameworkをapollo-server
apollo-client
使用して apollo-client
apollo-server
います。
これは単純化します:
- ネットワーキング、接続管理、リクエストルーティング。
- クライアント側の状態管理とレスポンスのキャッシュ
- ブリッジモジュールを使用したGraphQLとビューコンポーネントのインテグレーション。
Vue.jsは、Vue.jsアダプタを使用してApolloと統合します。Apolloは関数とフックを提供しており、これを使用してどのようにVue.jsを定義するかを定義します:
- ビューがクエリ、変異、サブスクリプションを送信する方法を定義する関数とフックを提供します。
- 応答は処理されるべきです。
- レスポンスデータはキャッシュされます。
エントリ ポイントはApolloClient
で、これは GraphQL クライアント オブジェクトです:
- 単一ページ上のすべてのビュー コンポーネント間で共有されます。
- すべてのビューコンポーネントは内部でサーバとの通信に使用します。
異なるタイプのリクエストをどのようにルーティングすべきかを決定するために、ApolloはApolloLink
抽象を使用します。具体的には、ActionCableLink
を使用して、リアルタイムのサーバー サブスクリプションを他のGraphQLリクエストから分割します:
- Action CableへのWebSocket接続を確立します。
- マップサーバーは、ビューが自分自身を更新するためにサブスクライブできるクライアント内の
Observable
イベントストリームにプッシュします。
ApolloとVue.jsの詳細については、GitLab GraphQL開発ガイドを参照してください。