VISITS Technologiesでマネージャー兼バックエンドエンジニアをしている@ham0215です。
今年のアドベントカレンダー3つ目の記事です。
直前までどのような内容の記事を書こうか迷っていたのですが、直前で @woods0918 さんがGraphQLの記事を投稿したので、私も流れに乗ってGraphQLの記事にしてみました。
Rails6.1で追加されたdelegated typeとGraphQLのUnion typesは相性よさそうだなと思ったので、実際に触ってみて使い心地を確かめてみました。
GraphQLのUnion typesとは
GraphQLにはUnion typesというタイプがあります。
公式ホームページの下記に記載されています。
https://graphql.org/learn/schema/#union-types
Union typesを使うと複数のタイプを組み合わせたタイプを表現することができます。
この記事では、公式ホームページのサンプルをそのまま使わせていただきます。
{
search(text: "an") {
__typename
... on Human {
name
height
}
... on Droid {
name
primaryFunction
}
... on Starship {
name
length
}
}
}
search
クエリーを実行するとHuman
かDroid
かStarship
という3種類のタイプが混在したリストが返却されます。
name
属性は全タイプ共通ですが、Human
の場合はheight
、Droid
の場合はprimaryFunction
、Starshipの場合はlength
という独自の属性を持っています。
実行結果は下記のようになります。
text: 'an'
というパラメーターがついているのでname
に'an'が含まれているデータを検索しています。
data
の中にHumanとStarshipのデータが混在しています(Droidは'an'がつくデータがなかったようですね)
{
"data": {
"search": [
{
"__typename": "Human",
"name": "Han Solo",
"height": 1.8
},
{
"__typename": "Human",
"name": "Leia Organa",
"height": 1.5
},
{
"__typename": "Starship",
"name": "TIE Advanced x1",
"length": 9.2
}
]
}
}
delegated typeとは
下記のプルリクエストで実装された機能です。
DHHが自らプルリク出していて、Rails6.1に入ったようです。
https://github.com/rails/rails/pull/39341
内容はプルリクの説明にサンプルコードも貼ってあるので、それを読む方が理解しやすいと思いますが、一言でいうとテーブルの継承関係を表現しやすくなる機能です。
...と言ってもイメージが湧きづらいと思うので、Union typesと同じ例を使って説明します。
この例のデータを保持するには1テーブルで表現する方法と複数テーブルで表現する方法が考えられます。
それぞれのどのようなテーブル構造になるのか確認しながら説明します。
1テーブルで表現する
1テーブルで表現する場合、下記のようなテーブルになると思います。
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
name | varchar(255) | NO |
type | tinyint(4) | NO |
height | float | YES |
primary_function | varchar(255) | YES |
length | float | YES |
この構成の場合、タイプを横断して検索するときにもこのテーブルをリードすればよいだけなのでパフォーマンスが出しやすいです。
ただ、タイプごとの属性(height, primary_function, length)が任意項目になっているため、例えばHumanタイプの場合はheightカラムが必須で、その他はnullにするという制御をアプリケーション側で担保する必要があります。
複数テーブルで表現する
1テーブルで表現する場合、どうしてもRDB側の制約がゆるくなってしまいます。
これを回避するためにタイプごとにテーブルを作成するという方法があります。
- Humans
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
name | varchar(255) | NO |
height | float | NO |
- Droids
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
name | varchar(255) | NO |
primary_function | varchar(255) | NO |
- Starships
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
name | varchar(255) | NO |
length | float | NO |
1テーブルの場合と違い、タイプごとにテーブルが分かれているのでtypeカラムは不要になります。
また、タイプごとに必要な属性(height, primary_function, length)も必須カラムにすることができるので、RDBの制約で制御することができるようになります。
データを保存するという観点ではメリットが多いのですが、データを取得する時は3テーブルを参照する必要があります。
特に最初の例のように混在したデータを取得する場合はSQLのUNION句を使ったり、データは別々に取得してアプリケーション側の処理で結合したりする必要がありパフォーマンスが悪くなる可能性があります。
1テーブルと複数テーブルの良いとこ取り
「複数テーブルで表現する」で書いた方法だと、タイプが混在したデータを取得するときに苦労します。
これを解消させるためにもう1テーブル作ります。
char_typeと各テーブルへのidを持っているテーブルです。例えばchar_typeがHumanの場合は、char_idにhumans.idが入ります。
共通項目であるnameで検索することがあるので、こちらのテーブルにname属性を追加しました。
二重管理になってしまうので各テーブルからはnameを消します。
- Characters
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
name | varchar(255) | NO |
char_type | tinyint(4) | NO |
char_id | bigint(20) | NO |
- Humans
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
height | float | NO |
- Droids
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
primary_function | varchar(255) | NO |
- Starships
Field | Type | Null |
---|---|---|
id | bigint(20) | NO |
length | float | NO |
このように1階層上のテーブルを作ることで、混在したデータを取得する際はこのテーブルをリードすればできるようになります。
また、タイプごとの必須制約にRDBの制約を使うこともできています。
このようなテーブル構成のデータをRailsから簡単に扱えるようにした機能がdelegated type
です。
実装
それでは実装してみます。
実装する内容ははここまでに使ったGraphQL公式ページの例を使います。
バージョン
この記事を書いた時点の主要ライブラリのバージョンは下記の通り。
- Ruby: 2.7.2
- Rails: 6.1
- graphql-ruby: 1.11.6
- mysql: 5.7.27
migration
例に合わせてマイグレーションします。
面倒なので1ファイルにしましたが、きちんとやる場合はテーブルごとに分けたほうがいいと思います。
typeを格納するカラム(今回の例ではchar_type)には'Human'や'Droid’のように文字列が入るので、stringで定義します。
class CreateTableDelegatedType < ActiveRecord::Migration[6.1]
def change
create_table :characters do |t|
t.string :name, null: false
t.string :char_type, null: false, limit: 10
t.bigint :char_id, null: false
t.timestamps
end
create_table :humans do |t|
t.float :height, null: false
t.timestamps
end
create_table :droids do |t|
t.string :primary_function, null: false
t.timestamps
end
create_table :starships do |t|
t.float :length, null: false
t.timestamps
end
end
end
下記のようにテーブルができました。
> desc characters;
+----------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+----------------+--------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| name | varchar(255) | NO | | NULL | |
| char_type | varchar(10) | NO | | NULL | |
| char_id | bigint(20) | NO | | NULL | |
| created_at | datetime(6) | NO | | NULL | |
| updated_at | datetime(6) | NO | | NULL | |
+----------------+--------------+------+-----+---------+----------------+
> desc droids;
+------------------+--------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------------+--------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| primary_function | varchar(255) | NO | | NULL | |
| created_at | datetime(6) | NO | | NULL | |
| updated_at | datetime(6) | NO | | NULL | |
+------------------+--------------+------+-----+---------+----------------+
> desc humans;
+------------+-------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+-------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| height | float | NO | | NULL | |
| created_at | datetime(6) | NO | | NULL | |
| updated_at | datetime(6) | NO | | NULL | |
+------------+-------------+------+-----+---------+----------------+
> desc starships;
+------------+-------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+------------+-------------+------+-----+---------+----------------+
| id | bigint(20) | NO | PRI | NULL | auto_increment |
| length | float | NO | | NULL | |
| created_at | datetime(6) | NO | | NULL | |
| updated_at | datetime(6) | NO | | NULL | |
+------------+-------------+------+-----+---------+----------------+
models
モデルを作成します。
まずは上位階層のCharacterモデルを定義します。
delegated_type
に必要な情報を設定します。
:char
各テーブルとのリレーションに使うxxx_type, xxx_idのxxxの部分を定義します。
今回はchar_typeとchar_idなので:char
を設定しています。
types
対応するモデルを指定します。
dependent
charactersを削除したときに関連テーブルの挙動を定義します。アソシエーションの設定と同じです。
class Character < ApplicationRecord
delegated_type :char, types: %w[Human Droid Starship], dependent: :destroy
end
各typeのモデルがincludeするモジュールを作成します。
Characterモデルへのリレーション(has_one)と、共通項目(name)のdelegateを定義しました。
module Char
extend ActiveSupport::Concern
included do
has_one :character, as: :char, touch: true, dependent: :destroy
delegate :name, to: :character
end
end
各タイプに対応するモデルを作ります。
Charモジュールをincludeします。
class Human < ApplicationRecord
# humenテーブルを参照してしまうので
self.table_name = "humans"
include Char
end
class Doroid < ApplicationRecord
include Char
end
class Starship < ApplicationRecord
include Char
end
CRUD
一通りモデルが作成できたので、HumanへのCRUDを試してみました。
Create
humansとcharactersへinsertが行われます。整合性を保つため同一transactionで実行されるようです。
途中でcharactersへselectしていますが、これは重複チェックだと思います。
irb(main)> Character.create!(name: 'Han Solo', char: Human.new(height: 1.8))
TRANSACTION (0.3ms) BEGIN
Human Create (0.4ms) INSERT INTO `humans` (`height`, `created_at`, `updated_at`) VALUES (1.8, '2020-12-15 05:26:45.730884', '2020-12-15 05:26:45.730884')
Character Load (0.5ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
Character Create (0.3ms) INSERT INTO `characters` (`name`, `char_type`, `char_id`, `created_at`, `updated_at`) VALUES ('Han Solo', 'Human', 1, '2020-12-15 05:26:45.833648', '2020-12-15 05:26:45.833648')
TRANSACTION (1.8ms) COMMIT
=> #<Character id: 1, name: "Han Solo", char_type: "Human", char_id: 1, created_at: "2020-12-15 05:26:45.833648000 +0000", updated_at: "2020-12-15 05:26:45.833648000 +0000">
Read
Characterモデルのオブジェクトから各タイプにアソシエーションと同じようにアクセスできるようになります。
違うタイプを指定したらnilが返ってきました。
また、human?のようなタイプを判定するメソッドも使えるようになります。
irb(main)> char = Character.first
Character Load (0.9ms) SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` ASC LIMIT 1
=> #<Character id: 1, name: "Han Solo", char_type: "Human", char_id: 1, created_at: "2020-12-15 05:26:45.833648000 +0000", updated_at: "2020-12-15 05:26:45.833648000 +0000">
irb(main)> char.human?
=> true
irb(main)> char.human
Human Load (0.7ms) SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 1 LIMIT 1
=> #<Human id: 1, height: 1.8, created_at: "2020-12-15 05:26:45.730884000 +0000", updated_at: "2020-12-15 05:26:45.730884000 +0000">
irb(main)> char.droid?
=> false
irb(main)> char.droid
=> nil
irb(main)> char.char_name
=> "human"
Update
Characterモデルの項目を修正するとcharactersテーブルのみ更新されます。
irb(main)> char.name = 'Han Soloooooo'
=> "Han Soloooooo"
irb(main)> char.save!
TRANSACTION (0.5ms) BEGIN
Character Update (2.7ms) UPDATE `characters` SET `characters`.`name` = 'Han Soloooooo', `characters`.`updated_at` = '2020-12-15 05:42:12.659673' WHERE `characters`.`id` = 1
TRANSACTION (2.3ms) COMMIT
=> true
Humanモデルの項目を修正するとcharactersテーブルのupdated_atも更新しています。
これはdelegated typeの機能というよりはchar moduleのhas_oneにtouch: trueを付けているからです。
irb(main)> char.human.height = 2.1
=> 2.1
irb(main)> char.human.save!
TRANSACTION (0.3ms) BEGIN
Human Update (0.6ms) UPDATE `humans` SET `humans`.`height` = 2.1, `humans`.`updated_at` = '2020-12-15 05:42:30.095070' WHERE `humans`.`id` = 1
Character Load (0.9ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
Character Update (0.5ms) UPDATE `characters` SET `characters`.`updated_at` = '2020-12-15 05:42:30.103572' WHERE `characters`.`id` = 1
TRANSACTION (2.5ms) COMMIT
=> true
Delete
Characterモデルを削除しました。
dependent: :destroy
を指定しているので関連するStarshipモデルも削除されています。
irb(main)> a = Character.last
Character Load (0.5ms) SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` DESC LIMIT 1
=> #<Character id: 3, name: "TIE Advanced x1", char_type: "Starship", char_id: 1, created_at: "2020-12-15 05:29:53.110094000 +0000", updated_at: "2020-12-15 05:29:53.110094000 +0000">
irb(main)> a.destroy
TRANSACTION (0.3ms) BEGIN
Character Destroy (1.0ms) DELETE FROM `characters` WHERE `characters`.`id` = 3
Starship Load (0.5ms) SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1 LIMIT 1
Character Load (1.0ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Starship' LIMIT 1
Starship Destroy (0.9ms) DELETE FROM `starships` WHERE `starships`.`id` = 1
TRANSACTION (3.9ms) COMMIT
=> #<Character id: 3, name: "TIE Advanced x1", char_type: "Starship", char_id: 1, created_at: "2020-12-15 05:29:53.110094000 +0000", updated_at: "2020-12-15 05:29:53.110094000 +0000">
Droidモデルを削除しました。
dependent: :destroy
を指定しているので関連するCharacterモデルも削除されています。
irb(main)> b = Character.last
Character Load (0.7ms) SELECT `characters`.* FROM `characters` ORDER BY `characters`.`id` DESC LIMIT 1
=> #<Character id: 2, name: "C-3PO", char_type: "Droid", char_id: 1, created_at: "2020-12-15 05:29:45.752745000 +0000", updated_at: "2020-12-15 05:29:45.752745000 +0000">
irb(main)> b.droid.destroy
Droid Load (0.7ms) SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1 LIMIT 1
TRANSACTION (0.4ms) BEGIN
Character Load (0.6ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Droid' LIMIT 1
Character Destroy (0.7ms) DELETE FROM `characters` WHERE `characters`.`id` = 2
Droid Destroy (0.7ms) DELETE FROM `droids` WHERE `droids`.`id` = 1
TRANSACTION (2.2ms) COMMIT
=> #<Droid id: 1, primary_function: "talk", created_at: "2020-12-15 05:29:45.744823000 +0000", updated_at: "2020-12-15 05:29:45.744823000 +0000">
GraphQL
ここからはGraphQLのsearchクエリーを実装していきます。
まずは各モデルのタイプを作ります。
module Types
class HumanType < BaseObject
field :id, ID, null: false
field :name, String, null: false
field :height, Float, null: false
end
end
module Types
class DroidType < BaseObject
field :id, ID, null: false
field :name, String, null: false
field :primary_function, String, null: false
end
end
module Types
class StarshipType < BaseObject
field :id, ID, null: false
field :name, String, null: false
field :length, Float, null: false
end
end
上記をまとめたCharacterタイプを作ります。やっとUnionタイプの登場です。
possible_types
に登場するタイプを指定します。
self.resolve_type
でタイプの判定方法と対応するオブジェクトを返却します。
module Types
class CharacterType < Types::BaseUnion
possible_types Types::HumanType, Types::DroidType, Types::StarshipType
def self.resolve_type(object, context)
if object.human?
[Types::HumanType, object.human]
elsif object.droid?
[Types::DroidType, object.droid]
elsif object.starship?
[Types::StarshipType, object.starship]
end
end
end
end
続いてクエリータイプです。
私はresolverをquery_type.rbと別クラスに定義する書き方をよくするので、今回も別クラスでSearchResolverを定義しています。
例のようにtextを指定するとnameで部分一致検索をするようにしました。
module Resolvers
class SearchResolver < BaseResolver
type Types::CharacterType.connection_type, null: false
argument :text, String, required: false
def resolve(text: nil)
text.nil? ? Character.all : Character.where('name like ?', "%#{text}%")
end
end
end
module Types
class QueryType < Types::BaseObject
field :search, resolver: Resolvers::SearchResolver
end
end
実行
ここまでで実装完了です。
早速実行してみましょう。
まずは全件検索です。
{
search {
edges {
node {
... on Human {
name
height
}
... on Droid {
name
primaryFunction
}
... on Starship {
name
length
}
}
}
}
}
下記のように各タイプが混在して返却されました。
{
"data": {
"search": {
"edges": [
{
"node": {
"name": "Han Solo",
"height": 1.8
}
},
{
"node": {
"name": "C-3PO",
"primaryFunction": "talk"
}
},
{
"node": {
"name": "TIE Advanced x1",
"length": 9.2
}
},
{
"node": {
"name": "Han Soloooo",
"height": 1.8
}
}
]
}
}
}
続いてtextで絞り込んでみます。
{
search(text: "an") {
edges {
node {
... on Human {
name
height
}
... on Droid {
name
primaryFunction
}
... on Starship {
name
length
}
}
}
}
}
きちんと"an"が含まれるデータのみ返却されるようになりました。
{
"data": {
"search": {
"edges": [
{
"node": {
"name": "Han Solo",
"height": 1.8
}
},
{
"node": {
"name": "TIE Advanced x1",
"length": 9.2
}
}
{
"node": {
"name": "Han Soloooo",
"height": 1.8
}
}
]
}
}
}
最後にクエリーを確認してみます。
対応するテーブル(humans, droids, starships)へのselectが1件ごとに実行されています。
また共通項目nameの取得時にcharactersテーブルへのselectも動いているようです。
まさにN+1地獄ですね・・・
これだと実践での利用は厳しいので先読みできないか調査してみました。
Character Load (0.8ms) SELECT `characters`.* FROM `characters`
↳ app/controllers/graphql_controller.rb:13:in `execute'
Human Load (0.7ms) SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 1 LIMIT 1
↳ app/graphql/types/character_type.rb:7:in `resolve_type'
Character Load (0.7ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Human' LIMIT 1
↳ app/models/concerns/char.rb:6:in `name'
Droid Load (0.7ms) SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1 LIMIT 1
↳ app/graphql/types/character_type.rb:9:in `resolve_type'
Character Load (0.6ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Droid' LIMIT 1
↳ app/models/concerns/char.rb:6:in `name'
Starship Load (0.5ms) SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1 LIMIT 1
↳ app/graphql/types/character_type.rb:11:in `resolve_type'
Character Load (0.4ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 1 AND `characters`.`char_type` = 'Starship' LIMIT 1
↳ app/models/concerns/char.rb:6:in `name'
Human Load (0.5ms) SELECT `humans`.* FROM `humans` WHERE `humans`.`id` = 2 LIMIT 1
↳ app/graphql/types/character_type.rb:7:in `resolve_type'
Character Load (0.7ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_id` = 2 AND `characters`.`char_type` = 'Human' LIMIT 1
↳ app/models/concerns/char.rb:6:in `name'
SearchResolverで取得するときにpreloadするようにリファクタリングしてみました。
最初はpreload(:char)
だけでいけるかなと思ったのですが、各モデルからCharacterモデルを参照するname取得処理でselectが発行されてしまったので、:character
も追記しました。
def resolve(text: nil)
- text.nil? ? Character.all : Character.where('name like ?', "%#{text}%")
+ chars = Character.all
+ chars = chars.where('name like ?', "%#{text}%") if text
+ chars.preload(char: :character)
end
下記のようにまとめて取得されるようになりました。(クエリーが多いので見ずらいですが、データが2件存在するhumansがまとめて取得されるようになりました)
Character Load (0.5ms) SELECT `characters`.* FROM `characters`
↳ app/controllers/graphql_controller.rb:13:in `execute'
Human Load (0.7ms) SELECT `humans`.* FROM `humans` WHERE `humans`.`id` IN (1, 2)
↳ app/controllers/graphql_controller.rb:13:in `execute'
Droid Load (0.3ms) SELECT `droids`.* FROM `droids` WHERE `droids`.`id` = 1
↳ app/controllers/graphql_controller.rb:13:in `execute'
Starship Load (0.4ms) SELECT `starships`.* FROM `starships` WHERE `starships`.`id` = 1
↳ app/controllers/graphql_controller.rb:13:in `execute'
Character Load (0.4ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Human' AND `characters`.`char_id` IN (1, 2)
↳ app/controllers/graphql_controller.rb:13:in `execute'
Character Load (0.5ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Droid' AND `characters`.`char_id` = 1
↳ app/controllers/graphql_controller.rb:13:in `execute'
Character Load (0.9ms) SELECT `characters`.* FROM `characters` WHERE `characters`.`char_type` = 'Starship' AND `characters`.`char_id` = 1
↳ app/controllers/graphql_controller.rb:13:in `execute'
最後に
最後まで読んでいただきありがとうございます
明日はVISITSのエンジニアリングマネージャー @kotala_b の記事です。お楽しみに!!