ActiveStorageのN+1問題
こんにちは!
毎週の更新を頑張りたいのですが、ブログ以外の進捗がメインなので、隔週に頻度を下げるかもしれません。。。
最近は、外で学習するときはゲーム音楽のサントラを聴いています。
ボーカル無い方が集中できる+戦闘曲はテンション上げられるのでおすすめです。
ちなみに今は、不思議の幻想郷シリーズのサントラです。
東方二次創作のローグライクゲームですね。
はい、本題!
今回は、やや調べるのに時間をかけてしまったActiveStorageのN+1問題を解消する方法を書きます。
ActiveStorageは画像データなどをデータベースに保存する時に使います。
N+1問題は、アソシエーションで関連づけたデータを呼び出す時にallを使用していると、元のデータへのアクセスに1 回、関連づけられたN個の要素を呼び出すためにN 回、合計(N+1) 回データベースにアクセスしてしまう問題です。
投稿サイトで投稿と投稿者名を表示する時などに発生します。
この場合は、投稿数をNとすると、投稿の取得に1回、投稿に関連づけられた投稿者名を投稿回数分取得しにいくのでN回、合計(N+1)回のデータベースへのアクセスとなります。
そのため、投稿が増えれば増えるほどデータベースへのアクセス回数が多くなり、ページの表示に負荷がかかってしまします。
解決方法は、includeメソッドを使用することです。
1回のアクセスで関連するデータを全て取得してくるようになるので、データベースへのアクセスは(1+1) 回ですみます。
ただし、ActiveStorageではincudesメソッドをそのままの形では使えません。。。
ではどうするか。結論としては、一次資料を見れば一発解決!でした。。。
ActiveStorageのReadmeに書いてあります
ほら。。。なんで気がつかなかったんだろう。。。
# Use the built-in with_attached_images scope to avoid N+1 @messages = Message.all.with_attached_images
これで解決なのですが、これでは芸が無いので実際にミニアプリを作成して挙動を確認してみます。
今回作成するミニアプリは、メッセージと複数枚の画像を同時に投稿できるミニアプリです。
トップページに、今までに投稿されたメッセージと画像が表示されます。
環境は、以下のとおりです。
ruby 2.6.5
rails 6.0.3.3
mysql使用
新規作成
rails _6.0.0_ new アプリ名 -d mysql
で新規作成します。
ActiveStorageの導入
続いて、ActiveStorageを導入します。
rails active_storage:include rails db:migrate
これで、データベースにActiveStorageのテーブルが作成されました。
active_storage_attachmentsとactive_storage_blobsができているはずです。
messageモデルの作成
投稿されたメッセージを保存するために、messageモデルを作成します。
rails g model message
作成されたマイグレーションファイルを編集し、メッセージを保存するためのcontentカラムを追加します。
class CreateMessages < ActiveRecord::Migration[6.0] def change create_table :messages do |t| t.text :content, null: false # この行を追加 t.timestamps end end end
マイグレーションファイルを編集したら、
rails db:migrate
を忘れずに。
アソシエーションの追加
messageモデルとActiveStorageを関連付けます。
app/models/message.rbに以下の文言を追加するだけ。
has_many_attached :images
今回は複数枚の画像の保存ということで、has_many_attachedを使用しています。
一枚だけで良い場合は、has_one_attachedを使用してください。
これで、messageモデルに対してimagesを保存しようとすると、ActiveStorageに保存されるようになりました。
トップページの作成
コントローラとトップページのビューを作成します。
rails g controller messages index
これで、messagesコントローラと対応するindexビューが作成されます。
今回は、このindexビューをルートに設定します。
config/routes.rbを編集します。
Rails.application.routes.draw do root 'messages#index' end
これで、サーバを立ち上げてローカルホストに接続すれば、以下の画面が表示されるはずです。
トップページの編集(投稿機能と表示機能の実装)
routes.rb、messages_controller.rb、index.html.erbを編集します。
routes.rb Rails.application.routes.draw do root 'messages#index' resources :messages, only: :create end
messages_controller.rb class MessagesController < ApplicationController def index @message = Message.new # まずは、何も考えずにallでデータベースから情報を取得します。 @messages = Message.all end def create @message = Message.new(params_message) if @message.save redirect_to root_path else render :index end end private def params_message # images: []として配列であることを明示 params.require(:message).permit(:content, images: []) end end
index.html.erb <h1>複数枚画像投稿</h1> <%= form_with model: @message, url: messages_path, local: true do |f| %> <%= f.text_field :content %> <%# name: 'message[images][]'として、imagesを配列として明示 %> <%= f.file_field :images, name: 'message[images][]' %> <%= f.file_field :images, name: 'message[images][]' %> <%= f.file_field :images, name: 'message[images][]' %> <%= f.submit %> <% end %> <h3>投稿されたコメントと画像</h3> <% @messages.each do |message| %> <p><%= message.content %></p> <div class='image-box'> <% message.images.each do |image| %> <%= image_tag image, class: 'image' %> <% end %> </div> <% end %>
だいぶ説明を端折っていますが、createアクションのルーティング、@messageに新規の投稿を格納、@messagesに今までの投稿を格納、あとは投稿フォームの作成をしています。
今回は省略していますが、バリデーションの設定もした方が良いです。
ポイントとしては、以下の2点です。
- まずは、何も考えずに@messages = Message.allで今までの投稿を取得します
- 複数の画像を保存するために、imagesをarray型で明示しています。
これで、トップページが完成しました。
メッセージと画像を投稿します。
2つのメッセージと、それぞれのメッセージに3枚ずつ合計6枚の画像を投稿しました。
ログの確認
サーバを立ち上げているターミナルでログを確認していきます。
ちなみに、私はvscode上でターミナルを起動しています。
まずは、allで取得してきた場合。
水色の箇所がデータベースにアクセスしている部分です。
メッセージの取得に1回、画像の取得に8回アクセスしています。
ActtiveStorageのテーブルへのアクセスは、まずactive_storage_attachmentsでどのblobを引き出せば良いか確認(1回)、active_storage_blobsからデータの取得(1つのメッセージにつき3枚の画像があるので3回)をしています。
今回はメッセージが2件なので、(1 回+3 回)/件 * 2件 = 8 回のアクセスになります。
メッセージと画像の投稿が増えていくにつれ、データベースへのアクセスが増えてしまうN+1問題です。
これを解消するのが、with_attached_(アソシエーションで設定した名前)です。
messages_controller.rbを以下のように編集します。
# N+1問題解決版です。allをやめてwith_attached_imagesを使います。(allは残してMessage.all.with_attached_imagesとしてもokです) @messages = Message.with_attached_images
includes(images_attachments: :blob)
を呼んできているので、上記のように書くこともできます。
お好みで使い分けてください。
この状態で、もう一度ログを見てみます。
メッセージ、active_storage_attachments、active_storage_blobsに1回ずつ合計3回のアクセスになりました。
これで投稿が増えても安心です。
まとめ
ActiveStorageのN+1問題を解消する方法でした。
has_one_attachedの時にも、基本的には同様の方法で解消できます。
N+1問題は表には出てこない部分なので、意識していないと忘れてしまいがちです。
安易にallを使用しない、データベースへのアクセスが無駄に多く無いかログを確認するようにして気付けるようにしていきたいです。
最後までお付き合いいただきありがとうございました!