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ができているはずです。

f:id:kojiprg:20200916160953p:plain
ActiveStorageのテーブルは上の2つです。

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

を忘れずに。

f:id:kojiprg:20200916162014p:plain
messagesテーブル作成。contentカラムが追加されている。

アソシエーションの追加

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

これで、サーバを立ち上げてローカルホストに接続すれば、以下の画面が表示されるはずです。

f:id:kojiprg:20200916163157p:plain
トップページへのアクセス確認。表示はこれから編集します。

トップページの編集(投稿機能と表示機能の実装)

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点です。

  1. まずは、何も考えずに@messages = Message.allで今までの投稿を取得します
  2. 複数の画像を保存するために、imagesをarray型で明示しています。

これで、トップページが完成しました。

f:id:kojiprg:20200916165433p:plain
完成したトップページ

メッセージと画像を投稿します。
2つのメッセージと、それぞれのメッセージに3枚ずつ合計6枚の画像を投稿しました。

f:id:kojiprg:20200916165609p:plain
メッセージ2つと6枚の画像

ログの確認

サーバを立ち上げているターミナルでログを確認していきます。
ちなみに、私はvscode上でターミナルを起動しています。
まずは、allで取得してきた場合。

f:id:kojiprg:20200916170010p:plain
allを使用した場合の、データベースへのアクセス。画像を1つずつ拾ってきています。

水色の箇所がデータベースにアクセスしている部分です。
メッセージの取得に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)

を呼んできているので、上記のように書くこともできます。
お好みで使い分けてください。
この状態で、もう一度ログを見てみます。

f:id:kojiprg:20200916171620p:plain
with_attached_imagesを使用した場合。N+1問題が解消されてすっきりしました。

メッセージ、active_storage_attachments、active_storage_blobsに1回ずつ合計3回のアクセスになりました。
これで投稿が増えても安心です。

まとめ

ActiveStorageのN+1問題を解消する方法でした。
has_one_attachedの時にも、基本的には同様の方法で解消できます。
N+1問題は表には出てこない部分なので、意識していないと忘れてしまいがちです。
安易にallを使用しない、データベースへのアクセスが無駄に多く無いかログを確認するようにして気付けるようにしていきたいです。
最後までお付き合いいただきありがとうございました!