📚

Rails でモデルの変更をリアルタイム同期する GraphQL Subscription を generator で自動生成する

に公開

こんにちは!株式会社MyVisionでエンジニアをしている @sukechannnn です!

GraphQLを採用している場合、複数人で同時編集するWebアプリケーションのフォームを実装する時に、GraphQL Subscription を使うのは有力な手段の1つだと思います。

Rails だと GraphQL-Ruby + Action Cable を用いることで比較的簡単に Subscription を実装できますが、今回はモデルの変更通知をより簡単に実装できるように generator を実装してみようと思います。また、concerns や簡単なメタプロを使った動的な定義を用いて、少ない変更で SubscriptionType にフィールド追加 & Subscriptionを発火できるようにします。

なぜ generator か

Rails は ActiveRecord パターンを使った実装をしているため、基本的にモデルとDBテーブルは1対1に対応します。また、GraphQL-Ruby の type も Rails のモデルに1対1対応させ、resolver でモデルのインスタンスを返すことで解決することが多いと思います。

ということは、ある1つのモデルの変更を通知する Subscription の実装は、どのモデルでも同じパターンで実装できそうです。

であれば、Rails の generator が活用できそうです。generator を実装すれば、簡単に同じパターンの実装を生成することができます。

generator の実装

では、generator の実装を見てみましょう。
慣習に従って lib/generators/ 以下に実装していきます。
ここでは、例として Project モデルの変更を通知する場合を想定して実装していきます。

lib/generators/model_changed_subscription/model_changed_subscription_generator.rb
# ModelChangedSubscriptionを生成するGenerator
#
# @example
#   rails generate model_changed_subscription Project
#   # => app/graphql/subscriptions/project_changed.rb を生成
class ModelChangedSubscriptionGenerator < Rails::Generators::NamedBase
  source_root File.expand_path('templates', __dir__)

  # Subscription クラスファイルを生成
  #
  # @return [void]
  def create_subscription_file
    @model_name = name
    @model_snake = @model_name.underscore

    template(
      'subscription.rb.erb',
      "app/graphql/subscriptions/#{@model_snake}_changed.rb",
    )
  end
end

Rails の generator の基本的な作り方は Railsガイド を参照していただければと思うのですが、ルールを簡単に説明すると以下のような感じです。

  • クラス名から Generator を取り除いてスネークケースにしたものがコマンド名になる
  • メソッド名は何でも良いが、上から順番に実行される
  • コマンドライン引数を与える場合は Rails::Generators::NamedBase を継承すると、name という変数に引数が入ってくる
  • source_root File.expand_path('templates', __dir__) を書くことで、テンプレートファイルの置き場所を定義できる

ちなみに、複数の引数を渡せるようにするには、argument を定義します。

class TestGenerator < Rails::Generators::NamedBase
  source_root File.expand_path('templates', __dir__)

  argument :test, type: :string, desc: 'The name of the resource'

  def test
    puts name
    puts test
  end
end

$ rails generate test arg1 testtest
#=> arg1
#=> testtest

生成するコードのテンプレートファイルは erb で定義します。
インスタンス変数は、メソッド内で template メソッドを呼ぶ前に入れておいたものが利用可能です。

lib/generators/model_changed_subscription/templates/subscription.rb.erb
# <%= @model_name %>が変更されたときに発火するsubscription
#
# モデルの id を受け取ってfindして初期値とし、そのモデルが更新されたらSubscriptionで通知する
class Subscriptions::<%= @model_name %>Changed < Subscriptions::BaseSubscription
  description '<%= @model_name %>が変更されたときに発火するsubscription'

  field :<%= @model_snake %>, ObjectTypes::<%= @model_name %>Type, null: true

  argument :<%= @model_snake %>_id, GraphQL::Types::ID, description: '購読対象の <%= @model_name %> ID'

  # subscription開始時の初期データをfindして返す
  #
  # @param <%= @model_snake %>_id [String] 購読対象の <%= @model_name %> ID
  # @return [Hash] <%= @model_snake %>: <%= @model_name %>インスタンス
  def subscribe(<%= @model_snake %>_id:)
    instance = <%= @model_name %>.find(<%= @model_snake %>_id)
    { <%= @model_snake %>: instance }
  end

  # ModelChangedSubscribable が include されている前提で、
  # モデルが更新された時に ModelChangedSubscribable#notify_model_changed で発火
  #
  # object['<%= @model_snake %>'] に更新後のモデルインスタンスが渡ってくる
  #
  # @param <%= @model_snake %>_id [String] 購読対象の <%= @model_name %> ID
  # @return [Hash] <%= @model_snake %>: <%= @model_name %>インスタンス
  # @raise [ArgumentError] <%= @model_snake %>に値が設定されていない、または不正な値の場合
  def update(<%= @model_snake %>_id:)
    instance = object['<%= @model_snake %>']

    raise ArgumentError, '<%= @model_snake %>に値が設定されていません' if instance.blank?
    raise ArgumentError, '<%= @model_snake %>が指定された<%= @model_snake %>_idに対応していません' if instance.id != <%= @model_snake %>_id

    { <%= @model_snake %>: instance }
  end
end

これで Subscription のクラスを generate できます。

$ rails generate model_changed_subscription Project
  create  app/graphql/subscriptions/project_changed.rb

試しに上記のコマンドを実行すると、以下のコードが生成されます。

app/graphql/subscriptions/project_changed.rb
# Projectが変更されたときに発火するsubscription
#
# モデルの id を受け取ってfindして初期値とし、そのモデルが更新されたらSubscriptionで通知する
class Subscriptions::ProjectChanged < Subscriptions::BaseSubscription
  description 'Projectが変更されたときに発火するsubscription'

  field :project, ObjectTypes::ProjectType, null: true

  argument :project_id, GraphQL::Types::ID, description: '購読対象の Project ID'

  # subscription開始時の初期データをfindして返す
  #
  # @param project_id [String] 購読対象の Project ID
  # @return [Hash] project: Projectインスタンス
  def subscribe(project_id:)
    instance = Project.find(project_id)
    { project: instance }
  end

  # ModelChangedSubscribable が include されている前提で、
  # モデルが更新された時に ModelChangedSubscribable#notify_model_changed で発火
  #
  # object['project'] に更新後のモデルインスタンスが渡ってくる
  #
  # @param project_id [String] 購読対象の Project ID
  # @return [Hash] project: Projectインスタンス
  # @raise [ArgumentError] projectに値が設定されていない、または不正な値の場合
  def update(project_id:)
    instance = object['project']

    raise ArgumentError, 'projectに値が設定されていません' if instance.blank?
    raise ArgumentError, 'projectが指定されたproject_idに対応していません' if instance.id != project_id

    { project: instance }
  end
end

これで Subscription を生成する generator が実装できました。

Subscription のトリガーの実装

ただし、この時点では Subscription を発火させるトリガーが実装されていません。
せっかくなので、このトリガーも include するだけで発火できるように concerns で定義します。

app/models/concerns/model_changed_subscribable.rb
module ModelChangedSubscribable
  class InvalidModelSubscribeError < StandardError; end

  extend ActiveSupport::Concern

  # TARGET_MODELS に書かれているクラスがSubscription発火の対象
  TARGET_MODELS = %w[Project User]

  included do
    after_commit -> { notify_model_changed(self) }, on: %i[update]
  end

  # このmoduleをincludeしたモデルが更新された際にGraphQL Subscriptionをトリガーする
  def notify_model_changed(model_instance)
    model_name = model_instance.class.name

    unless TARGET_MODELS.map(&:constantize).any? { |tm| model_instance.is_a?(tm) }
      raise InvalidModelSubscribeError, "対象外のモデルの変更をSubscriptionで通知しようとしています: #{model_name}"
    end

    return if model_instance.destroyed? || model_instance.saved_change_to_id?

    model_name_camel = model_name.camelize(:lower)
    model_name_snake = model_name.underscore

    # Subscriptions::ModelChangedBase で動的に生成されたクラスの #update メソッドに送信する
    MyAppApiSchema.subscriptions.trigger(
      "#{model_name_camel}Changed",
      { "#{model_name_snake}_id" => model_instance.id },
      { model_name_snake => model_instance },
      context: { is_subscription_trigger: true },
    )
  end
end

上記の場合、TARGET_MODELS に追加した ProjectUser モデルが対象です。
対象のモデルで include ModelChangedSubscribable することで、id 以外に更新があれば Subscription が発火するようにコールバックを定義しています。

app/models/project.rb
class Project < ApplicationRecord
  include ModelChangedSubscribable
...
end

SubscriptionType のフィールド追加

最後に subscription_type.rb へのフィールド追加です。
ModelChangedSubscribable::TARGET_MODELS を定義したので、それを利用しましょう。
簡単なメタプロを用いて、動的に SubscriptionType に field を追加します。

app/graphql/object_types/subscription_type.rb
class ObjectTypes::SubscriptionType < ObjectTypes::BaseObject
  # ModelChangedSubscribable::TARGET_MODELS に定義されているクラスの変更を通知する
  # GraphQL Subscription field を動的に生成する
  ModelChangedSubscribable::TARGET_MODELS.each do |model|
    subscription_class_name = "Subscriptions::#{model}Changed"
    subscription_class = subscription_class_name.safe_constantize

    field :"#{model.underscore}_changed", subscription: subscription_class if subscription_class
  end
end

以上で完了です。

通知対象のモデルを追加する

最後に、これらの実装がされた状態で、Subscription通知対象のモデルを追加してみましょう。

例として Task モデルを通知の対象にしたい場合は、以下の3ステップで完了です。

  1. $ rails generate model_changed_subscription Task を実行する
  2. ModelChangedSubscribable::TARGET_MODELSTask を追加
  3. Task モデルで ModelChangedSubscribable を include

簡単ですね。

まとめ

Rails generator と GraphQL-Ruby で、モデルの変更通知 Subscription を簡単に追加できる仕組みを実装してみました。
generator を叩いた後は設定を数行追加するだけなので、チーム開発でも抜け漏れなく変更通知を実装できます(自分自身も助かる)。

ちなみにフロントエンド側はというと、例えば Apollo + React Hook Form を使ってる場合の簡単な例としては以下のようになります。

まずは Subscription を .gql ファイルに定義して codegen します(graphql-codegen を想定してます)。

subscription projectChanged($projectId: ID!) {
  projectChanged(projectId: $projectId) {
    project {
      id
      title
    }
  }
}

そして、Subscription で受け取った値で form.reset すれば、他の人の変更がリアルタイムにフォーム反映される仕組みを作れます。

useProjectChangedSubscription({
  variables: { projectId: project.id },
  onData({ data }) {
    const project = data.data?.projectChanged.project;
    if (!form.formState.isDirty && project) {
      // GraphQL の値を Zod とかで定義されてる Schema データに変換
      const formData = mapToProjectForm(project)
      form.reset(formData)
    }
  },
})

情報がリアルタイムに同期されるとUXがとても良く、モダンなアプリケーション感が出るのでぜひ試してみてください!

MyVision技術ブログ

Discussion