Open13

Rails, React, GraphQL subscriptionを使ってnotionのリアルタイムアイコン表示みたいなのをやってみる

ymstymst

※Zenn初投稿なので若干アレかもしれんけどとりあえず書いていく

環境

Rails 7.0.1
React 18.2.0

Railsは gem graphql-ruby, Reactは apollo を使用

何についての記事か

タイトルの通り、Webアプリケーション上でユーザーが何かを共同編集する時にリアルタイムで同じページ見てる人のアイコンを表示したいよね、という要件をどのように実装したかについて部分的に書いていきます。

ちなみに表示できたとこで満足したのでnotionほどかっこよい表示にはなってません。
意外とハマったとこが多かったので、同じことを考えてる方への参考になれば幸いです。

(ちなみに筆者は主にRailsで仕事をしてきたので、Reactはそこまでいい感じのコードになっていない可能性が高いです。そういう気持ちで呼んでもらえればと思います)

ymstymst

1. Railsがredisを使えるようにする

と、言うと単純そうに聞こえるんですが実はここで罠があります。
まずredisを使う理由は「Railsでwebsocket通信がしたい -> ActionCableを使えるようにする ため」なのですが、ActionCableを使うためには gem redis かつ < 5.0 のバージョンにする必要があります。
そして、このredis clientに対応した redis本体のバージョンを使う必要があります。

Gemfile
gem 'redis', '< 5.0'
docker-compose.yml
  redis:
    image: redis:5.0.5
    ports:
      - "6379:6379"

(脳死でlatestを入れると6.0以降になるはず。この記事を書いてる時点ではそれだと動きません。
うろ覚えですがsidekiqとかはgem redisのバージョンも一緒に上げていかないといけなかったりで、両方を同時に使う環境だとなかなか面倒な事態が発生した記憶)

ymstymst

2. ActionCableを使えるようにする

先述の通りwebsocket通信をするためにActionCableを使うので、そのための実装を追加します。

app/channels/application_cable/channel.rb
class ApplicationCable::Channel < ActionCable::Channel::Base
end
config/application.rb
require 'action_cable/engine'
config/cable.yml
development:
  adapter: redis
  url: redis://redis:6379/0
  channel_prefix: app_dev

# productionの設定は適宜行う

今回は前提として gem graphql を使っているものとするため、routeに以下を追加してください。

routes.rb
mount ActionCable.server => '/subscriptions'
ymstymst

3. ActionCableのコネクションからユーザーを渡せるようにする

コネクションという言葉がいきなり出ましたが、これはHTTPの方で言うコントローラと同じ意味だと思ってください。

今回のユースケースだと、ユーザーはなんらか別のところからログインして、ファイルを見にいくタイミングでwebsocketの通信が開始されるという感じになります。
なので、ユーザーの情報はログイン時にcookieに保存しwebsocket通信開始時はclientからここに渡されることを前提にします。

ActionCableではこんな感じで書くのがお作法らしいので、やっていきましょう

app/channels/application_cable/connection.rb
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を使っています。この辺は結構調べたんですが正解に辿り着けなかった...)

ymstymst

4. GraphQLのスキーマにsubscriptionを追加する

ここは脳死で入れちゃってください。

app/graphql/my_schema.rb
class MySchema < GraphQL::Schema
  subscription(Types::SubscriptionType)

  use GraphQL::Subscriptions::ActionCableSubscriptions

 # 以降は省略
app/graphql/subscriptions/base_subscription.rb
class Subscriptions::BaseSubscription < GraphQL::Schema::Subscription
end
ymstymst

5. Subscriptionの開始と削除

ここから、割と定番な雰囲気の実装と、僕が考えた独自の実装が混じってくるので注意しながら読んでもらえたらと思います。

繰り返しになりますが、今回の要件は「リアルタイムにユーザーがどのファイルをアクティブに表示しているか」を認知できるようにしたいです。これを、websocketの通信の死活 = ユーザーのアクティブ表示ON/OFF となるよう今回は実装していきます。

ポイントとしては、「graphql-ruby には subscriptionの開始時にはbroadcastする仕組みが予め実装されているが、終了時には特にないので自前でどうにかする必要がある」ということです。
正直、こういうのは元々存在するか、なかったとしても何かしらパターン化されてるんじゃないかと思ってたんですが、見当たらなかったのでどうにか自分でやってみました。
(今回筆を取ったのもこの辺がモチベーション。「お前の実装は微妙だ」的なツッコミももらえると嬉しいです)

前置きが長くなりましたが、コードがこちら。
上で書いたように、削除時の挙動を実現するために subscription_id_mappings を最初に作っておき、それを元にして色々動かしていくということを意図しています。

app/channels/graphql_channel.rb
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クラスに対して、削除時に何かしらを実行させるためのサービスクラスみたいな感じですね。

app/models/subscription_manager.rb
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
ymstymst

6. Subscription classの実装

5までで、Subscriptionクラスを使うための実装が仕上がったので、ここでは実際にSubscriptionとして配信するものを実装します。
今回の要件を満たすために「同一のURLを見ているユーザーの名前を配列で返す」という実装をしてみたのが下記になります。

まずは、呼び出すためのフィールドを作ります。QueryTypeとほぼ同じですね。

app/graphql/types/subscription_type.rb
class Types::SubscriptionType < GraphQL::Schema::Object
  field :same_page_watchers, subscription: Subscriptions::SamePageWatchers
end

そしてこちらが↑で呼び出される箇所の実装です。

app/graphql/types/subscription_type.rb

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側でこれをどう呼び出すかを書いていきたいと思います。

ymstymst

7. FrontEnd側で必要なパッケージのインストール

今回はRailsチームが配布している actioncable と、subscriptionをwebsocketで行うためのモジュールをインポートします。

package.json に以下が加わっている状態にしてください。
"@rails/actioncable", "@types/rails__actioncable", "graphql-ruby-client", "subscriptions-transport-ws"

(コマンドが残ってなかったので適宜ググって欲しい mm)

ymstymst

8. ApolloがActionCableで会話できるようにする

ここも一発でいける実装がググっても出てこなくて若干ハマりだったのですが、出来上がってみれば簡単で、「websocketLinkと同じように振る舞うActionCableのLinkを実装する」「出来上がったlinkをいい感じにhttpLinkと競合しないようにハンドリングする」という感じです。

src/providers/ApolloProvider.tsx
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(),
});
ymstymst

9. graphql-codegenでgenerateする

各環境によって異なるかと思いますが、codegenしてRails側で作ったsubscriptionのqueryを呼べるようにします。

src/graphql/queries/sameUrlWatchers.ts
import { gql } from "@apollo/client";

const SAME_URL_WATCHERS = gql`
  subscription sameUrlWatchers($url: String) {
    sameUrlWatchers(url: $url) {
      users {
        name
      }
    }
  }
`;

export { SAME_URL_WATCHERS };
ymstymst

10. Subscriptionに連動してアイコン表示するコンポーネントを実装する

今回は密結合になるのは許容して、振る舞いとUIを一緒に実装してしまいます。

src/components/molecules/Avatars.tsx

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;
ymstymst

11. 画面フォーカスのOn/Offに連動してコンポーネントを出し入れする

前の手順は、単に今いる人を引いてくるコンポーネントを実装したので、ここでは「自分は今見ている」をsubscriptionに反映するための実装を行います。
といってもやることは簡単で、フォーカスとコンポーネントのライフサイクルを同期するだけですね。

src/components/organisms/SameUrlWatchers.tsx
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などを使ってサーバーの通信を外に繋ぎ、他のデバイスから見にいくとかがいいかなと思います。

ymstymst

おわりに

だーっと書いてしまいましたが、よくよく考えるとシュッと立ち上がるリポジトリを作った方が理解しやすいかもな〜という気がしてきたので、自分のsandbox作りも兼ねてやってみるかもしれません。

今回の実装にあたり、まんまこれと同じことをやってる記事が見つからなかったんですよね〜。
チャットを実装してみるみたいな記事は結構多くて、参考にはしつつもやりたいことが違うので応用するのがなかなか難しかったです。(自分のFE力のなさも手伝って)

ただ、頑張って一個一個紐解いていくと決して理解不能なものではなく、うまく使えばsubscriptionは色んな可能性がありそうかなと思ったので、もしこれを読んで参考にされたかたがいましたら、何かしら記事にしてもらえると嬉しいなと思います。