No day younger than today

RubyとかRailsとか蒙古タンメンとか

【Rails】ActiveStorageを使った画像アップロード機能を実装する

こんにちは!!
フィヨルドブートキャンプでWebエンジニアを目指してプログラミング習得に励んでいる@ふーがです。

bootcamp.fjord.jp

Railsの標準機能である「ActibeStorage」を使って画像をアップロードする機能を実装したので、復習と備忘録をかねて記事にしたいと思います。

2021年4月現在の内容で作成していますが、内容の間違いや、認識の誤りがあればぜひ(やさしめに)ご指摘いただけますと幸いです。

ActiveStorageとは

Rails5.2から標準で搭載されるようになった、ファイルアップロードを行うための機能です。
Amazon S3Google Cloud Storageといったクラウドストレージサービスへのファイルのアップロードや、Active Recordに紐づけて画像を利用できるようになっています。

今回は、development環境でローカルディスクに画像を保存する形で、「ユーザーアイコン機能」を実装する方法について書いていきたいと思います。

前提

以前書いた記事、【Rails】deviseを使ってユーザー認証を実装する - No day younger than todayの内容を実装していることが前提になります。
具体的には、「deviseで実装したユーザー機能にユーザーアイコンを設定できるようにActiveStorageを導入する」ということになります。

環境

実装手順

それでは、順番に実装していきたいと思います。

アイコンアップロード機能を有効にする

必要なテーブルを生成する

最初に以下のコマンドを実行します。

$ bin/rails active_storage:install

すると、active_storage_attachmentsactive_storage_blobsいう名前の2つのテーブルを作成するmigrationファイルが生成されます。

# db/migrate/[VersionID]_create_active_storage_tables.active_storage.rb
class CreateActiveStorageTables < ActiveRecord::Migration[5.2]
  def change
    create_table :active_storage_blobs do |t|
      t.string   :key,          null: false
      t.string   :filename,     null: false
      t.string   :content_type
      t.text     :metadata
      t.string   :service_name, null: false
      t.bigint   :byte_size,    null: false
      t.string   :checksum,     null: false
      t.datetime :created_at,   null: false

      t.index [:key], unique: true
    end

    create_table :active_storage_attachments do |t|
      t.string     :name,     null: false
      t.references :record,   null: false, polymorphic: true, index: false
      t.references :blob,     null: false

      t.datetime :created_at, null: false

      t.index %i[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end

    create_table :active_storage_variant_records do |t| # rubocop:disable all
      t.belongs_to :blob, null: false, index: false
      t.string :variation_digest, null: false

      t.index %i[blob_id variation_digest], name: 'index_active_storage_variant_records_uniqueness', unique: true
      t.foreign_key :active_storage_blobs, column: :blob_id
    end
  end
end

マイグレーションを実行します。

$ bin/rails db:migrate

Userモデルにアイコンを紐付ける

has_one_attachedで、レコードとファイルの間に1対1の関連付けを定義します。

# app/models/user.rb
class User < ApplicationRecord
  has_one_attached :user_icon
end

これによって、user.user_icon.attachuser.user_icon.attached?といったメソッドが使えるようになりました。

user.user_icon.attachは、ユーザーオブジェクトにuser_iconを添付するためのメソッドです。
user.user_icon.attached?は、特定のユーザーがuser_iconを持っているかどうか検証し、真偽値を返すメソッドです。

Strong Parametersを追加する

次に、application_conntrollerのStrong Parametersに、user_iconを許可するように追加します。

# app/controllers/application_controller.rb
def configure_permitted_parameters
  keys = %i[name postal_code address self_introduction user_icon]
  devise_parameter_sanitizer.permit(:sign_up, keys: keys)
  devise_parameter_sanitizer.permit(:account_update, keys: keys)
end

keysという配列の中に、許可するパラメータ名が入っています。
そこにuser_iconを追加しています。

viewにアップロード用のフォームを設置する

# app/views/devise/registrations/_profile_fields.html.erb
<div class="field">
  <%= f.label :user_icon %>
  <%= f.file_field :user_icon %>
</div>

これで、ファイルアップロードができるようになりました。

アップロードしたアイコンを表示する

次に、設定されたアイコンを表示できるようにviewファイルを編集していきます。

# app/views/users/index.html.erb
.
.
<th><%= User.human_attribute_name(:user_icon) %></th>
.
.
<td>
  <% if user.user_icon.attached? %><%= image_tag user.user_icon %><% end %>
</td>
# app/views/users/index.html.erb
<% if @user.user_icon.attached? %>
  <p>
    <strong><%= User.human_attribute_name(:user_icon) %>:</strong>
    <%= image_tag @user.user_icon %>
  </p>
<% end %>

現在設定されているアイコンを表示する

プロフィール編集ページにも、アイコンを表示します。

# app/views/users/show.html.erb
<% if @user.user_icon.attached? %>
  <p>
    <strong><%= User.human_attribute_name(:user_icon) %>:</strong>
    <%= image_tag @user.user_icon %>
  </p>
<% end %>

@user.user_icon.attached?で、アイコンが設定されているかどうかを検証し、設定されている場合のみアイコンを表示するようにしています。

表示する画像をリサイズする

現在の状態だと、画像サイズにかかわらずそのまま表示するようになっています。
小さい画像ならいいのですが、「1980x1200」のような大きい画像をアップロードした場合、表示が崩れるだけでなくネットワークにも大きな負荷がかかってしまいます。

これを防ぐため、指定したサイズで表示されるようにします。

ImageMagickをインストールする

ImageMagickは、画像の操作や表示のために使うことができるソフトウェアです。
これをマシン自体にインストールすることで、このあとインストールするimage_processingと連携して画像を操作することが可能です。

$ brew install imagemagick

image_processing gemをインストールする

# Gemfile
gem 'image_processing', '~> 1.2'
$ bundle install

画像を出力するコードを修正する

以下のように、.variant(resize: "100x100")とすることで、「100x100」サイズに変換した上でURLを返します。
初めてアクセスする場合にリサイズが実行されるので少し時間がかかりますが、それ以降は時間がかかることはありません。

<%= image_tag user.user_icon.variant(resize: "100x100") %>

ファイルタイプを制限する

現状は画像以外のファイルでもアップロードはできてしまいます。
しかし、画像以外のファイルがアップロードされることは想定された作りになっていないので、バリデーションを設定してアップロードできるファイルタイプを制限します。

ここでは、「jpg, jpeg, gif, png」のみをアップロードできるように制限したいと思います。

# app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
  validate :verify_file_type  # ①
  has_one_attached :user_icon

  private

  def verify_file_type
    return unless user_icon.attached?  # ②

    allowed_file_types = %w[image/jpg image/jpeg image/gif image/png]  # ③
    errors.add(:user_icon, 'only jpg, jpeg, gif, png') unless allowed_file_types.include?(user_icon.blob.content_type)  # ④
  end
end

①カスタムバリデーションヘルパーを登録

vaidateに続けてカスタムメソッドのシンボルを渡すことで、そのメソッドを登録することができます。

privateメソッドとしてカスタムバリデーションを定義しています。

②ファイルアップロードの有無を検証

画像ファイルがアップロードされているかどうかをチェックして、されている場合は次の処理に移り、されていない場合はreturnでメソッドを終了させています。

③許容するファイルタイプを定義

今回は基本的な画像ファイルのみとしたいので、jpeg,gif,pngの配列を作っています。

④ファイルタイプの検証

user_icon.blob.content_typeでアップロードされているファイルタイプをチェックし、それが③で定義した許容されるファイルタイプのいずれかにマッチするかどうかを検証しています。

マッチしない場合には、エラーメッセージを追加して、バリデーションエラーが起こるようにしています。

「N+1問題」を解消する

ここまでで一通りの画像アップロード機能が実装できましたが、1つ問題が残っています。
それは、「N+1問題」です。

「N+1問題」とは

ループ処理の中で都度SQLが発行されてしまい、大量のSQLが発行されることでパフォーマンスの低下を招く問題のことです。
1対多のリレーションを張っている場合に起きます。

参考:qiita.com

このアプリでも、ユーザー一覧ページを表示した場合にこの問題が起こります。

小規模なアプリであればそこまで大きな問題にはならないようですが、解消しておきます。

現在の状況

現状、実際N+1問題が発生しているのかを確認するために、Railsサーバーを立ち上げてユーザー一覧ページにアクセスし、ログを見てみたいと思います。

f:id:fuga__ch:20210426205456p:plain

f:id:fuga__ch:20210426205452p:plain

ログを見てみるとわかるとおり(途中はしょってしまっていますが…)、「25件分のユーザーを取得するクエリ1回」と「各ユーザーのデータを取得するクエリ25回」の合計26回のクエリが発行されてしまっています。
これでは取得する件数が増えれば増えるほど、クエリの件数も比例して増えていってしまいます。

25件ならまだしも、これが1万件あったら大変です。

これこそが「N+1問題」の正体です。

解決方法

ループ処理を開始する部分で、with_attached_user_iconを入れてあげるだけです。

# app/views/users/index.html.erb
<% @users.with_attached_user_icon.each do |user| %>

これでN+1問題が解決されるはずです。
実際にログを見てみましょう。

f:id:fuga__ch:20210426205459p:plain

なんとクエリの発行はたったの2回だけです。
取得するデータが何件になっても、クエリ自体は2回だけです。

まとめ

ActiveStorageを使うことで簡単に画像アップロード機能が実装できました。
今回は1ユーザー1枚の画像でしたが、複数の画像を持たせることも簡単にできるようです。

WEBサービスで画像の扱いは切っても切れない関係であると思うので、もっと使いこなしていけるようにしたいものです。

最後まで読んでいただきありがとうございました。