"The Modular Monolith: Rails Architecture"を読んだ

Modular MonolithというアーキテクチャをRailsアプリケーションへ適用する記事を読みました。

medium.com

モノリスアーキテクチャとマイクロサービスアーキテクチャの中間に位置する、一つのモノリシックなアプリケーション内でドメインごとにモジュールに分解しつつ運用するためのアーキテクチャを、Railsでどのように実装するか、という内容です。

Modular Monolithとは

記事から引用します。

Rather than extracting microservices, we decided to first focus on making our app modular. Our goal was to identify good architectural boundaries before we extracted code out into independent services. This would set us up to be able to migrate to microservices in the future, by having the code structured in a way to make a smooth transition.

  • モノリスから複数のマイクロサービスを抽出するより、まずアプリ内をモジュラーにしていく
  • 独立したサービスとして抽出する前にアーキテクチャ上のよい境界を見つける。スムーズに移行しやすいようにコードを構成しておく

というものです。Railsで実現するための具体的な方法として次の項目が挙げられています。

  • app ディレクトリを持たず、コードはすべて gemsengines の下に置く
  • gems の下に置くコードははRailsに依存しないRubyのコードとなる
    • すべてステートレスであり、ストレージを使わない
    • Active Supportだけは使う
  • engines の下に置くコードはRailsに依存するRubyのコードとなる
    • mountable engineにする
    • Active Recordによってデータを永続化する
    • Action PackによってAPIやWebインタフェースを公開する

なぜModular Monolithを使うのか

rails new してRailsアプリケーションをふつうに作っていくとモノリスアーキテクチャになります。ユーザー管理、商品管理、決済など、さまざまなドメインの機能をまとめて app 配下などで管理しています。一方、ドメインごとにサービスを切り出して別のアプリケーションとして運用し、サービス間はWeb APIなどを通じて連携するマイクロサービスアーキテクチャも存在します。

Martin Fowler氏は、MonolithFirstという記事で、最終的にマイクロサービスにアーキテクチャを移行したいとしても、最初はモノリスから始めるのがよいと述べています。Monolith Firstではアプリケーションを成長させながらドメインの境界を見つけていき、またその必要があるならば、徐々にマイクロサービスとして切り出していきます。

Railsであれば、小〜中規模なアプリケーションならモノリスのままRailsの利点を活かしていくのがよいでしょう。成長して大きくなってきたアプリケーションは、モジュール間の依存が複雑になって変更時の影響範囲が読めなくなってきたりします。こういう場合は、まずモノリスの中をドメインごとにモジュールとして分解していくのが効果的といえ、今回読んだ記事ではModular Monolithを使うのがよいと述べています。

RailsにおけるModular Monolithアーキテクチャの実現

ここからは、記事で説明されているModular Monolithアーキテクチャを実現するための実装方法について、かいつまんで説明します。

コードを gemsengines に配置

前述したように、コードをすべて gemsengines の下に置きます。すべてgemとして構成するので、それぞれのディレクトリにはgemspecが存在します。Gemfileでは次のようにしてロードします。

Dir.glob(File.expand_path("../engines/*", __FILE__)).each do |path|
  gem File.basename(path), :path => path
end

Dir.glob(File.expand_path("../gems/*", __FILE__)).each do |path|
  gem File.basename(path), :path => path
end

やっていることは、enginesgems 配下のディレクトリについて gem メソッドで読み込むgemを指定しています。同一リポジトリ内にgemが存在するので path オプションを使っています。これを見るとわかるように、実質monorepoとなっています。記事では、プロダクトのコアとなるコードとgemのコードを同時に更新できるので、後方互換性が問題とならず便利であることを利点として挙げています。

モジュール構成のスタート地点

記事では、Admin, API, Domain というモジュールから始めたと述べています。AdminAPIDomain にそれぞれ依存する形です。ここから、Domain モジュールを分解していくのがよいだろうと述べています。

境界の遵守

Railsでgemをロードすると、あるクラスは他のクラスに自由にアクセスできてしまいます。これを防ぐために、Railsエンジンのテスト実行時にそのエンジン自体と依存先だけをロードできるようにGemfileの記述を工夫しています。テストはRailsエンジンのディレクトリで実行します。

if ENV["ENGINE"].nil?
  if Dir.pwd.split("/")[-2] == "engines"
    ENV["ENGINE"] = Dir.pwd.split("/").last
  end
end

Dir.glob(File.expand_path("../engines/*", __FILE__)).each do |path|
  engine = File.basename(path)
  gem engine, :path => "engines/#{engine}", :require => (ENV["ENGINE"].nil? || ENV["ENGINE"] == engine)
end

ENGINE 環境変数が設定されていなければ、Railsエンジン名を ENGINE 環境変数に保存しておきます。そして、engines 配下のディレクトリ名をトラバースしながら、ENGINE 環境変数を見て該当のRailsエンジンだけを require しています。この方法によって、あるモジュールが依存先として指定していないモジュールのクラスを使ってしまっているときはテストでエラーにできます。

元記事では、このロード方法をさらに発展させて、変更したgemとRailsエンジンだけテストが実行されるようにしていました。

循環依存の検出

Bundlerのおかげでモジュールの循環依存が検出できるという話です。

ある二つのモジュール間に循環依存があると、それらのモジュールは強く結合しており、実質一つのモジュールになっているといえます。Modular MonolithアーキテクチャでgemやRailsエンジンといったモジュールに分解することで、モジュール間に循環依存があると、それらのモジュールのロード時にBundlerがエラーとして検出してくれるようになります。

疎結合化のためのObserverパターン

Modular Monolithでは、モジュール間の依存方向に気をつける必要があることがわかってきました。モジュール間の依存方向を制御したいときにはObserverパターンが使えます。

# driving_scoreエンジンにある定期ジョブ
score = ScoringService.generate_score(user)
if score.eligible?
  QuoteService.generate_quotes(user)
end

上のコードは quoting (見積)エンジンの持つ QuoteService へ依存が発生しています。driving_score エンジンから quoting エンジンへの依存を作りたくない場合、次のようにすれば、モジュール間の依存を解消して疎結合にできます。

# driving_scoreエンジンにある定期ジョブ
score = ScoringService.generate_score(user)
if score.eligible?
  DRIVING_SCORE_PUB_SUB.publish(:eligible_score, :user_id => user_id)
end

# quotingエンジンにあるイベントsubscribe用コード
DRIVING_SCORE_PUB_SUB.subscribe(:eligible_score) do |user_id|
  QuoteService.generate_quotes(user_id)
end

driving_score エンジンでは eligible_score というイベントだけを発行し、だれがそのイベントを購読しているかに関心はありません。つまり、driving_score エンジンから quoting エンジンへの依存をなくすことに成功しています。

実際には、pub/subの実現にはKafkaやメッセージキュー用のミドルウェアなどが必要になります。私見としては、このあたりはRailsだとWhisperが使えそうだと思いました。

感想

独立したアプリケーションの機能をmountable engineに切り出すと便利というのはこれまでも言われていたことですが*1app 配下を廃してすべてのコードを gemsengines 配下に置くという割り切りや、マイクロサービスを意識して徐々にドメイン境界を見つけて切り出すという点が独特だと思いました。

また、書籍『マイクロサービスアーキテクチャ』では、モノリシックなアプリケーション内にモジュールを作ることは「実世界ではプロセス境界内でのモジュール分離の約束が守られてことはほとんどありません」*2と述べられています。この問題に対しては、Modular Monolithの設計方針であるgem/Railsエンジンへの分離やBundlerによる依存関係管理を用いた境界の遵守が、解決策の候補になるのではないかと思いました。

興味があれば元記事も読んでみてください。

*1:https://speakerdeck.com/kami/mountable-engine-for-small-team など

*2:『マイクロサービスアーキテクチャ』p.12(原文ママ)

アソシエーションのscopeを使いつつLIMITっぽいことをやる

細かすぎて伝わらないかもしれませんが、ハマったので共有します。

問題

ユーザーが免許を持つ次のようなモデルを考えます。has_one :licenseは免許更新を重ねてきたなかで最新の免許を取得したくて書いたと思ってください。

class User < ApplicationRecord
  has_many :licenses
  has_one :license, -> { order(created_at: :desc).limit(1) }
end

「Userが2件のLicenseを持つ」というセットを3件作って、それぞれのUserの最新のLicenseが取りたいので、licenseをeager loadしてlicenseをそれぞれのUserに対して呼んでみると、次のような結果になります。

User.includes(:license).map(&:license)
#=> [nil,
#    nil,
#    #<License:0x00007fb67bfbbbf0
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]

それぞれのUserの最新のLicenseが取りたかったのですが、nilが入っており期待と違う結果になりました。このときにActive Recordが生成するクエリは次のような感じです。licensesテーブルの該当レコードをすべて取得したあとにLIMITしてしまうので、該当レコードすべての中の最新レコード1件しか取れていないようです。

SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?) ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["LIMIT", 1]]

一方、次のようにlicenses(複数形)をincludesすると取得結果は正しくなりますが、ORDER BY DESCを使うクエリとそうではないクエリに分かれてしまい、N+1問題が発生してしまいます。

User.includes(:licenses).map(&:license)
#=> [#<License:0x00007fb680000288
#     id: 2,
#     code: "326",
#     user_id: 1,
#     created_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00>,
#    #<License:0x00007fb67bfe7c28
#     id: 4,
#     code: "8028",
#     user_id: 2,
#     created_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00>,
#    #<License:0x00007fb67bfdf4d8
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]
SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 1], ["LIMIT", 1]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 2], ["LIMIT", 1]]
SELECT  "licenses".* FROM "licenses" WHERE "licenses"."user_id" = ? ORDER BY "licenses"."created_at" DESC LIMIT ?  [["user_id", 3], ["LIMIT", 1]]

ある解決策

licensesのscopeでソートしつつ、インスタンスメソッド化したUser#licenseのなかでActiveRecord::Associations::CollectionProxy#takeすると、期待どおり動くようです。

class User < ApplicationRecord
  has_many :licenses, -> { order(created_at: :desc) }

  def license
    licenses.take
  end
end

License取得時はlicenses(複数形)をeager loadします。

User.includes(:licenses).map(&:license)
#=> [#<License:0x00007fb680000288
#     id: 2,
#     code: "326",
#     user_id: 1,
#     created_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:14:59 UTC +00:00>,
#    #<License:0x00007fb67bfe7c28
#     id: 4,
#     code: "8028",
#     user_id: 2,
#     created_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:24 UTC +00:00>,
#    #<License:0x00007fb67bfdf4d8
#     id: 6,
#     code: "4934",
#     user_id: 3,
#     created_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00,
#     updated_at: Sun, 29 Apr 2018 11:15:26 UTC +00:00>]

licensesに対してORDER BY DESCするクエリを1回だけ発行して、Ruby側では取得済みレコードに対してtakeでデータを取るので、N+1問題を回避できています。

SELECT "users".* FROM "users"
SELECT "licenses".* FROM "licenses" WHERE "licenses"."user_id" IN (?, ?, ?) ORDER BY "licenses"."created_at" DESC  [["user_id", 1], ["user_id", 2], ["user_id", 3]]

GraphQL Subscriptionsをgraphql-rubyとAction Cableで作る

graphql-rubyでは、RailsのAction Cableに乗ることでGraphQL Subscriptionsを実装できます。

GraphQL Subscriptionsとは

GraphQL Subscriptionsは、あらかじめ特定のGraphQLクエリを購読しておき、サーバ側でイベントが起きるたびにその形式のデータを受信できる仕組みです。用途としてはプッシュ通知などを想定しているようです。

2018年4月の段階ではまだworking draftですが、FacebookによるGraphQL SubscriptionsのRFCがあります。ここではアーキテクチャだけが示されており、具体的な実装方法については言及していません。

github.com

Ruby/RailsまわりのGraphQL Subscriptionsの実装としては、graphql-rubyが提供しているものがあります。バックエンドとしては

  • RailsのAction Cable
  • Pub/SubメッセージングサービスのPusher

をサポートしており、Pusherのほうは有料のpro版の機能となるので、最初はAction Cableを選択することになると思います。この記事でもAction Cableを使います。

GraphQL Subscriptionsを実装する

Order(注文)が入ったときにクライアントへ通知するサーバをGraphQL Subscriptionsとして実装します。

この記事では次のgemを使います。

  • rails 5.1.6
  • graphql-ruby 1.7.14

次の手順で実装を進めます。

  • 配信するデータを準備する
  • Action Cableを準備する
  • Subscription Typeを追加する
  • GraphqlChannelを追加する
  • データを配信する

配信するデータを準備する

事前にAPIモードでプロジェクトを作っておき、bin/rails g scaffold order price:integerを実行して、OrderをCRUDできる状態にしているものとします。また、OrderTypeを次のように定義しておきます。

Types::OrderType = Graphql::ObjectType.define do
  name 'Order'
  field :price, !types.Int
end

Action Cableを準備する

以前、Action CableでWebSocketのAPIを作って通信する方法について書いたので、こちらを見てください。Action Cable特有のメッセージ形式についても説明しています。

blog.kymmt.com

WebSocketの接続確立時に作られるApplicationCable::Connectionに、subscribeするユーザの認証処理を入れます。

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      self.current_user = find_verified_user || reject_unauthorized_connection
    end

    private

    def find_verified_user
      User.first
    end
  end
end

今回はfind_verified_userで手を抜いて、必ずUser.firstが接続してきているような実装にしていますが、実際はここでなんらかの認証が走るようにします。

Subscription Typeを追加する

スキーマ定義にSubscriptionTypeのフィールドを追加します。また、GraphQL::Subscriptions::ActionCableSubscriptionsをスキーマ定義でuseすることで、スキーマでSubscriptionsを使えるようにします。

AppSchema = GraphQL::Schema.define do
  subscription Types::SubscriptionType
  use GraphQL::Subscriptions::ActionCableSubscriptions
end

クライアントが受信するデータを表すSubscriptionTypeを書きます。

Types::SubscriptionType = GraphQL::ObjectType.define do
  name 'Subscription'

  field :orderCreated do
    subscription_scope :current_user_id
    type Types::OrderType
  end
end

あとで述べるGraphqlChannelに対して購読メッセージを送ると、サーバ側でイベントが発生したときに、ここで定義した型でデータが配信されます。また、subscription_scope :current_user_idを指定することで、あとで説明するtrigger利用時にcurrent_userにだけデータを配信できます。

GraphqlChannelを追加する

次に、GraphQL Subscriptionsを使うときにAction Cableのクライアントが購読するGraphqlChannelを定義します。graphql-rubyのAPIドキュメントにサンプル実装が載っています。

Class: GraphQL::Subscriptions::ActionCableSubscriptions — Documentation for graphql (1.7.14)

抜粋します。

class GraphqlChannel < ApplicationCable::Channel
  def subscribed
    @subscription_ids = []
  end

  def execute(data)
    query = data["query"]
    variables = ensure_hash(data["variables"])
    operation_name = data["operationName"]
    context = {
      current_user: current_user,
      # Make sure the channel is in the context
      channel: self,
    }

    result = MySchema.execute({
      query: query,
      context: context,
      variables: variables,
      operation_name: operation_name
    })

    payload = {
      result: result.subscription? ? nil : result.to_h,
      more: result.subscription?,
    }

    # Track the subscription here so we can remove it
    # on unsubscribe.
    if result.context[:subscription_id]
      @subscription_ids << context[:subscription_id]
    end

    transmit(payload)
  end

  def unsubscribed
    @subscription_ids.each { |sid|
      CardsSchema.subscriptions.delete_subscription(sid)
    }
  end
end

これを使えばだいたい動きますが、なにをやっている実装なのかいろいろ気になるので、メソッドごとに見ていきます。

subscribed

ApplicationCable::Channelを継承すると、クライアントからこのチャンネルへsubscribeメッセージが送られたときにsubscribedメソッドが実行されます。Rails Guidesの説明などでは、ここでstream_fromを使ってストリームを自前で管理するように書いてありますが、graphql-rubyでは、このあと説明するexecute実行時にgem側でストリームを管理するので、ここでストリームを管理する必要はありません。

また、execute/unsubscribedで使うために@subscription_idsを空配列として定義しています。

execute

クライアントからこのチャンネルへexecuteを実行するメッセージが送られたときにexecuteメソッドが実行されます。ここはgraphql-rubyを使っているときによく見るGraphqlController#executeの実装とよく似ています。

GraphQL::Schema#executeを実行しているので、subscribe後に普通のqueryやmutationのクエリを送ると、それが実行されて結果を返すことができます。一方、subscriptionクエリを送った場合は、executeすると次の場所でsubscription IDと対応するGraphQLクエリを登録し、stream_fromでストリームを開きます。

graphql-ruby/action_cable_subscriptions.rb at v1.7.14 · rmosolgo/graphql-ruby

このGraphQL::Subscriptions::ActionCableSubscription#write_subscriptionはsubscriptionクエリを受信後にGraphQL::Subscriptions::Instrumentation#after_queryで実行されます。

graphql-ruby/instrumentation.rb at v1.7.14 · rmosolgo/graphql-ruby

この結果、result.subscription?trueになります。このときは、まだイベントが発生したわけではないので、payloadとして空の結果を返します。また、unsubscribed用にsubscription IDを@subscription_idsに入れておいて、最後にAction CableのActionCable::Channel::Base#transmitでクライアントへデータを送信します。

なお、例のコードはpayloadresultnilですが、本当は{ data: nil }が正しいです*1

unsubscribed

クライアントからクエリへunsubscribeメッセージが送られたときに実行されます。execute時に登録されたsubscription IDに対応するsubscriptionをdelete_subscriptionで削除しています。delete_subscriptionは次のコードです。

graphql-ruby/action_cable_subscriptions.rb at v1.7.14 · rmosolgo/graphql-ruby

登録したクエリを削除することで、write_subscriptionを実行したときにデータが配信されなくなります。

データを配信する

subscriptionを登録したクライアントに対するデータの配信にはGraphQL::Subscriptions#triggerを使います。今回はわかりやすくPOST /ordersを叩いたときに、クライアントへデータを配信してみます。

class OrdersController < ApplicationController
  def create
    @order = Order.new(price: params[:price])
    if @order.save
      AppSchema.subscriptions.trigger('orderCreated', {}, @order, scope: @order.user_id)
      render status: :created
    else
      # ...
    end
  end
end

triggerの詳しい説明は公式ガイドにあります。

GraphQL - Triggers

動作を確認する

WebSocket経由でサーバへメッセージを送信することで、購読とデータの受信ができるか確かめます。

リクエスト手順

サーバへは次のGraphQLクエリを送ります。

subscription {
  orderCreated {
    price
  }
}

bin/rails sしてサーバを立ち上げたあとに、wscat -c localhost:3000/cable'でWebSocketへ接続後、次の順番でデータを送ります。

  • {"command":"subscribe","identifier":"{\"channel\":\"GraphqlChannel\"}"}
    • GraphqlChannelの購読
  • {"command":"message","identifier":"{\"channel\":\"GraphqlChannel\"}","data":"{\"action\":\"execute\",\"query\":\"subscription{orderCreated{price}}\"}"}
    • GraphqlChannel#executeを実行して指定クエリを購読

このあと、適当なHTTPクライアントでPOST /ordersへ次のJSONを投げます。

{"order":{"price":1000}}

これで、OrdersController#createでtriggerが実行されて、subscriptionクエリで購読した構造のデータをwscatが次のように受信するのが見えるはずです。

< {"identifier":"{\"channel\":\"GraphqlChannel\",\"channelId\":\"16280cac9e0\"}","message":{"result":{"data":{"orderCreated":{"price":"1000"}}},"more":true}}

実際は、mutationでデータを変更したときにtriggerしたり、バックグラウンドジョブが実行完了したときにtriggerするとそれっぽくてよいと思います。

クライアントについて

今回はサーバ側だけ作ったのでwscatで動くかどうか確認しました。アプリケーションを作るときは、Subscriptionsに対応したモジュールがApolloやRelayに存在するので、これらを使えばJSでSubscriptions対応のクライアントを作ることができます。

wscatでAction Cableと通信する

Railsで/cableなどのエンドポイントにAction CableをマウントするとWebSocketサーバとして利用できます。wscatを使ってAction CableによるWebSocket APIと対話的に通信するために、送信するデータの形式などを調べました。

準備

wscat

github.com

npmでインストールできます。

$ npm install -g wscat

Action Cable

この記事ではRails 5.1.6を使います。今回は、APIモードのRailsアプリケーションにAction Cableをマウントします(Action Cableサーバを独立に起動することも可能)。まず、適当にアプリを作ります。

$ rails new --api action-cable-sample
$ cd action-cable-sample
$ bin/rails g scaffold message body:string
$ bin/rails db:migrate

config/application.rbaction_cable/engineを読み込み、さらにマウントパスを指定します。/cableにマウントするのがRails wayの様子なのでそうします。

# config/application.rb
require_relative 'boot'

require "rails"
# ...
require "action_cable/engine"
# ...

module ActionCableSample
  class Application < Rails::Application
    # ...
    config.api_only = true

    config.action_cable.mount_path = '/cable'
  end
end

また、デフォルトではCSRF対策で同じオリジンからしかWebSocket通信できないので、開発環境ではどのオリジンからでもWebSocket通信できるように設定します。

# config/environments/development.rb
Rails.application.configure do
  # ...
  config.action_cable.disable_request_forgery_protection = true
end

あとはAction Cableのチャンネルを適当に作ります。

$ bin/rails g channel message

MessageChannelは次のように書いておきます。

class MessageChannel < ApplicationCable::Channel
  def subscribed
    stream_from 'message_channel'
  end

  def unsubscribed
  end
end

また、今回はmessages#createが成功したときにWebSocket経由でメッセージをブロードキャストします。

class MessagesController < ApplicationController
  # ...

  def create
    @message = Message.new(message_params)

    if @message.save
      ActionCable.server.broadcast 'message_channel', body: @message.body

      render json: @message, status: :created, location: @message
    else
      render json: @message.errors, status: :unprocessable_entity
    end
  end
end

実際に通信する

次のコマンドでWebSocketサーバへ接続します。HTTPでリクエストしてからWebSocketへアップグレードする処理などは自動でやってくれます。

$ wscat -c localhost:3000/cable

connected (press CTRL+C to quit)

< {"type":"welcome"}
>

Action Cableへ送信するデータにはsubscribe, message, unsubscribeの3種類があります。次の形式でデータを送信することでAction Cableとやりとりできます。

{"command":"subscribe","identifier":"{\"channel\":\"MessageChannel\"}"}
{"command":"message","identifier":"{\"channel\":\"MessageChannel\"","data":"\"action\":\"chat\"}"} # "chat"は例です
{"command":"unsubscribe","identifier":"{\"channel\":\"MessageChannel\"}"}

Action CableはフルスタックアプリでJSを書いて使うことを想定されているためか、このあたりの仕様はREADMEやRails Guidesを見てもとくにドキュメント化されていないようでした。仕様を把握するにはAction Cableのコードを読む必要があります。

ActionCable::Connection::Subscriptions#execute_commandで受信したデータを解析し、commandに指定された文字列subscribe, message, unsubscribeによって処理を分岐しています。messageを送信したときはActionCable::Channel::Base#perform_actionに移り、受信データのactionで指定された名前を持つチャンネルのメソッドを動的に呼び出しています。

また、キー"identifier"の値が文字列化されたJSONになっているのは、この文字列がActionCable::Connection::Subscriptionsの中でActiveSupport::JSON.decodeに渡るからです。

実際に上述した形式のsubscribeのデータを送ると、チャンネルを購読できます。

> {"command":"subscribe","identifier":"{\"channel\":\"MessageChannel\"}"}
< {"identifier":"{\"channel\":\"MessageChannel\"}","type":"confirm_subscription"}

その後、コントローラのアクション内からブロードキャストするとメッセージを受信できます。

# curlで叩く
$ curl --request POST --url http://localhost:3000/messages --header 'content-type: application/json' --data '{"message":{"body":"test"}}'

# wscatでデータを受信する
< {"identifier":"{\"channel\":\"MessageChannel\"}","message":{"body":"test"}}

参考

GraphQL APIを作るときにテストをどう書いていくか

こういうのはどうかという最近の考えを書いておきます。とはいっても、だいたいはgraphql-rubyのドキュメントに書いてあります。Rails + graphql-ruby + RSpecが前提です。

各フィールドのテスト

フィールドから正しく値を取得できるか、つまりRailsのモデルとgraphql-rubyとの連携が正しいかという点をテストします。次のようにスキーマのオブジェクトから型情報を取得して、resolveを実行します。

# たとえば spec/graphql/types/user_type_spec.rb に書く
user_type = MySchema.types['User']
user = FactoryBot.create(:user, name: 'Foo')
expect(user_type.fields['name'].resolve(user, nil, nil)).to eq 'Foo' # nameフィールドが正しい値を取得できるかのテスト

resolverの処理を切り出す

resolveを明示的に指定していて、さらに少し複雑な場合があると思います。

Types::ArticleType = GraphQL::ObjectType.define do
  # ...
  field :something do
    type !types.String
    resolve ->(article, args, ctx) {
      # ref: https://github.com/Shopify/graphql-batch/blob/master/examples/association_loader.rb
      Loaders::AssociationLoader.for(Article, :comments).load(article).then do |comments|
        # articleとcommentsからなにかを生成する処理
      end
    }
  end
end

この場合、resolverの処理をクラスとして外に出し、そのクラスをテストすると見通しが少しよくなります。

これに関連して、graphql-rubyのドキュメントにおける"Testing"のページの"Don't test the schema"という節を見ると、だいたい次のようなことが書いてあります。

  • スキーマはテストしない
  • フィールドのテストはresolverの処理を切り出してapp/models配下に単機能のクラスとして置く
    • #new#valueだけ持つクラス
    • #newでフィールドが属する型に対応するオブジェクトを受け取る
    • #valueでresolverの中にもともとベタ書きされていた処理を実行する
  • GraphQLのスキーマとフィールド内の処理が疎になって便利

これはapp/models配下にGraphQLだけで使いそうなクラスが混ざる点が気になるので、app/models配下には置かずapp/graphql/types/<型名>_fields配下にフィールドごとにresolverを切り出します。

app
└ graphql
  └ types
    └ <型名>_fields
      └ <フィールド名>.rb

<型名>_fields配下のクラスは上述したgraphql-rubyのドキュメントにおけるものと同じような、#valueだけをメソッドとして持つ単機能のクラスです。

ちょっと適当な例ですが、このようなクラスを使うとフィールドの定義は次のようになります。

Types::ArticleType = GraphQL::ObjectType.define do
  # ...
  field :something do
    type !types.String
    resolve ->(article, args, ctx) {
      Loaders::AssociationLoader.for(Article, :comments).load(article).then do |comments|
        Types::ArticleFields::Something.new(article, comments).value
      end
    }
  end
end

request specになにを書くか

request specでテストを書くと、スキーマが大きく/深くなるにしたがって、テスト中のクエリが大きくなり、さらに複数の種類を持つようになります。すると、必要な事前処理や期待値の準備が大変になり、メンテしづらくなります。

基本的には、request specにはGraphQL APIへのリクエストを受けるコントローラ内でのエラーケースに関するテストを書く、ぐらいがいいかと思います。たとえば認可されていないアクセスに対して401 Not Authorizedを返す処理をコントローラレベルでやっている場合、そのレスポンスをテストする、などです。