7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

React + TypeScript: Apollo ClientのサブスクリプションによりGraphQLサーバーからリアルタイムに更新を取得する

Posted at

Apollo ClientはReactで使える状態管理ライブラリです。ローカルとリモートのデータをGraphQLで扱えます。本稿は公式サイトの「Subscriptions - Get real-time updates from your GraphQL server」にもとづいて、GraphQLサーバーからデータをリアルタイムにどう更新するかについての解説です。Apollo Clientでクエリを使うための基礎はすでに学んだことが前提となります(まだの方は先に「React + TypeScript: Apollo ClientのGraphQLクエリを使ってみる」をお読みください)。ドキュメントの邦訳ではなく、日本語で説明し直しました。原文から省いた部分もあり、逆にわかりにくいところは補っています。

GraphQLがクエリ変更に加えてサポートする3つめの操作がサブスクリプションです。

クエリと同じく、サブスクリプションはデータをフェッチできます。クエリと違うのは、サブスクリプションが長期間にわたる操作だということです。時間の経過とともに結果を変更できます。GraphQLサーバーとのアクティブな接続が維持されるのです(もっとも一般的なのはWebSocket経由)。サブスクリプションの結果に、サーバーから更新をプッシュできます。

バックエンドデータの変更をリアルタイムでクライアントに通知できるのが、サブスクリプションの便利な点です。新しいオブジェクトの作成や重要なフィールドの更新などが考えられます。

サブスクリプションをいつ使うか

ほとんどの場合、クライアントがサブスクリプションによりバックエンドの最新情報を取得することはお勧めできません。pollIntervalを使えば一定の時間間隔で問い合わせできます。あるいは、refetch関数により、何らかのアクション(たとえば、ボタンクリック)に応じて、必要なクエリを再実行してもよいでしょう(「キャッシュしたクエリの結果を更新する」参照)。

サブスクリプションを用いるのは、つぎのような場合です。

  • 大きなオブジェクトの小さく段階的な変更
    大きなオブジェクトを繰り返し更新するのは、とくにそのフィールドのほとんどがほぼ変わらない場合は高コストです。そのようなとき、オブジェクトの初期状態はクエリで取得しておき、サーバーから個々のフィールドの更新をプロアクティブにプッシュできます。
  • 遅延の少ないリアルタイムの更新
    たとえば、チャットアプリケーションのクライアントは、新しいメッセージが入手可能になり次第、それを受け取りたいでしょう。

サブスクリプションライブラリを選ぶ

GraphQLの仕様には、サブスクリプションリクエストを送るための特定のプロトコルは定義されていません。WebSocke上でサブスクリプションを実装したApollo Clientがサポートするライブラリには、graphql-wsがあります。このあとご説明するのは、graphql-wsの使い方です。

サブスクリプションを定義する

クエリや変更と同じく、サブスクリプションはサーバーサイドとクライアントサイドの両方で定義します。

サーバーサイド

GraphQLスキーマで使用可能なサブスクリプションをSubscription型で定めてください。つぎのcommentAddedは、特定のブログ記事(postIDで指定)に新しいコメントが加えられるたびにサブスクライブしているクライアントに通知するサブスクリプションです。

type Subscription {
	commentAdded(postID: ID!): Comment
}

サーバー側でサブスクリプションのサポートを実装する方法について、詳しくは「Subscriptions in Apollo Server」をご参照ください。

クライアントサイド

アプリケーションのクライアントでは、Apollo Clientで実行する各サブスクリプションの形状を、つぎのように定めてください。

const COMMENTS_SUBSCRIPTION = gql`
	subscription OnCommentAdded($postID: ID!) {
		commentAdded(postID: $postID) {
			id
			content
		}
	}
`;

Apollo ClientがOnCommentAddedサブスクリプションを実行すると、GraphQLサーバーへの接続が確立され、応答データを待ち受けます。クエリとは異なり、サーバーが直ちに処理して応答を返すわけではありません。サーバーがデータをクライアントにプッシュするのは、バックエンドで特定のイベントが発生したときのみです。

GraphQLサーバーがデータをサブスクライブしているクライアントにプッシュするときはつねに、データはクエリの場合と同じく実行されたサブスクリプションの構造にしたがいます。

{
	"data": {
		"commentAdded": {
			"id": "123",
			"content": "What a thoughtful and well written post!"
		}
	}
}

トランスポートを設定する

サブスクリプションは通常、つねに接続を保つため、ApolloClientがクエリと変更に用いるデフォルトのHTTPトランスポートは使わないでください。 Apolloクライアントサブスクリプションでもっとも一般的なのは、graphql-wsライブラリを介してWebSocketで通信することです。

1. 必要なライブラリをインストールする

Apollo Linkは、Apolloクライアントのネットワーク通信をカスタマイズするのに役立つライブラリです。 このライブラリでリンクチェーンを定義すれば、操作を変更したり、適切な宛先にルーティングできます。

WebSocketを介してサブスクリプションを実行するため、リンクチェーンに加えるのがGraphQLWsLinkです。 このリンクに必要なgraphql-wsライブラリを、つぎのようにインストールしてください。

npm install graphql-ws

2. GraphQLWsLinkを初期化する

ApolloClientを初期化した同じプロジェクトファイルに、GraphQLWsLinkオブジェクトをインポートして初期化します。

index.js
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

const wsLink = new GraphQLWsLink(createClient({
	url: 'ws://localhost:4000/subscriptions',
}));

urlオプションの値は、GraphQLサーバーのサブスクリプション固有のWebSocketエンドポイントに書き替えます。 Apollo Serverをお使いの場合の設定は、「Subscriptions in Apollo Server」の「Enabling subscriptions」をご参照ください。

操作によって通信を分ける(推奨)

Apolloクライアントは、GraphQLWsLinkを用いてすべてのタイプの操作が実行できます。けれどほとんどの場合、クエリと変更には引き続きHTTPを使うのがよいでしょう。 クエリと変更はステートフルでなく、長時間接続し続ける必要もありません。WebSocket接続がまだされていないときに、HTTPをより効率的かつスケーラブルにできるからです。

通信の切り分けをサポートするために、@apollo/clientライブラリにはsplit関数が備わっています。ブール値で確かめた結果に応じて、ふたつの異なるLinkのうちのひとつが使えるのです。

つぎのコードは、前の例を拡張して、GraphQLWsLinkHttpLinkの両方を初期化しました。 そして、split関数を使用して、ふたつのLinkをひとつにまとめています。ふたつのうちどちらを使うかは、実行される操作のタイプに応じて変わるのです。

index.js
import { split, HttpLink } from '@apollo/client';
import { getMainDefinition } from '@apollo/client/utilities';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

const httpLink = new HttpLink({
	uri: 'http://localhost:4000/graphql'
});

const wsLink = new GraphQLWsLink(createClient({
	url: 'ws://localhost:4000/subscriptions',
}));

// split関数の3つの引数
// [1]各操作が実行されるたびに呼び出される関数
// [2]関数の戻り値が真のとき操作に用いるリンク
// [3]関数の戻り値が偽のとき操作に用いるリンク
const splitLink = split(
	({ query }) => {
		const definition = getMainDefinition(query);
		return (
			definition.kind === 'OperationDefinition' &&
			definition.operation === 'subscription'
		);
	},
	wsLink,
	httpLink,
);

このロジックによって、クエリと変更は通常どおりHTTP(httpLink)を用い、サブスクリプションはWebSocket(wsLink)を使います。

4. Apollo Clientにリンクチェーンを与える

ふたつのLinkをまとめたリンクチェーン(splitLink)を定義したら、LinkコンストラクタオプションでApollo Clientに与えます。

index.js
import { ApolloClient, InMemoryCache } from '@apollo/client';

// ...[前掲コードは省略]...

const client = new ApolloClient({
	link: splitLink,
	cache: new InMemoryCache()
});

[注記] linkオプションの指定は、uriオプションよりも優先されます(uriに与えたURLが用いられるのはデフォルトのHTTPリンクチェーンとしてです)。

WebSocketで認証する(オプション)

サブスクリプション結果の受信は、クライアントを認証してから許可すべきことが少なくありません。 そのためにはつぎのように、graphQLWsLinkコンストラクターにconnectionParamsオプションを与えます。

import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import { createClient } from 'graphql-ws';

const wsLink = new GraphQLWsLink(createClient({
	url: 'ws://localhost:4000/subscriptions',
	connectionParams: {
		authToken: user.authToken,
	},
}));

GraphQLWsLinkは、接続するたびにconnectionParamsオブジェクトをサーバーに渡します。 サーバーは受け取ったconnectionParamsオブジェクトを用いて、認証や他の接続関連のタスクが実行されるのです。

サブスクリプションを実行する

Reactからサブスクリプションを実行するために用いるのが、Apollo ClientのuseSubscriptionフックです。 useQueryと同じく、useSubscriptionはApollo Clientから結果オブジェクトを返します。その中に収められたloadingerrorおよびdataプロパティをUIのレンダリングに使うのです。

つぎのコードのコンポーネント(LatestComment)は、前の例ですでに定義したサブスクリプションを使います。指定したブログ投稿に加えられた最新のコメントをレンダリングするためです。 GraphQLサーバーが新しいコメントをクライアントにプッシュするたびに、コンポーネントは新しいコメントとともに再描画されます。

const COMMENTS_SUBSCRIPTION = gql`
	subscription OnCommentAdded($postID: ID!) {
		commentAdded(postID: $postID) {
			id
			content
		}
	}
`;

function LatestComment({ postID }) {
	const { data, loading } = useSubscription(
		COMMENTS_SUBSCRIPTION,
		{ variables: { postID } }
	);
	return <h4>New comment: {!loading && data.commentAdded.content}</h4>;
}

クエリの更新をサブスクライブする

ApolloClientでクエリが結果を返すとき、そこに必ず含まれるのがsubscribeToMore関数です。 この関数でフォローアップサブスクリプションを実行すると、クエリのもとの結果に更新がプッシュできます。

[注記] subscribeToMore関数の構造は、ページネーション処理によく使われるfetchMore関数と似ています。 おもな違いは実行する操作で、fetchMoreがフォローアップクエリなのに対し、subscribeToMoreはサブスクリプションだということです。

まずは、標準的なクエリのコード例から始めましょう。特定のブログ記事への既存のコメントをすべて取得します。

const COMMENTS_QUERY = gql`
	query CommentsForPost($postID: ID!) {
		post(postID: $postID) {
			comments {
				id
				content
			}
		}
	}
`;

function CommentsPageWithData({ params }) {
	const result = useQuery(
		COMMENTS_QUERY,
		{ variables: { postID: params.postID } }
	);
	return <CommentsPage {...result} />;
}

さてここで、記事に新たなコメントが加えられたら、すぐにGraphQLサーバーからクライアントに更新をプッシュさせたいとしましょう。まず必要なのは、サブスクリプションの定義です。COMMENTS_QUERYが返ってきたら、Apollo Clientにこのサブスクリプションを実行させます。

const COMMENTS_SUBSCRIPTION = gql`
	subscription OnCommentAdded($postID: ID!) {
		commentAdded(postID: $postID) {
			id
			content
		}
	}
`;

つぎに、CommentsPageWithData関数の修正です。戻り値のCommentsPageコンポーネントにsubscribeToNewCommentsプロパティを加えます。このプロパティに定めるのは、subscribeToMoreを実行する関数です。このあと、コンポーネントがマウントされたら(componentDidMount())関数を呼び出すようにします。

function CommentsPageWithData({ params }) {
	const { subscribeToMore, ...result } = useQuery(
		COMMENTS_QUERY,
		{ variables: { postID: params.postID } }
	);

	return (
		<CommentsPage
			{...result}
			subscribeToNewComments={() =>
				subscribeToMore({
					document: COMMENTS_SUBSCRIPTION,
					variables: { postID: params.postID },
					updateQuery: (prev, { subscriptionData }) => {
						if (!subscriptionData.data) return prev;
						const newFeedItem = subscriptionData.data.commentAdded;

						return Object.assign({}, prev, {
							post: {
								comments: [newFeedItem, ...prev.post.comments]
							}
						});
					}
				})
			}
		/>
	);
}

このコード例で、subscribeToMoreの引数オブジェクトに与えたオプションはつぎの3つです。

  • document - 実行するサブスクリプション。
  • variables - サブスクリプションを実行するときに含める変数。
  • updateQuery - クエリの結果をどうまとめるかApollo Clientに指示する関数。
    • 引数 - オプションオブジェクト。
      • prev - クエリの現在キャッシュされている結果。
      • subscriptionData - GraphQLサーバーからプッシュされたデータ。
    • 戻り値 - クエリの現在のキャッシュ結果を完全に上書きする値。

最後に、クラスコンポーネントCommentsPageの定義で、コンポーネントがマウントされたとき(componentDidMount())にsubscribeToNewCommentsを実行するよう定めます。

export class CommentsPage extends Component {
	componentDidMount() {
		this.props.subscribeToNewComments();
	}
}

useSubscriptionAPIリファレンス

[注記] React ApolloのSubscriptionレンダープロップコンポーネントを使った場合も、以下の表のオプション/結果の内容はそのまま有効です(オプションはコンポーネントプロップで、結果はレンダープロップ関数に渡されます)。 ひとつだけ違うのは、subscriptionプロップ(gqlがASTに解析したGraphQLサブスクリプションドキュメントを保持する)も必要なことです。

レンダープロップコンポーネントには、Subscriptionに加えて、QueryMutationがあります。けれど、「Extending components」によれば、関数コンポーネントとともにそれぞれuseSubscriptionuseQuery、およびuseMutationを用いるのがこれからの推奨です。

オプション

オプション 説明
subscription DocumentNode graphql-tagがASTに解析したGraphQLサブスクリプションドキュメント。useSubscriptionフックでは、省略できる。サブスクリプションはフックの第1引数として渡せるため。Subscriptionコンポーネントでは必須。
variables { [key: string]: any } サブスクリプションが実行する必要のあるすべての変数を含むオブジェクト。
shouldResubscribe boolean サブスクリプションを解除して再度サブスクライブする必要があるかどうか。
onSubscriptionData (options: OnSubscriptionDataOptions) => any useSubscriptionフック/Subscriptionコンポーネントがデータを受け取るたびに呼び出されるコールバック関数の登録先。コールバックのoptionsオブジェクトは、client内の現在のApollo Clientインスタンスのパラメータ、およびsubscriptionDataで受け取ったサブスクリプションデータにより構成される。
fetchPolicy FetchPolicy コンポーネントがApolloキャッシュとどのように相互作用するかを指定する(「Setting a fetch policy」および「フェッチポリシーをカスタマイズする」参照)。
context Record コンポーネントとネットワークインタフェース(Apollo Link)間の共有コンテクスト。
client ApolloClient ApolloClientインスタンス。デフォルトでは、useSubscription/Subscriptionは、コンテクストから渡されたクライアントを用いる。別のクライアントを渡すことも可能。

結果

useSubscriptionフックは、呼び出されたあと、つぎの表のプロパティが備わった結果オブジェクトを返します。

プロパティ 説明
data TData GraphQLサブスクリプションの結果を含むオブジェクト。デフォルトは空のオブジェクト。
loading boolean 初期データが返されたかどうかを示すブール値。
error ApolloError graphQLErrorsおよびnetworkErrorプロパティで発生したランタイムエラー。
7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?