Rails, React, GraphQL subscriptionを使ってnotionのリアルタイムアイコン表示みたいなのをやってみる
※Zenn初投稿なので若干アレかもしれんけどとりあえず書いていく
環境
Rails 7.0.1
React 18.2.0
Railsは gem graphql-ruby, Reactは apollo を使用
何についての記事か
タイトルの通り、Webアプリケーション上でユーザーが何かを共同編集する時にリアルタイムで同じページ見てる人のアイコンを表示したいよね、という要件をどのように実装したかについて部分的に書いていきます。
ちなみに表示できたとこで満足したのでnotionほどかっこよい表示にはなってません。
意外とハマったとこが多かったので、同じことを考えてる方への参考になれば幸いです。
(ちなみに筆者は主にRailsで仕事をしてきたので、Reactはそこまでいい感じのコードになっていない可能性が高いです。そういう気持ちで呼んでもらえればと思います)
1. Railsがredisを使えるようにする
と、言うと単純そうに聞こえるんですが実はここで罠があります。
まずredisを使う理由は「Railsでwebsocket通信がしたい -> ActionCableを使えるようにする ため」なのですが、ActionCableを使うためには gem redis
かつ < 5.0
のバージョンにする必要があります。
そして、このredis clientに対応した redis本体のバージョンを使う必要があります。
gem 'redis', '< 5.0'
redis:
image: redis:5.0.5
ports:
- "6379:6379"
(脳死でlatestを入れると6.0以降になるはず。この記事を書いてる時点ではそれだと動きません。
うろ覚えですがsidekiqとかはgem redisのバージョンも一緒に上げていかないといけなかったりで、両方を同時に使う環境だとなかなか面倒な事態が発生した記憶)
2. ActionCableを使えるようにする
先述の通りwebsocket通信をするためにActionCableを使うので、そのための実装を追加します。
class ApplicationCable::Channel < ActionCable::Channel::Base
end
require 'action_cable/engine'
development:
adapter: redis
url: redis://redis:6379/0
channel_prefix: app_dev
# productionの設定は適宜行う
今回は前提として gem graphql
を使っているものとするため、routeに以下を追加してください。
mount ActionCable.server => '/subscriptions'
3. ActionCableのコネクションからユーザーを渡せるようにする
コネクションという言葉がいきなり出ましたが、これはHTTPの方で言うコントローラと同じ意味だと思ってください。
今回のユースケースだと、ユーザーはなんらか別のところからログインして、ファイルを見にいくタイミングでwebsocketの通信が開始されるという感じになります。
なので、ユーザーの情報はログイン時にcookieに保存しwebsocket通信開始時はclientからここに渡されることを前提にします。
ActionCableではこんな感じで書くのがお作法らしいので、やっていきましょう
class ApplicationCable::Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
user_id = cookies[:user_id]
self.current_user = User.find_by(id: user_id)
end
end
(ちなみに、HTTPのようにAuthorizationをheaderに入れるというやりかたがwebsocketでもできるようなのですが、ActionCableだとうまく動いてくれなかったので今回はcookieを使っています。この辺は結構調べたんですが正解に辿り着けなかった...)
4. GraphQLのスキーマにsubscriptionを追加する
ここは脳死で入れちゃってください。
class MySchema < GraphQL::Schema
subscription(Types::SubscriptionType)
use GraphQL::Subscriptions::ActionCableSubscriptions
# 以降は省略
class Subscriptions::BaseSubscription < GraphQL::Schema::Subscription
end
5. Subscriptionの開始と削除
ここから、割と定番な雰囲気の実装と、僕が考えた独自の実装が混じってくるので注意しながら読んでもらえたらと思います。
繰り返しになりますが、今回の要件は「リアルタイムにユーザーがどのファイルをアクティブに表示しているか」を認知できるようにしたいです。これを、websocketの通信の死活 = ユーザーのアクティブ表示ON/OFF となるよう今回は実装していきます。
ポイントとしては、「graphql-ruby
には subscriptionの開始時にはbroadcastする仕組みが予め実装されているが、終了時には特にないので自前でどうにかする必要がある」ということです。
正直、こういうのは元々存在するか、なかったとしても何かしらパターン化されてるんじゃないかと思ってたんですが、見当たらなかったのでどうにか自分でやってみました。
(今回筆を取ったのもこの辺がモチベーション。「お前の実装は微妙だ」的なツッコミももらえると嬉しいです)
前置きが長くなりましたが、コードがこちら。
上で書いたように、削除時の挙動を実現するために subscription_id_mappings
を最初に作っておき、それを元にして色々動かしていくということを意図しています。
class GraphqlChannel < ApplicationCable::Channel
def subscribed
@subscription_id_mappings ||= {} # 個別のclientに対してランダムなIDをマップし、削除時の挙動をハンドリングするのに使う
end
def execute(data)
query = data['query']
variables = ensure_hash(data['variables'])
operation_name = data['operationName']
subscription_mapping_key = SecureRandom.base64(30) # execute が走る時にコンテキストから渡す
context = {
current_user: connection.current_user,
channel: self,
subscription_mapping_key:,
}
result = MySchema.execute(query:, context:, variables:, operation_name:)
payload = {
result: result.to_h,
more: result.subscription?,
}
subscription_id = result.context[:subscription_id] # これはexecuteが完了すると渡ってくるキー。削除時に subscription_mapping_key と組み合わせて使う
if subscription_id
@subscription_id_mappings[subscription_id] = subscription_mapping_key
end
transmit(payload)
end
def unsubscribed
@subscription_id_mappings.each do |sid, subscription_mapping_key|
# ここはchannelが切られると自動で飛んでくるところなので、clientが明示的にどのgraphqlのsubscriptionを停止したという操作ではない
# そのため、視聴ユーザーが消えたよという処理は自前で呼び出す必要がある
SubscriptionManager.remove_subscription(subscription_mapping_key:)
MySchema.subscriptions.delete_subscription(sid)
end
end
private
def ensure_hash(ambiguous_param)
case ambiguous_param
when String
if ambiguous_param.present?
ensure_hash(JSON.parse(ambiguous_param))
else
{}
end
when Hash, ActionController::Parameters
ambiguous_param
when nil
{}
else
raise ArgumentError, "Unexpected parameter: #{ambiguous_param}"
end
end
end
↓は完全に独自で考えたもので、接続されていたsubscriptionクラスに対して、削除時に何かしらを実行させるためのサービスクラスみたいな感じですね。
class SubscriptionManager
class << self
EXPIRES = 5.minutes
# 削除時に必要な情報をここで書き込む
def register_subscription(subscription_mapping_key:, subscription_field_argument:, subscription_class_name:)
value = { subscription_field_argument:, subscription_class_name: }
Rails.cache.write(subscription_mapping_key, value, expires_in: EXPIRES)
end
def remove_subscription(subscription_mapping_key:)
value = Rails.cache.read(subscription_mapping_key)
argument = value[:subscription_field_argument]
class_name = value[:subscription_class_name]
klass = class_name.constantize
# 削除時に実行したい処理が呼ばれる
klass.unsubscribe_by_subscription_mapping_key(subscription_mapping_key, **argument)
Rails.cache.delete(subscription_mapping_key)
end
end
6. Subscription classの実装
5までで、Subscriptionクラスを使うための実装が仕上がったので、ここでは実際にSubscriptionとして配信するものを実装します。
今回の要件を満たすために「同一のURLを見ているユーザーの名前を配列で返す」という実装をしてみたのが下記になります。
まずは、呼び出すためのフィールドを作ります。QueryTypeとほぼ同じですね。
class Types::SubscriptionType < GraphQL::Schema::Object
field :same_page_watchers, subscription: Subscriptions::SamePageWatchers
end
そしてこちらが↑で呼び出される箇所の実装です。
class Subscriptions::SameUrlWatchers < Subscriptions::BaseSubscription
argument :url, String, required: false
field :users, [Types::TenantUserType], null: false
SUBSCRIPTION_NAME = 'sameUrlWatchers'.freeze
UserObject = Struct.new(:name) # GraphQL Typeに渡す際はStructで返す必要がある
EXPIRES = 5.minutes
def subscribe(url: nil)
user = context[:current_user]
subscription_mapping_key = context[:subscription_mapping_key]
SubscriptionManager.register_subscription(
subscription_mapping_key:,
subscription_field_argument: { url: },
subscription_class_name: self.class.name,
)
# このスキーマに必要なロジックをここで書く
user_key = build_user_cache_key(subscription_name: SUBSCRIPTION_NAME, url:, user_id: user.id, subscription_mapping_key:)
user_information = { name: user.name }
Rails.cache.write(user_key, user_information, expires_in: EXPIRES)
GorlemRailsSchema.subscriptions.trigger(SUBSCRIPTION_NAME, { url: }, nil) # 購読済みのユーザーに配信する
{ users: watchers(url:) }
end
# triggerされるとここが呼ばれる
def update(url:)
{ users: watchers(url:) }
end
# unsbscribeが呼ばれないので自前で実装する必要がある
# https://github.com/rmosolgo/graphql-ruby/issues/3714
class << self
def unsubscribe_by_subscription_mapping_key(subscription_mapping_key, url:)
wildcard_cache_key = build_user_cache_key(subscription_name: SUBSCRIPTION_NAME, url: '*', user_id: '*', subscription_mapping_key:)
keys = Rails.cache.redis.keys(wildcard_cache_key)
keys.each do |key|
Rails.cache.delete(key)
end
GorlemRailsSchema.subscriptions.trigger(SUBSCRIPTION_NAME, { url: }, nil)
end
def build_user_cache_key(subscription_name:, url:, user_id:, subscription_mapping_key:)
"#{subscription_name}|#{url}|#{user_id}|#{subscription_mapping_key}"
end
end
private
def build_user_cache_key(subscription_name:, url:, user_id:, subscription_mapping_key:)
self.class.build_user_cache_key(subscription_name:, url:, user_id:, subscription_mapping_key:)
end
def watchers(url:)
wildcard_cache_key = build_user_cache_key(subscription_name: SUBSCRIPTION_NAME, url:, user_id: '*', subscription_mapping_key: '*')
keys = Rails.cache.redis.keys(wildcard_cache_key)
users = Rails.cache.read_multi(*keys) # ここではHashが帰ってくる. Array[Hash]ではない点に注意
users.values.map { |u| UserObject.new(u[:name]) }
end
end
ここまででRails側の実装は完了です。
次項からはReact側でこれをどう呼び出すかを書いていきたいと思います。
7. FrontEnd側で必要なパッケージのインストール
今回はRailsチームが配布している actioncable と、subscriptionをwebsocketで行うためのモジュールをインポートします。
package.json に以下が加わっている状態にしてください。
"@rails/actioncable"
, "@types/rails__actioncable"
, "graphql-ruby-client"
, "subscriptions-transport-ws"
(コマンドが残ってなかったので適宜ググって欲しい mm)
8. ApolloがActionCableで会話できるようにする
ここも一発でいける実装がググっても出てこなくて若干ハマりだったのですが、出来上がってみれば簡単で、「websocketLinkと同じように振る舞うActionCableのLinkを実装する」「出来上がったlinkをいい感じにhttpLinkと競合しないようにハンドリングする」という感じです。
import { getMainDefinition } from "@apollo/client/utilities";
import { split } from "@apollo/client";
import { createConsumer } from "@rails/actioncable";
import ActionCableLink from "graphql-ruby-client/subscriptions/ActionCableLink";
// snip...
const generateWebsocketUrl = (url: string, subdomain: string) => {
const [, rest] = url.split("://");
const [domain] = rest.split("/");
return `ws://${subdomain}.${domain}/subscriptions}`;
};
const cable = createConsumer(generateWebsocketUrl(uri, subdomain));
const actionCableLink = new ActionCableLink({ cable });
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === "OperationDefinition" &&
definition.operation === "subscription"
);
},
actionCableLink,
httpLink
);
export const client = new ApolloClient({
link: from([authLink, httpLink, errorLink, cleanTypeName]),
cache: new InMemoryCache(),
});
9. graphql-codegenでgenerateする
各環境によって異なるかと思いますが、codegenしてRails側で作ったsubscriptionのqueryを呼べるようにします。
import { gql } from "@apollo/client";
const SAME_URL_WATCHERS = gql`
subscription sameUrlWatchers($url: String) {
sameUrlWatchers(url: $url) {
users {
name
}
}
}
`;
export { SAME_URL_WATCHERS };
10. Subscriptionに連動してアイコン表示するコンポーネントを実装する
今回は密結合になるのは許容して、振る舞いとUIを一緒に実装してしまいます。
import { FC } from "react";
import { Avatar, AvatarGroup, Typography } from "@mui/material";
import { useSameUrlWatchersSubscription } from "graphql/generated";
import Tooltip from "@mui/material/Tooltip";
const Avatars: FC = () => {
const { data: sameUrlWatchers } = useSameUrlWatchersSubscription({
variables: {
url: window.location.href,
},
});
const avatarStyle = {
width: "40px",
height: "40px",
};
function typographyStyles(name: string): { fontSize: string } {
if (name.length <= 2) {
return { fontSize: "0.8em" };
} else if (name.length > 2 && name.length <= 4) {
return { fontSize: "0.6em" };
} else if (name.length > 4 && name.length <= 6) {
return { fontSize: "0.4em" };
} else {
return { fontSize: "0.2em" };
}
}
return (
<>
<AvatarGroup max={7}>
{sameUrlWatchers?.sameUrlWatchers.users.map((user, idx) => (
<Avatar
alt={user.name}
variant="circular"
style={avatarStyle}
key={idx}
>
</Avatar>
))}
</AvatarGroup>
</>
);
};
export default Avatars;
11. 画面フォーカスのOn/Offに連動してコンポーネントを出し入れする
前の手順は、単に今いる人を引いてくるコンポーネントを実装したので、ここでは「自分は今見ている」をsubscriptionに反映するための実装を行います。
といってもやることは簡単で、フォーカスとコンポーネントのライフサイクルを同期するだけですね。
import { FC, useEffect, useState } from "react";
import Avatars from "components/molecules/Avatars";
const SameUrlWatchers: FC = () => {
const { data: sameUrlWatchers } = useSameUrlWatchersSubscription({
variables: {
url: window.location.href,
},
});
const [currentFocus, setCurrentFocus] = useState<boolean>(true);
useEffect(() => {
const handleBlur = () => {
setCurrentFocus(false);
};
const handleFocus = () => {
setCurrentFocus(true);
};
window.addEventListener("blur", handleBlur);
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("blur", handleBlur);
window.removeEventListener("focus", handleFocus);
};
}, [sameUrlWatchers]);
return <>{currentFocus && <Avatars />}</>;
};
export default SameUrlWatchers;
あとは、このコンポーネントを表示したいページに組み込んで動作確認してみてください。
注意点としては、フォーカスがONになっている人しか「現在見ている人」の表示にならないので、必然的に一つのPCでは画面からの同時接続検証が難しいんですよね (ブラウザは複数枚開けるが、アクティブなものは常に一つしかない)。
なので、確認時だけちょっとコードをいじるとか、ngrokなどを使ってサーバーの通信を外に繋ぎ、他のデバイスから見にいくとかがいいかなと思います。
おわりに
だーっと書いてしまいましたが、よくよく考えるとシュッと立ち上がるリポジトリを作った方が理解しやすいかもな〜という気がしてきたので、自分のsandbox作りも兼ねてやってみるかもしれません。
今回の実装にあたり、まんまこれと同じことをやってる記事が見つからなかったんですよね〜。
チャットを実装してみるみたいな記事は結構多くて、参考にはしつつもやりたいことが違うので応用するのがなかなか難しかったです。(自分のFE力のなさも手伝って)
ただ、頑張って一個一個紐解いていくと決して理解不能なものではなく、うまく使えばsubscriptionは色んな可能性がありそうかなと思ったので、もしこれを読んで参考にされたかたがいましたら、何かしら記事にしてもらえると嬉しいなと思います。