ActionCableのコネクションのIDについて

この記事は、「フィヨルドブートキャンプ Part 2 Advent Calendar 2024」12日目の記事です。

フィヨルドブートキャンプ Part 2 Advent Calendar 2024 - Adventar

Advent Calender Part1はこちらになります。

フィヨルドブートキャンプ Part 1 Advent Calendar 2024 - Adventar

前回は、jyoumaさんのFBCと歩む私の小さな一歩:感謝と思い切り挑戦する日々でした。

子育てしながら学習をされているjyoumaさん凄いです!

この記事では、Railsの機能であるAction CableのコネクションのIDについて調べたことを書こうと思います。

RailsガイドではコネクションIDの設定方法について記述がありますが、内部でどのようにコネクションIDが決定されるのか気になったため、コードを読んでみました。

コネクションについて

Action Cableのコネクションとは、Action CableサーバーがWebSocket接続を受け取ると作成されるもので、1つのWebSocket接続に対して1つ作成されます。

詳しくはRailsガイドのAction Cableのページを参照してください。

コネクションのIDの設定

Railsガイドには、コネクションの設定の例として以下のようなコードが記載されています。

# app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user
    end

    private
      def find_verified_user
        if verified_user = User.find_by(id: cookies.encrypted[:user_id])
          verified_user
        else
          reject_unauthorized_connection
        end
      end
  end
end

上記のコードでWebSocket接続が行われた際、以下のような流れでコネクションにIDが設定されます。

  • identified_by :current_userで、コネクションのIDがcurrent_user属性の値で決定されることを宣言する。
  • WebSocket接続が確立した際、connectメソッドが呼ばれる。
  • ApplicationCable::Connectionインスタンスのcurrent_user属性に、cookieの値から取得したユーザーオブジェクトを代入する。

つまり、ActionCableのコネクションのIDはコネクションインスタンスのcurrent_user属性に保存されているユーザーオブジェクトによって決定される、ということになります。

では、保存された値を元にどのようにしてIDが決定されるのかを追っていきます。

identified_by

はじめにidentified_byメソッドを見ていきます。 このメソッドはActionCable::Connection::Identificationに定義されています。

module ActionCable
  module Connection
    module Identification
      extend ActiveSupport::Concern

      included do
        class_attribute :identifiers, default: Set.new
      end

      module ClassMethods
        # Mark a key as being a connection identifier index that can then be used to
        # find the specific connection again later. Common identifiers are current_user
        # and current_account, but could be anything, really.
        #
        # Note that anything marked as an identifier will automatically create a
        # delegate by the same name on any channel instances created off the connection.
        def identified_by(*identifiers)
          Array(identifiers).each { |identifier| attr_accessor identifier }
          self.identifiers += identifiers
        end
      end

https://github.com/rails/rails/blob/410ad1c8a18ebf7f544d77d2cb82de2a0e87d23a/actioncable/lib/action_cable/connection/identification.rb#L5

identified_byは以下のことを行なっています。

  • attr_accessorを呼び出して、受け取った引数のアクセサメソッドを追加する。
  • identifiers属性に引数を追加する。

WebSocket接続時に行なっていた代入(self.current_user = find_verified_user)は、identified_byによってcurrent_userというアクセサメソッドが追加されているため可能となっていました。

identifiersはデフォルトで空の集合となっており、identified_byの引数をその集合に追加しています。

connection_identifier

コネクションのIDはconnection_identifierによって返されます。

このメソッドはidentified_byと同じActionCable::Connection::Identificationに定義されています。

      # Return a single connection identifier that combines the value of all the
      # registered identifiers into a single gid.
      def connection_identifier
        unless defined? @connection_identifier
          @connection_identifier = connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") }
        end

        @connection_identifier
      end

      private
        def connection_gid(ids)
          ids.map do |o|
            if o.respond_to? :to_gid_param
              o.to_gid_param
            else
              o.to_s
            end
          end.sort.join(":")
        end

https://github.com/rails/rails/blob/410ad1c8a18ebf7f544d77d2cb82de2a0e87d23a/actioncable/lib/action_cable/connection/identification.rb#L29

connection_identifierは以下のことを行なっています。

  • @connection_identifierが定義されていなければ、@connection_identifierに値を保存する
  • @connection_identifierを返す

@connection_identifierに保存される値を詳しく見てみましょう。

connection_gid identifiers.filter_map { |id| instance_variable_get("@#{id}") }

identifiers.filter_map { |id| instance_variable_get("@#{id}") }は、identifiersを対応する値に変換しています。

Railsガイドのコードを元に流れを追ってみます。

  1. identifiersはSet[:current_user]
  2. Set[:current_user]にfilter_mapが呼ばれる
  3. ブロック内でinstance_variable_get("@current_user")が呼ばれる
  4. current_user属性にはself.current_user = find_verified_userで保存したユーザーオブジェクトが格納されているため、そのオブジェクトが返る
  5. [user]が返る

そして、取得した配列をconnection_gidに渡しています。

        def connection_gid(ids)
          ids.map do |o|
            if o.respond_to? :to_gid_param
              o.to_gid_param
            else
              o.to_s
            end
          end.sort.join(":")
        end

引数で受け取った配列の中身を、オブジェクトがto_gid_paramを呼び出せるならばto_gid_paramを呼び出し、呼び出せないならばto_sを呼び出して変換し、変換した値ををソートして連結しています。

to_gid_paramとはなんぞや?と思い調べてみると、次のような実装例がありました。

  def to_gid_param
    to_global_id.to_param
  end

https://github.com/rails/rails/commit/952887a8bd1fc1e197a482b01c6b1b1ccbcdba2d

モデルのインスタンスに対しto_global_idというメソッドを呼び出しています。 Railsの依存関係の中にglobalidというgemが含まれており、モデルのインスタンスに対してこのメソッドが呼び出せるようになっています。

to_paramメソッドもこのgemで定義されていました。 https://github.com/rails/globalid/blob/e9548a3531a89abfaf036f88d4dac732355a5b80/lib/global_id/global_id.rb#L72

試しに開発中のアプリでメソッドを呼び出してみると、次のような値が返ってきました。

fjord-minutes(dev)> Member.first.to_global_id
  Member Load (1.0ms)  SELECT "members".* FROM "members" ORDER BY "members"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<GlobalID:0x0000000122f8c8e8 @uri=#<URI::GID gid://fjord-minutes/Member/1>>
fjord-minutes(dev)> Member.first.to_global_id.to_param
  Member Load (1.2ms)  SELECT "members".* FROM "members" ORDER BY "members"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> "Z2lkOi8vZmpvcmQtbWludXRlcy9NZW1iZXIvMQ"

ActionCable::Connection::Identificationのコメントにある通り、コネクションのIDの元となる値はユーザーオブジェクト以外でも大丈夫とされています。 以下のコードでは文字列を元にコネクションのIDが決まっています。

https://github.com/rails/rails/blob/410ad1c8a18ebf7f544d77d2cb82de2a0e87d23a/actioncable/test/connection/string_identifier_test.rb

まとめ

Action CableコネクションのIDはidentified_byで指定した属性の値を元に決定される。(複数指定も可能)

WebSocket接続時に、identified_byで指定した属性に値を保存しておく必要がある。(保存する値は通常ユーザーオブジェクト)

保存された値はto_gid_paramもしくはto_sで変換され、ソートして:で連結されたものがコネクションのIDとなる。

おわりに

なぜAction Cableのコネクションについて調べたのかというと、開発中の自作サービスでAction Cableを使っているのですが、最初はコネクションIDの設定を追加しておらず、テストを書こうとした際に行き詰まってしまったからです。

Railsのコードを読むことで、Action Cableについての理解を多少なりとも深めることができたと思います。

サーバーのコードもいくつか見てみましたが、その動きを完全に理解するには至りませんでした。これは今後の学習課題としたいと思います。

参考