アソシエーションの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を返す処理をコントローラレベルでやっている場合、そのレスポンスをテストする、などです。

Yokohama.rb Monthly Meetup #88 に参加した

2018-02-17(月)のYokohama.rb Monthly Meetup #88 参加メモです。

yokohamarb.doorkeeper.jp

Rubyレシピブック

気づいたら第11章「オブジェクトとメタプログラミング」に入っていました。

  • 273: オブジェクトが同じか調べる
  • 274: オブジェクトを複製する
  • 275: オブジェクトのクラスを調べる
  • 276: オブジェクトを文字列に変換する
  • 277: オブジェクトを数値に変換する

とくに盛り上がったのは274, 277あたりでしょうか。274ではObject#dupObject#cloneといったメソッドがshallow copyであるという話が紹介されていて、deep_copyのようなものはあったっけというところから、Active Supportのdeep_dupの実装を見ていました。ArrayやHashに対しては再帰的にdeep_dupを呼んでコピーしています。

rails/deep_dup.rb at v5.1.5 · rails/rails

277ではto_iが数値として有効でない文字列やnilを0に変換するのに対して、Kernel.#Integerは数値として有効でないデータに対しては例外を投げるという厳密なメソッドなので、後者を使うほうが安全な場面は結構あるのではという話をしていました。

Slack

Yokohama.rbのSlack workspaceができました。@hamaknさんにSlackinをセットアップしていただきました。



次回は2018-03-17です。

yokohamarb.doorkeeper.jp