使用React和TypeScript:通过Apollo Client的Subscription从GraphQL服务器实时获取更新

Apollo Client是一個在React中使用的狀態管理庫。它可以處理本地和遠程的數據,並使用GraphQL。本文是根據官方網站上的「Subscriptions – Get real-time updates from your GraphQL server」來解釋如何從GraphQL服務器實時更新數據的。閱讀本文的前提是已經學習了在Apollo Client中使用查詢的基礎知識(如果還沒有,請先閱讀「React + TypeScript: Apollo ClientのGraphQLクエリを使ってみる」)。這不是文件的翻譯,而是用日語重新解釋。部分內容已省略,並補充了一些不太容易理解的地方。

GraphQL支持的第三种操作是订阅,它可以对查询和变更进行同时处理。

与查询一样,订阅也可以获取数据。与查询不同的是,订阅是一个长期操作,可以随时间的推移而改变结果。它保持与GraphQL服务器的活动连接(最常见的是通过WebSocket)。可以通过订阅结果从服务器推送更新。

能够实时通知客户端后端数据的变更是订阅的便利之处。这包括创建新对象和更新重要字段等操作。

什么时候使用订阅呢?

大多数情况下,建议客户端不要使用订阅来获取后端的最新信息。您可以使用pollInterval以固定时间间隔进行查询。或者,您也可以使用refetch函数,在某些操作(例如按钮点击)触发时重新执行所需的查询(请参考“更新缓存查询结果”)。

使用订阅服务的情况如下:

大きなオブジェクトの小さく段階的な変更
大きなオブジェクトを繰り返し更新するのは、とくにそのフィールドのほとんどがほぼ変わらない場合は高コストです。そのようなとき、オブジェクトの初期状態はクエリで取得しておき、サーバーから個々のフィールドの更新をプロアクティブにプッシュできます。

遅延の少ないリアルタイムの更新
たとえば、チャットアプリケーションのクライアントは、新しいメッセージが入手可能になり次第、それを受け取りたいでしょう。

选择订阅服务图书馆

GraphQL的规范没有定义用于发送订阅请求的特定协议。在WebSocket上实现了订阅的Apollo Client支持graphql-ws库。接下来我将说明如何使用graphql-ws。

定义订阅

与查询和更改一样,订阅也在服务器端和客户端两边定义。

服务器端

请在GraphQL模式中使用Subscription类型定义可用的订阅。 commentAdded是一个订阅,它会通知订阅了特定博客文章(通过postID指定)的客户端,每当有新的评论添加到该文章中时。

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

有关在服务器端实现订阅功能的方法,请参阅“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

初始化GraphQLWsLink。

在同一个项目文件中初始化ApolloClient,导入并初始化GraphQLWsLink对象。

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,请参考“Apollo Server中的订阅”中的“启用订阅”进行设置。

使用操作将通信分离(推荐)

Apollo客户端可以使用GraphQLWsLink执行所有类型的操作。然而,在大多数情况下,对于查询和变更仍然最好使用HTTP。查询和变更是无状态的,并且不需要长时间保持连接。这是因为当WebSocket连接尚未建立时,使用HTTP可以更高效且可扩展。

为了支持通信的切分,@apollo/client库提供了split函数。根据布尔值的确认结果,可以使用两个不同的链接之一。

下面的代码是在扩展前一个例子的基础上初始化了GraphQLWsLink和HttpLink两个链接。然后使用split函数将两个链接合并为一个链接。使用哪个链接取决于执行的操作类型的不同。

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)。

给Apollo Client提供链接链。

在定义了包含两个链接的链接链(splitLink)后,将其作为 Apollo Client 的链接构造器选项提供。

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对象来执行认证和其他连接相关任务。

进行订阅

为了执行订阅操作,我们可以使用Apollo Client的useSubscription钩子函数,就像使用useQuery一样,useSubscription也会从Apollo Client返回一个结果对象,其中包含loading、error和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函数的参数对象有以下三个选项。

document – 実行するサブスクリプション。

variables – サブスクリプションを実行するときに含める変数。

updateQuery – クエリの結果をどうまとめるかApollo Clientに指示する関数。

引数 – オプションオブジェクト。

prev – クエリの現在キャッシュされている結果。

subscriptionData – GraphQLサーバーからプッシュされたデータ。

戻り値 – クエリの現在のキャッシュ結果を完全に上書きする値。

最后,在类组件CommentsPage的定义中,规定组件在挂载(componentDidMount())时执行subscribeToNewComments。

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

使用订阅API的参考文档

【注記】即使使用了React Apollo的Subscription Render Prop组件,下表中的选项/结果内容仍然有效(选项是组件的属性,结果是传递给渲染函数的内容)。唯一的不同之处在于还需要subscription属性(用于保存由gql解析为AST的GraphQL订阅文档)。

在 Render Props 组件中,除了 Subscription 之外,还有 Query 和 Mutation。然而,根据「扩展组件」的说法,未来建议与函数组件一起使用 useSubscription、useQuery 和 useMutation。

以下是其中一种方式以中文本地化地重述:”提供一个选项”

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

结果:

useSubscription钩子在调用后返回一个具有下表属性的结果对象。

プロパティ型説明dataTDataGraphQLサブスクリプションの結果を含むオブジェクト。デフォルトは空のオブジェクト。loadingboolean初期データが返されたかどうかを示すブール値。errorApolloErrorgraphQLErrorsおよびnetworkErrorプロパティで発生したランタイムエラー。
bannerAds