テスト時にAPIドキュメントのスキーマ定義からレスポンスのJSONを自動でバリデーションするgemを作った

あらかじめ書いたJSON Hyper Schema/OpenAPI 2.0のAPIドキュメントにおけるレスポンスのスキーマ定義をもとに、APIモードのRailsでHTTPリクエストを発行するテストを実行すると、自動でレスポンスのJSONをバリデーションしてくれるSchemaConformistというgemを作りました。

github.com

といっても、次の記事でやっていることをgem (Rails plugin) として切り出して、JSON Hyper Schemaにも対応させたあと、いくつか設定できるようにしただけのものです。

使いかた

インストールは、Railsのプロジェクトで Gemfilegem 'schema_conformist' を追加すれば終わりです。

あとは、テスト実行前に次のパスへAPIドキュメントを置いておきます。

  • JSON Hyper Schemaを使うとき
    • public/schema.json
  • OpenAPI 2.0を使うとき
    • public/swagger.json
    • OpenAPI 2.0を使うときは設定 schema_conformist.driver:open_api_2 を指定(後述します)

これで、Railsのintegration testやRSpecのrequest specでHTTPリクエストを発行したときに、APIドキュメントに書いたレスポンスのJSON Schemaにもとづいて、自動で実際のレスポンスのバリデーションが実行されるようになります。

テストを実行したときにどのような結果になるかについては、次のエントリをご覧ください。

バリデーションNGのときは次のようなエラーが出ます。

  1) Users GET /users/:id レスポンスがAPI定義と一致する
     Failure/Error: assert_schema_conform

     Committee::InvalidResponse:
       Invalid response.

       #: failed schema #/properties//users/{userId}/properties/GET: "email" wasn't supplied.

オプション

オプションはひとまず次のものを用意しました。READMEもご覧ください。

  • schema_conformist.driver
    • JSON Hyper SchemaとOpenAPI 2.0どちらを使うか
      • :hyper_schema:open_api_2 を指定
    • デフォルトはJSON Hyper Schema(深い意味はないです…)
  • schema_conformist.ignored_api_paths
    • バリデーションしないAPIパスの正規表現のリスト
    • デフォルトは空
  • schema_conformist.schema_path
    • API定義のファイルパス
    • デフォルトは上述のとおり

config/environments/test.rb あたりに次のように書いておけばOKです。

config.schema_conformist.driver = :open_api_2
config.schema_conformist.ignored_api_paths << %r(\A/private)
config.schema_conformist.schema_path = Rails.root.join('path', 'to', 'swagger.json')

余談

このgemを作った理由の一つとして、José Valim氏の "Crafting Rails 4 Applications" を一通り読んだ結果、Rails pluginを作りたくなったというのがあります。Rails内部の仕組みを細かく見ていったり、Rails pluginでRailsを拡張していったりする本です。今回もいくつか参考にしました。

Crafting Rails 4 Applications: Expert Practices for Everyday Rails Development (The Facets of Ruby)

Crafting Rails 4 Applications: Expert Practices for Everyday Rails Development (The Facets of Ruby)



以上です。興味のあるかたはご活用ください。

RSpecのrequest specでCommitteeを使ってレスポンスJSONを自動的にバリデーションする

この記事の続きのようなものです。

blog.kymmt.com

やりたいこと

Rails + RSpecでWeb APIのrequest specを書くときに、Committee(とCommittee::Rails)の assert_schema_conform を使って、レスポンスのJSONがOpenAPIドキュメントで定義したレスポンスのJSON Schemaと一致するかどうか自動でチェックできるようにします。つまり、次のようにrequest specを書いたら自動でJSONのバリデーションが走ります。

describe 'User', type: :request do
  describe 'GET /users/:id' do
    it 'returns 200 OK' do
      get "/users/:id" # GETリクエスト発行後にJSONのバリデーションを自動で実行
    end
  end
end

前提

前述した記事の内容を実施しているものとします。

使うソフトウェアのバージョンは次のとおりです。

  • Rails 5.1.4
  • Committee 2.0.0
  • Committee::Rails 0.2.0

結論

先に結論を書いておくと、次のことをやればできます。

  • ActionDispatch::Integration::Session#process を実行したあとにCommitteeの assert_schema_conform を実行する

やりかたは後述の「HTTPリクエスト発行後に assert_schema_conform を実行する」を見てください。

request spec内でのHTTPリクエスト発行メソッドの正体を調べる

request specで get, post などのHTTPリクエストメソッドを発行したときに assert_schema_conform を実行したいので、まずはこれらのHTTPリクエスト発行メソッドの正体を調べます。

結論としては、これらのメソッドの実体は、RailsのAction Dispatch(以下AD, AD とします)における AD::Integration::Session という結合テスト時のHTTP通信セッション管理用クラスが持つメソッド #process です。このメソッドは次のようにHTTPメソッド、パス、パラメータなどHTTPリクエストを発行するのに必要なデータを受け取って、実際にリクエストを発行します。

# see: https://github.com/rails/rails/blob/d79e102bfaefc0dce843a73a48456831bd7848b7/actionpack/lib/action_dispatch/testing/integration.rb#L204
def process(method, path, params: nil, headers: nil, env: nil, xhr: false, as: nil)
  # ...
end

AD::Integration::Session#process はモジュール AD::integration::RequestHelpers で定義されている get, post などのヘルパーメソッドから呼び出されています。get の例を引用します。

# see: https://github.com/rails/rails/blob/d79e102bfaefc0dce843a73a48456831bd7848b7/actionpack/lib/action_dispatch/testing/integration.rb#L17-L19
module ActionDispatch
  module Integration #:nodoc:
    module RequestHelpers
      # Performs a GET request with the given parameters. See ActionDispatch::Integration::Session#process
      # for more details.
      def get(path, **args)
        process(:get, path, **args)
      end
      # ...

モジュール AD::integration::RequestHelpers はクラス AD::Integration::Sessioninclude されています。

モジュール AD::Integration::Runner は結合テストを実行するために AD::Integration::Session を使ってHTTP通信のセッションを開きます。そして、get, post などのメソッド呼び出しを AD::Integration::Session へ委譲するメソッドを動的に定義しています。AD::Integration::SessionAD::Integration::RequestHelperinclude しているので、委譲されてきたメソッド呼び出しを処理することができます。

# see: https://github.com/rails/rails/blob/d79e102bfaefc0dce843a73a48456831bd7848b7/actionpack/lib/action_dispatch/testing/integration.rb#L343-L354
module ActionDispatch
  module Integration
    # ...
    module Runner
      %w(get post patch put head delete cookies assigns follow_redirect!).each do |method|
        define_method(method) do |*args|
          # reset the html_document variable, except for cookies/assigns calls
          unless method == "cookies" || method == "assigns"
            @html_document = nil
          end

          # 注:integraion_session が Session のインスタンス
          integration_session.__send__(method, *args).tap do
            copy_session_variables!
          end
        end
      end
    # ...

rspec-railsでは、モジュール RSpec::Rails::RequestExampleGroup でモジュール AD::Integration::Runnerinclude しています。また、rspec-railsはrequest specのときにモジュール RSpec::Rails::RequestExampleGroupinclude します。これにより、request specでは get, post などが使えるようになっています。

HTTPリクエスト発行後に assert_schema_conform を実行する

ここまで把握したら、あとは Session#process を実行したあとに assert_schema_conform を差し込めばよさそうです。他にいいやりかたがあるかもしれませんが、今回は次のようにしました。

# spec/support/assert_schema_conform_available.rb
# CommitteeRailsOpenapi2 は前回記事参照
module AssertSchemaConformAvailable
  include CommitteeRailsOpenapi2

  def process(*args)
    super *args
    assert_schema_conform
  end
end

class ActionDispatch::Integration::Session
  prepend AssertSchemaConformAvailable
end

まず、#process を定義したモジュール AssertSchemaConformAvailable を作り、継承チェーンの上位に process があるとして、そのメソッドを呼んだあとに assert_schema_conform を単純に差し込んでいます。そして、このモジュールを AD::Integration::Sessionprepend することで、このモジュールが継承チェーンにおいて AD::Integration::Session の下位に入り、request specから getpost を呼んだときに AssertSchemaConformAvailable#process を呼べるようにしています。

あとは spec/rails_helper.rb でこのファイルを require しておけば、普通にrequest specを書くだけで、OpenAPIドキュメントに基づいて自動でレスポンスJSONをバリデーションできるようになります。

その他

  • OpenAPIドキュメントに書いていないパスがあれば AssertSchemaConformAvailable#process の中で除外しておく
  • OpenAPIドキュメントをファイル分割して書いて必要なときに結合する運用のときは、request spec実行前後でドキュメントを自動作成/削除すると便利そう

参考

この記事での試みはこちらに影響されております。

Web APIのレスポンスJSONをCommittee + OpenAPIでバリデーションして仕様と実装の乖離を防ぐ

APIドキュメントに書いたJSON Schemaと実際に実装したWeb APIのレスポンスJSONが一致するかバリデーションするためのCommitteeというgemがあります。また、このCommitteeをRailsプロジェクト中のテストから使うためのCommittee::Railsというgemがあります。

CommitteeはAPIドキュメントの形式としてJSON Hyper SchemaOpenAPI 2.0に対応しています。また、APIエンドポイントを叩いたときのレスポンスJSONがドキュメントで定義したJSON Schemaと一致したかを確認するアサーションメソッド assert_schema_conform を持っているので、このメソッドを使ってAPIドキュメントの実際の動作の乖離を未然に防ぐことができます。

今回はOpenAPI 2.0の形式で書いたAPIドキュメントを使って、Railsで作ったAPIのエンドポイントからのレスポンスをRspecのテストでバリデーションしてみます。

使用するライブラリのバージョン

ライブラリのバージョンは次のものとします。

  • Committee 2.0.0
  • Committee::Rails 0.2.0

例のAPI仕様

今回、次のようなAPIエンドポイントを持つ単純なアプリケーションを考えます。

  • GET /users/{userId}

このエンドポイントはステータスコード200で userId のIDを持つユーザを返します。ここで、ユーザは次のような属性を持つデータとします。

属性名 必須
id
email
name
age

すなわち、レスポンスのJSONは次のような形となります。

{
  "id": 1,
  "email": "foo@example.com",
  "name": "John Doe",
  "age": 25
}

OpenAPIドキュメントの記述

上述した仕様に基づいて、次のようなOpenAPI 2.0形式のドキュメントを書きます。

{
  "swagger": "2.0",
  "info": {
    "version": "1.0.0",
    "title": "Committee Rails Sample",
    "license": {
      "name": "MIT"
    }
  },
  "host": "example.com",
  "schemes": [
    "http"
  ],
  "consumes": [
    "application/json"
  ],
  "produces": [
    "application/json"
  ],
  "paths": {
    "/users/{userId}": {
      "get": {
        "summary": "get users",
        "operationId": "userShow",
        "tags": [
          "users"
        ],
        "parameters": [
          {
            "name": "userId",
            "in": "path",
            "description": "user ID",
            "required": true,
            "type": "string"
          }
        ],
        "responses": {
          "200": {
            "description": "A user",
            "schema": {
              "$ref": "#/definitions/User"
            }
          }
        }
      }
    }
  },
  "definitions": {
    "User": {
      "required": [
        "id",
        "email",
        "name"
      ],
      "properties": {
        "id": {
          "type": "integer"
        },
        "email": {
          "type": "string"
        },
        "name": {
          "type": "string"
        },
        "age": {
          "type": "integer"
        }
      },
      "additionalProperties": false
    }
  }
}

ここでは次の点に注目してもらえればOKです。

  • GET /usres/{userId} のレスポンスとして definitions 配下のデータ定義 User を使っている
  • データ定義 User では required で必須パラメータを指定しつつ、additionalPropertiesfalse を指定して記述したパラメータ以外が含まれることを禁じている

Committee::RailsでOpenAPIを使う準備

Committee::RailsでOpenAPIを使うために、Committee::Test::Methods#committee_schema というメソッドをオーバーライドします。このメソッドはAPIドキュメントに書いたJSON Schemaで実際のJSONをバリデーションするときに、そのAPIドキュメントを読み込むメソッドです。Committee::Railsでは Committee::Rails::Test::Methods#commitee_schema でJSON Hyper Schemaのドライバを使うようにあらかじめオーバーライドしていますが、今回はOpenAPI 2.0のドライバを使いたいので、自前でオーバーライドし直します。

# spec/support/committee_rails_openapi2.rb
module CommittteeRailsOpenapi2
  include Committee::Rails::Test::Methods

  def committee_schema
    @committee_schema ||=
      begin
        driver = Committee::Drivers::OpenAPI2.new
        schema_hash = JSON.parse(File.read(schema_path))
        driver.parse(schema_hash)
      end
  end

  def schema_path
    Rails.root.join('docs', 'swagger.json')
  end
end

ここでは、例としてRailsプロジェクトの docs/swagger.json に存在するOpenAPIドキュメントを読み込んでいます。

テストの記述

ここまで来ると、上述の committee_schema オーバーライドによって、APIエンドポイントが返すレスポンスがOpenAPIドキュメントに記述したJSON Schemaに一致するかどうかをRailsのテストで確認できるようになりました。テストはCommittee::RailsのREADMEに書かれているものとまったく同じで、RSpecを使うと次のように書けます。

# spec/requests/users_spec.rb
require 'rails_helper'

RSpec.describe 'Users', type: :request do
  describe 'GET /users/:id' do
    let!(:user) { create(:user) }

    it 'レスポンスがAPI定義と一致する' do
      get "/users/#{user.id}"
      assert_schema_conform
    end
  end
end

もしレスポンス用テンプレートの記述を間違えて必須属性 email を含めなかった場合、次のようなエラーが出ます。

  1) Users GET /users/:id レスポンスがAPI定義と一致する
     Failure/Error: assert_schema_conform

     Committee::InvalidResponse:
       Invalid response.

       #: failed schema #/properties//users/{userId}/properties/GET: "email" wasn't supplied.

また、もしレスポンス用テンプレートの記述を間違えてOpenAPIドキュメントで定義していない属性 phone を含めた場合、次のようなエラーが出ます。

  1) Users GET /users/:id レスポンスがAPI定義と一致する
     Failure/Error: assert_schema_conform

     Committee::InvalidResponse:
       Invalid response.

       #: failed schema #/properties//users/{userId}/properties/GET: "phone" is not a permitted key.

さらに、 属性 age は必須としない定義にしているので、レスポンスに age を含めなくてもエラーにはなりません。

なお、注意点として、ライブラリの実装上、正常系(ステータスコード200〜300番台)のレスポンスだけテストでき、異常系(ステータスコード400〜500番台)についてはテストできません*1

まとめ

  • Railsで作るWeb APIのレスポンスJSONがOpenAPIに定義したJSON Schemaと一致しているかをチェックするにはCommitteeとCommittee::Railsを使う
  • OpenAPI 2.0の形式のドキュメントを読み込むように必要なメソッドをオーバーライドしておく必要がある

Rails 5.1でRails pluginをセットアップする手順

最近読んでいる “Crafting Rails 4 Applications” の中でRails pluginを開発する場面が多々あるので、手元でもRails 5を使ってRails pluginを書いてみています。

Rails 5だと、プラグインのセットアップ手順が本の記述やRails Guidesの記述と少し違っていたので書いておきます。

環境は次のとおりです。

  • Ruby 2.4.1
  • Rails 5.1.3
  • Bundler 1.14.6

プラグインの新規作成

$ rails plugin new my_plugin
      create
      create  README.md
      create  Rakefile
      create  my_plugin.gemspec
      create  MIT-LICENSE
      create  .gitignore
      create  Gemfile
      create  lib/my_plugin.rb
      create  lib/tasks/my_plugin_tasks.rake
      create  lib/my_plugin/version.rb
      create  bin/test
      create  test/test_helper.rb
      create  test/my_plugin_test.rb
      append  Rakefile
  vendor_app  test/dummy

gemのインストール

$ cd my_plugin
$ bundle
You have one or more invalid gemspecs that need to be fixed.
The gemspec at /Users/kymmt/my_plugin/my_plugin.gemspec is not valid. Please fix this gemspec.
The validation error was '"FIXME" or "TODO" is not a description'

Rails pluginはgemの一種なのでプロジェクト中に *.gemspec ファイルを持ちます。plugin作成時はこの gemspec 中の次の項目に TODO の文字列が入っていて、これだと gemspec がinvalidと判定され先に進めないので、消すなりなんなりします。

Gem::Specification.new do |s|
  # ...
  s.homepage    = "TODO" # TODOを消す
  s.summary     = "TODO: Summary of MyPlugin." # TODOを消す
  s.description = "TODO: Description of MyPlugin." # TODOを消す
  # ...
end

もう1回 bundle するとgemが入ります。

$ bundle
Fetching gem metadata from https://rubygems.org/..........
(snip)
Bundle complete! 2 Gemfile dependencies, 40 gems now installed.
Gems in the groups production and rmagick were not installed.
Use `bundle show [gemname]` to see where a bundled gem is installed.

テストの実行

“Crafting Rails 4 Applications” には「rake test でテストを実行できる」という旨の記述がありますが、これをやると次のようになります。

$ rake test
/Users/kymmt/my_plugin/test/dummy/db/schema.rb doesn't exist yet. Run `rails db:migrate` to create it, then try again. If you do not intend to use a database, you should instead alter /Users/kymmt/my_plugin/test/dummy/config/application.rb to limit the frameworks that will be loaded.
/Users/kymmt/my_plugin/test/test_helper.rb:9:in `<top (required)>': uninitialized constant Rails::TestUnitReporter (NameError)
        from /Users/kymmt/my_plugin/test/my_plugin_test.rb:1:in `require'
        from /Users/kymmt/my_plugin/test/my_plugin_test.rb:1:in `<top (required)>'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:15:in `require'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:15:in `block in <main>'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:4:in `select'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:4:in `<main>'
rake aborted!
Command failed with status (1)

Tasks: TOP => test
(See full trace by running task with --trace)

テスト用ダミーアプリケーションのDBマイグレーションができていないので、ダミーアプリケーションのルートディレクトリまで降りてから、DBマイグレーションを実行します。

$ cd test/dummy
$ rake db:migrate
$ cat db/schema.rb
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# Note that this schema.rb definition is the authoritative source for your
# database schema. If you need to create the application database on another
# system, you should be using db:schema:load, not running all the migrations
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
# you'll amass, the slower it'll run and the greater likelihood for issues).
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 0) do

end

これで、もう1回テストを実行すると、今度は次のエラーになります。

$ cd ../../
$ rake test
/Users/kymmt/my_plugin/test/test_helper.rb:9:in `<top (required)>': uninitialized constant Rails::TestUnitReporter (NameError)
        from /Users/kymmt/my_plugin/test/my_plugin_test.rb:1:in `require'
        from /Users/kymmt/my_plugin/test/my_plugin_test.rb:1:in `<top (required)>'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:15:in `require'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:15:in `block in <main>'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:4:in `select'
        from /Users/kymmt/.rbenv/versions/2.4.1/lib/ruby/gems/2.4.0/gems/rake-12.0.0/lib/rake/rake_test_loader.rb:4:in `<main>'
rake aborted!
Command failed with status (1)

Tasks: TOP => test
(See full trace by running task with --trace)

これは test/test_helper.rbRails::TestUnitReporter を読み込んでないのが原因で、test/test_helper.rbrequire 'rails/test_unit/reporter' を加えれば動くようになります*1

ですが、実は “The Basics of Creating Rails Plugins — Ruby on Rails Guides” の第2節に書いてあるとおり、テストを実行するには bin/test を実行するだけでOKです。これは bin/test の中で rails/plugin/testrequire しており、その rails/plugin/test では Rails::TestUnitReporterrequire_relative されているからです*2

$ bin/test
Run options: --seed 60803

# Running:

.

Finished in 0.002613s, 382.6843 runs/s, 382.6843 assertions/s.
1 runs, 1 assertions, 0 failures, 0 errors, 0 skips

参考

Crafting Rails 4 Applications: Expert Practices for Everyday Rails Development (The Facets of Ruby)

Crafting Rails 4 Applications: Expert Practices for Everyday Rails Development (The Facets of Ruby)

GraphQL::Batchのサンプルコードを読む & 使ってみる

単純にGraphQLクエリを投げると、サーバサイドでの関連を含むレコード取得時にN+1問題が発生することがあります。こういうケースでは、複数のデータ取得リクエストをひとまとめにして、単一のリクエストとしてDBからデータを取得するbatchingが推奨されています*1

GraphQLにおけるbatchingをgraphql gemを使ってやるためのGraphQL::Batchというgemがあります。先日リポジトリにサンプルコードが追加されて使いかたを把握しやすくなったので、サンプルコードを読みながら使ってみます。

github.com

GraphQL::Batchの概要

GraphQL::Batchでは、Loaderというデータを取得するためのクラスを作って使うことが想定されています。これは、Facebookが開発しているDataLoaderでの考えかたが元となっています。Loaderがbatchingでデータを取得するときは、load 関数でレコードのキーを複数受け取り、複数レコードを解決するpromiseを返します*2

利用例

コードを読む前にGraphQL::Batchの利用例を示します。

前提

本エントリではRailsでGraphQL::Batchを使うこととします*3

次のようなモデルが存在するRailsアプリケーションを考えます。DBテーブルもこれにしたがった構成であるとします。

class User < ApplicationRecord
  has_many :customers
end

class Customer < ApplicationRecord
  belongs_to :user
  has_many :orders
  has_many :deliverers, through: :orders
end

class Deliverer < ApplicationRecord
  has_many :orders
  has_many :customers, through: :orders
end

class Order < ApplicationRecord
  belongs_to :customer
  belongs_to :deliverer
end

このとき、次のようなGraphQLのスキーマを app/graphql/types 配下などに定義するとします。

Types::QueryType = GraphQL::ObjectType.define do
  name 'Query'

  field :user do
    type Types::UserType
    argument :email, !types.String
    resolve ->(obj, args, ctx) {
      User.find_by!(email: args['email'])
    }
  end
end

Types::UserType = GraphQL::ObjectType.define do
  name 'User'

  field :email, !types.String
  connection :customers, Types::CustomerType.connection_type
end

Types::CustomerType = GraphQL::ObjectType.define do
  name 'Customer'

  field :name, !types.String
  connection :orders, Types::OrderType.connection_type
  connection :deliverers, Types::DelivererType.connection_type
end

Types::DelivererType = GraphQL::ObjectType.define do
  name 'Deliverer'

  field :name, !types.String
  connection :orders, Types::OrderType.connection_type
  connection :customers, Types::CustomerType.connection_type
end

Types::OrderType = GraphQL::ObjectType.define do
  name 'Order'

  field :price, !types.Int
  field :customer, !Types::CustomerType
  field :deliverer, !Types::DelivererType
end

ここで、次のようなクエリをサーバへ投げてみます(DBにはいい感じにデータが保存されているとします)。

{
  user(email: "foo@example.com") {
    customers(first: 2) {
      edges {
        node {
          orders {
            edges {
              node {
                deliverer {
                  name
                }
              }
            }
          }
        }
      }
    }
  }
}

すると、次のように Customer のレコードごとに Order を、Order のレコードごとに Deliverer を取得するSQLを発行してしまうN+1問題が発生します。

User Load (1.6ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "foo@example.com"], ["LIMIT", 1]]
Customer Load (1.7ms)  SELECT  "customers".* FROM "customers" WHERE "customers"."user_id" = ? LIMIT ?  [["user_id", 1], ["LIMIT", 2]]
Order Load (3.3ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = ?  [["customer_id", 1]]
Deliverer Load (1.8ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.4ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (2.1ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (2.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (2.9ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.8ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.9ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.8ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Order Load (2.1ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" = ?  [["customer_id", 2]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.4ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (2.2ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.7ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (1.4ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Deliverer Load (1.5ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Deliverer Load (2.6ms)  SELECT  "deliverers".* FROM "deliverers" WHERE "deliverers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]

GraphQL::Batchの利用

上述したN+1問題を解消するためにGraphQL::Batchを使います。具体的には次のことをやります。

  • サンプルを参考にLoaderを定義する
  • スキーマ定義の resolve 内で使う

まず、サンプルを参考にして、Loaderを app/graphql/loaders 配下などに定義しておきます。そして、定義したLoaderを使って次のようにスキーマを定義し直します。変更部分だけ抜粋します。

Types::UserType = GraphQL::ObjectType.define do
  # ...
  connection :customers, Types::CustomerType.connection_type do
    resolve ->(user, args, ctx) {
      Loaders::AssociationLoader.for(User, :customers).load(user)
    }
  end
end

Types::CustomerType = GraphQL::ObjectType.define do
  # ...
  connection :orders, Types::OrderType.connection_type do
    resolve ->(customer, args, ctx) {
      Loaders::AssociationLoader.for(Customer, :orders).load(customer)
    }
  end
  connection :deliverers, Types::DelivererType.connection_type do
    resolve ->(customer, args, ctx) {
      Loaders::AssociationLoader.for(Customer, :deliverers).load(customer)
    }
  end
end

Types::DelivererType = GraphQL::ObjectType.define do
  # ...
  connection :orders, Types::OrderType.connection_type do
    resolve ->(deliverer, args, ctx) {
      Loaders::AssociationLoader.for(Deliverer, :orders).load(deliverer)
    }
  end
  connection :customers, Types::CustomerType.connection_type do
    resolve ->(deliverer, args, ctx) {
      Loaders::AssociationLoader.for(Deliverer, :customers).load(deliverer)
    }
  end
end

Types::OrderType = GraphQL::ObjectType.define do
  # ...
  field :customer, !Types::CustomerType do
    resolve ->(order, args, ctx) {
      Loaders::RecordLoader.for(Customer).load(order.customer_id)
    }
  end
  field :deliverer, !Types::DelivererType  do
    resolve ->(order, args, ctx) {
      Loaders::RecordLoader.for(Deliverer).load(order.deliverer_id)
    }
  end
end

この状態で先ほどと同じクエリを送信すると、Order, Deliverer に対するSQLがまとめて発行されるようになり、N+1問題を防いでいることがログを見るとわかります。

User Load (1.6ms)  SELECT  "users".* FROM "users" WHERE "users"."email" = ? LIMIT ?  [["email", "foo@example.com"], ["LIMIT", 1]]
Customer Load (1.5ms)  SELECT "customers".* FROM "customers" WHERE "customers"."user_id" = 1
Order Load (2.2ms)  SELECT "orders".* FROM "orders" WHERE "orders"."customer_id" IN (1, 2, 3, 4)
Deliverer Load (1.5ms)  SELECT "deliverers".* FROM "deliverers" WHERE "deliverers"."id" IN (1, 2)

それでは、このLoaderが何をやっているかを見ていきます。

Loaderサンプルコードリーディング

次の場所にある RecordLoaderAssociationLoader のコードを読んで何をやっているか見ていきます。

なお、Loaderを作るには GraphQL::Batch::Loader を継承する必要があります。

RecordLoader

コードはこちら。

belongs_to のように関連先が1件のときに使うLoaderです。上述した例では Order で利用しています。

Graphql::Batch::Loader はファクトリメソッド for から initialize を使っており、この initialize を必要に応じてオーバーライドしていきます。ここでは model で関連先モデルのクラス名を渡せるようになっています。column はデフォルトでは主キー(Active Recordのデフォルトではサロゲートキー id)ですが、オプションで別のキーも渡せるようになっています。また、特定レコードだけ絞り込むために where を渡せるようになっています。

def initialize(model, column: model.primary_key, where: nil)
  @model = model
  @column = column.to_s
  @column_type = model.type_for_attribute(@column)
  @where = where
end

load へはバッチで取得してほしいレコードのキーを渡します。ここでは @column_type の表す型にキャストしてから親クラス GraphQL::Batch::Loaderload に引数を渡しています。これは、任意の主キーを適切な型に変換する処理と思われます。

def load(key)
  super(@column_type.cast(key))
end

perform へは、バッチで渡ってくる keys をもとに、一気にレコードをロード、つまりbatchingする処理を書きます。このときに、プライベートメソッド query で、initialize で渡された絞り込み条件と perform の引数 keys をもとに、必要な関連先レコードだけロードしています。その後、fulfill することで、promiseを解決状態に遷移させつつ、ロードしたレコードを渡しています。GraphQL::Batch::Loader#fulfill の定義は次を参照してください。

また、対応するレコードが存在しない keys 中のキーのpromiseも解決状態とするために、最後の行で fulfill できていない keys の要素に対して、レコードが存在しなかったという意味合いで nil を渡して fulfill しています。

def perform(keys)
  query(keys).each do |record|
    value = @column_type.cast(record.public_send(@column))
    fulfill(value, record)
  end
  keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
end

private

def query(keys)
  scope = @model
  scope = scope.where(@where) if @where
  scope.where(@column => keys)
end

AssociationLoader

コードはこちら。

has_many のように関連先が複数件あるときに使うLoaderです。上述した例では User, Customer, Deliverer で利用しています。

initialize では関連元モデルのクラス名 model と、モデル内で has_many に指定する関連先名 association_name を渡しています。最後の行の validate では、model が本当に association_name で指定される関連を持っているのかをチェックしています。

def initialize(model, association_name)
  @model = model
  @association_name = association_name
  validate
end

private

def validate
  unless @model.reflect_on_association(@association_name)
    raise ArgumentError, "No association #{@association_name} on #{@model}"
  end
end

perform では、バッチで渡ってくる関連元レコード群に対する関連先を preload_association 内の ActiveRecord::Associations::Preloader#preload で一気にpreloadしています。このメソッドは :nodoc: なRailsの内部APIですが、ここでは利便性をとって使われているようです。その後、RecordLoader と同じように、ロードした各レコードに対して fulfill することで、promiseを解決状態にしつつ、eager loadしたレコードを渡しています。

def perform(records)
  preload_association(records)
  records.each { |record| fulfill(record, read_association(record)) }
end

private

def preload_association(records)
  ::ActiveRecord::Associations::Preloader.new.preload(records, @association_name)
end

def read_association(record)
  record.public_send(@association_name)
end

順番が前後しますが、load へはバッチで関連先を取得してほしい関連元レコードを渡します。ここで、すでに関連先レコードを perform でロード済みであれば、batchingの対象とすることなく、そのレコードを持つ解決済みpromiseをすぐに返しています。

def load(record)
  raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
  return Promise.resolve(read_association(record)) if association_loaded?(record)
  super
end

private

def association_loaded?(record)
  record.association(@association_name).loaded?
end

おわりに

サンプルコードを試しつつ読みながらGraphQL::Batchをどう使うか調べました。具体的には次のようにすればひとまず使えそうです。

  • サンプルを参考にLoaderを定義する
  • スキーマ定義の resolve 内で使う

私が試しに書き散らしたコードは次の場所に置いています。

github.com

*1:GraphQL Best Practices | GraphQL

*2:https://github.com/facebook/dataloader#batching

*3:GraphQL::BatchはActive Recordに依存しているわけではありません