大ちゃんの駆け出し技術ブログ

RUNTEQ受講生のかわいいといわれるアウトプットブログ

ams_lazy_relationships

はじめに

Active Model Serializersというgemを使用していたんですけど、N + 1問題が多く生じるという問題を抱えています。mapメソッドを使用して自分でロジックを組んで解消していくような実装が多く見受けられてたのですが、自分のポートフォリオはモデル数が15を超えているため、あまり自分でロジックを組んで時間をかけたくありませんでした。gemでどうにかしてよしなにやってくれないかと探していたところ、下記のgemを発見しました。

ams_lazy_relationships

Bajena/ams_lazy_relationships

star数自体はそこまで多くないですが、自作で新しいロジックを組むことなく各シリアライザーを編集するだけで済むらしく、興味本位で使ってみました。すると一瞬でN + 1問題が解決できて驚き。仕組みは全く分かりませんがありがたく使わせてもらいました。

そこで勉強しようとQiitaで探そうと思ったところ、、、

https://i.gyazo.com/949b0c07a8338131abc8c908f9df2a08.png

なんとわずか1記事のみ。しかも、この方の記事はams_lazy_relationshipsについて触れているだけであって、gemについては書いていません。つまり0記事。これは記事を書くチャンスと思い本記事を書くこととしました。

導入が相変わらず長くすみません。記事を書いていきます。構成としては、まずgemのドキュメントを訳しつつ概要を把握し、実際に導入する中で使用方法を明確にさせていきます。

ドキュメントの訳

Eliminates N+1 queries problem in Active Model Serializers gem thanks to batch loading provided by a great BatchLoader gem.

Active Model Serializers gemのN+1クエリの問題を解消するgemということで、Active Model Serializers専用のgemであることがわかります。BatchLoaderは依存gemですね。こちらもN + 1問題を解決するために使われていますが、Active Model Serializerのためのgemというわけではなく、より広義のgemみたいですね。ちなみにstar数は800を超えているのでかなり使われているgemかと思います。

exAspArk/batch-loader

次にams_lazy_relationshipsの特徴についてです。これは公式gemに載っていなかったので、別記事を参照しています。


  • バッチローディングを使用して、複雑なオブジェクトトレスをシリアライズする際のN+1クエリを防止する。
  • 過剰なデータをロードしない(Railsが不適切に使用された場合に発生するような)
  • すべてのリレーションシップがActiveRecordモデルではない場合でも、N+1を取り除くことができる(例えば、一部のレコードはMySQL DBに、他のモデルはCassandraに保存されている場合など

Use lazy relationships to eliminate N+1 queries in your Rails apps

全て詳しく説明すると長文になるため、ここではかなりよしなにN + 1問題を解決してくれるgemという理解だけに留めておいてください。正直このgemはかなりよしなにやってくれるgemなので仕組みはほとんど書かれていなかったので、、


次に具体的な使用方法についてですが、これが恐ろしく簡単に記載されています。下記のようにserializerでアソシエーションをしている場合、N + 1問題が発生します。

class BlogPostSerializer < BaseSerializer
  has_many :comments
end

しかし、上記の記述を以下のように変更するだけでN + 1問題は解決できるとのことです。

class BlogPostSerializer < BaseSerializer
  lazy_has_many :comments
end

よしなにやりすぎてませんか??笑ただ接頭辞にlazyを付け足すだけなんて、、

もう少し詳しく見ていきます。公式のgemでは以下のように複数の使用例が紹介されていましたのでそおれぞれ見ていきます。

class UserSerializer < BaseSerializer
  # Short version - preloads a specified ActiveRecord relationship by default
  lazy_has_many :blog_posts
  
  # Works same as the previous one, but the loader option is specified explicitly
  lazy_has_many :blog_posts,
                serializer: BlogPostSerializer,
                loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)
  
  # The previous one is a shorthand for the following lines:
  lazy_relationship :blog_posts, loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)
  has_many :blog_posts, serializer: BlogPostSerializer do |serializer|
    # non-proc custom finder will work as well, but it can produce redundant sql
    # queries, please see [Example 2: Modifying the relationship before rendering](#example-2-modifying-the-relationship-before-rendering)
    -> { serializer.lazy_blog_posts }
  end
   
  lazy_has_one :poro_model, loader: AmsLazyRelationships::Loaders::Direct.new(:poro_model) { |object| PoroModel.new(object) }
  
  lazy_belongs_to :account, loader: AmsLazyRelationships::Loaders::SimpleBelongsTo.new("Account")
  
  lazy_has_many :comment, loader: AmsLazyRelationships::Loaders::SimpleHasMany.new("Comment", foreign_key: :user_id)
end

まず上から1つ目と2つ目ですがどちらも同じ意味だそうです。

  # Short version - preloads a specified ActiveRecord relationship by default
  lazy_has_many :blog_posts
  
  # Works same as the previous one, but the loader option is specified explicitly
  lazy_has_many :blog_posts,
                serializer: BlogPostSerializer,
                loader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)

上の方は全てよしなにやっている場合で、下はよしなにやってくれるが一応設定を明示していますね。serializer: BlogPostSerializerでどのserializerを使用するか明示しています。目新しい部分はloaderオプションの箇所であるloader: AmsLazyRelationships::Loaders::Association.new("User", :blog_posts)です。

複数の使用例で記載したコードを見るにams_lazy_relationshipsには複数のLoadersクラスがあるようです。


  • AmsLazyRelationships::Loaders::Association

ActiveRecordのアソシエーション(has_one/has_many/has_many-through/belongs_to)をバッチでロードする。特にloaderオプションで明示がない場合、こちらのオプションがデフォルトとして使用される。

  • AmsLazyRelationships::Loaders::SimpleBelongsTo

シリアル化されたオブジェクトの外部キーを使用して、ActiveRecordモデルを一括してロードする。SimpleBelongsToとあるようにbelongs_to用に使われる。

  • AmsLazyRelationships::Loaders::SimpleHasMany

シリアル化されたオブジェクトの外部キーを使用して、ActiveRecordモデルを一括してロードする。SimpleHasManyとあるようにhas_many用に使われる。

  • AmsLazyRelationships::Loaders::Direct

必要な時にだけよしなに実行されるらしい。あまり詳しくは記載なし。


loadersオプションはデフォルトでAssociationですが、基本的にこれだけで完結するのかなと。ただ、オプションの明示が必要な時もあります。例えばシリアライザーの名前と実際のモデル名が異なる場合です。下記の例の場合、シリアライザーの名前はPostであるが、実際のモデルはBlogPostモデルですので、loaderオプションで明示してあげます。

class PostSerializer < BaseSerializer
  lazy_has_many :comments, serializer: CommentSerializer,
    loader: AmsLazyRelationships::Loaders::Association.new(
              "BlogPost", :comments
            )
end

導入手順

公式の流れに沿って、実際に導入していきます。

# Gemfile
gem "ams_lazy_relationships"
$ bundle
Fetching batch-loader 2.0.1
Installing batch-loader 2.0.1
Fetching ams_lazy_relationships 0.3.2
Installing ams_lazy_relationships 0.3.2

各自シリアライザーのベースとなるシリアライザーにinclude AmsLazyRelationships::Coreを記載します。

class ApplicationSerializer < ActiveModel::Serializer
  include AmsLazyRelationships::Core
end

また、注意点として、上述したようにこのgemはBatchLoaderを多用しています。gemの仕様上HTTPリクエストの間にバッチローダのキャッシュをクリアすることを推奨しています。方法はconfig配下のapplication.rbに以下を記載します。

# application.rb

config.middleware.use BatchLoader::Middleware

準備はこれだけです。

では導入前のN + 1問題が起きている状態を見てみます。修正前のシリアライザーの状態です。

# app/serializers/question_block_serializer.rb
class QuestionBlockSerializer < ApplicationSerializer
  attributes :id, :title
  has_many   :question_items,  serializer: QuestionItemSerializer
  has_many :question_block_likes
  has_many :users, through: :question_block_likes
end

質問ブロックみたいなモデルがあってそこに質問項目であるquestion_itemsがhas_manyの関係で表示、また、質問ブロックに対して言い値機能を実装しているので、usershas_many :users, throughの関係でアソシエーションが結ばれています。

ここで実際にリクエストを送ります。N + 1問題検出用のgemであるbulletを使用していますので、bulletのalertがターミナル上に表示されます。

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlock => [:question_items]
  Add to your query: .includes([:question_items])
Call stack

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlock => [:question_block_likes]
  Add to your query: .includes([:question_block_likes])
Call stack

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlock => [:users]
  Add to your query: .includes([:users])
Call stack

GET /api/v1/question_blocks
USE eager loading detected
  QuestionBlockLike => [:user]
  Add to your query: .includes([:user])
Call stack

これだけで4つものN + 1問題が検出されました。長すぎるのでここには少ししか載せませんが、ターミナル上にはたくさんのSQL文が飛んでいます。

  User Load (1.0ms)  SELECT `users`.* FROM `users` INNER JOIN `question_block_likes` ON `users`.`id` = `question_block_likes`.`user_id` WHERE `question_block_likes`.`question_block_id` = 66
  ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index'
  QuestionItem Load (0.9ms)  SELECT `question_items`.* FROM `question_items` WHERE `question_items`.`question_block_id` = 67
  ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index'
  QuestionBlockLike Load (1.5ms)  SELECT `question_block_likes`.* FROM `question_block_likes` WHERE `question_block_likes`.`question_block_id` = 67
  ↳ app/controllers/api/v1/question_blocks_controller.rb:12:in `index'
  ・
    ・
    ・

ここでlazy_has_manyを使用してみます。面倒なので全てにつけてみましょう。

# app/serializers/question_block_serializer.rb
class QuestionBlockSerializer < ApplicationSerializer
  attributes :id, :title, :owing_user
  lazy_has_many   :question_items,  serializer: QuestionItemSerializer
  belongs_to :profile_block,   serializer: ProfileBlockSerializer
  lazy_has_many :question_block_likes
  lazy_has_many :users, through: :question_block_likes
end

実際にQuestionBlockについて発行されたSQLは下記の部分のみでした。


SQL (191.2ms) SELECT question_blocks.id AS t0_r0, question_blocks.title AS t0_r1, question_blocks.profile_block_id AS t0_r2, question_blocks.created_at AS t0_r3, question_blocks.updated_at AS t0_r4, profile_blocks.id AS t1_r0, profile_blocks.user_id AS t1_r1, profile_blocks.created_at AS t1_r2, profile_blocks.updated_at AS t1_r3, users.id AS t2_r0, users.provider AS t2_r1, users.uid AS t2_r2, users.encrypted_password AS t2_r3, users.reset_password_token AS t2_r4, users.reset_password_sent_at AS t2_r5, users.allow_password_change AS t2_r6, users.remember_created_at AS t2_r7, users.name AS t2_r8, users.image AS t2_r9, users.email AS t2_r10, users.role AS t2_r11, users.team_id AS t2_r12, users.created_at AS t2_r13, users.updated_at AS t2_r14, teams.id AS t3_r0, teams.name AS t3_r1, teams.workspace_id AS t3_r2, teams.image AS t3_r3, teams.created_at AS t3_r4, teams.updated_at AS t3_r5 FROM question_blocks LEFT OUTER JOIN profile_blocks ON profile_blocks.id = question_blocks.profile_block_id LEFT OUTER JOIN users ON users.id = profile_blocks.user_id LEFT OUTER JOIN teams ON teams.id = users.team_id WHERE teams.workspace_id = '852021469278993732'


bulletのalertも表示されていません。本当に簡単に導入できてびっくりしました。。

しかし、実際にはこのまま脳死で使用していくのはブラックボックスすぎるのでさらに理解を深められたら後述で本gemについて加筆していきたいと思います。

参考記事

Use lazy relationships to eliminate N+1 queries in your Rails apps

Bajena/ams_lazy_relationships

exAspArk/batch-loader