はじめに
こんにちは、こんばんは、初めまして。
プログラミングスクールRUNTEQで学習し、現在就活フェイズに入ったmassanです。
先週の記事でも触れましたが、現在RUNTEQのアウトプットリレーという企画に参加しており、毎週記事を書いています。
今週からテーマがフリーになるので、今後は自分がRUNTEQで学んだことなどをアウトプットしていこうかなと考えています
今回も僕のRUNTEQでの卒業制作、「Music Hour」で実装したことについてアウトプットがてら記事にしていきます
Music Hourについては先週の記事にざっくりと書いてあるのでそちらをご覧ください
今回は画像投稿機能の実装ですが、タイトルの通り、スタンダードな構成とは少し違っており、逆張り的な構成での実装になっています。
この構成に至った経緯などはNoteにまとめました
本文のほとんどで実装手順を書いていき、最後のまとめでざっくりと今回の構成とスタンダードな構成の違いについて感想を書こうと思います。
対象読者
- MVCやRailsの基本的な動作が理解できる
- 画像投稿機能や関連するgemがある程度わかるもしくは自分で調べられる
- 画像投稿機能実装してみたい
- libvipsを用いた画像処理について知りたい
- スタンダードな構成ではなく別の構成の画像投稿機能も試してみたい
- Music Hourの画像投稿機能について知りたい
この記事を書くのが精いっぱい、かつ長くなりそうだったのでCarrierWaveやMiniMagick、ruby-vipsがどんなものかについては触れることができませんでした。申し訳ありません🙇♂️
環境
- Windows11
- Ubuntu 24.04 LTS (WSL2)
- Docker
- Ruby 3.3.6
- Rails 7.2.1
今回の目標
- Active Storageを用いて、レコードに関連付けた画像をCloudinaryに保存できるようにする
- livbipsを用いて画像を保存する前にリサイズと形式の変換(webpへ変換)をする
実装
画像保存部分の設定
Active Storageの準備
Gemfileで必要なgemを追加します
# Rails標準のActiveStorageで使うので標準で用意されているが、コメントアウトされているので外す
gem "image_processing", "~> 1.2"
gemfileに記述終わったらbundle install
# ローカルの場合はbundle installのみ
$ docker compose run web bundle install
ちなみにimage_processingをインストールするとmini_magickもインストールされています(gemfile lockを確認)
必要なgemのインストールが終わったら、次はActiveStorageの有効化を行っていきます
# コンテナに入る
$ docker compose exec web bash
# コンテナ内で(ローカルの場合はここから)実行
$ rails active_storage:install # 自動的にActive Storage用のマイグレーションファイルが生成される
$ rails db:migrate # マイグレーションファイルは特に編集せずにmigrate
モデルに以下を追加して画像を保存できるようにします。今回は例としてpostモデルにします。
class Post < ApplicationRecord
# 省略
has_one_attached :image # imageはカラム名
# 省略
end
has_one_attachedは読んで字のごとく一つの画像のみを保存するためのものです
複数保存したい場合はhas_many_attachedを使用してください
画像用のバリデーションを追加
ActiveStorageは標準のバリデーションメソッドを用意してくれていないのでgemを使って直感的にバリデーションをかけられるようにします
いくつかありましたが今回はgem ActiveStorage Validation
を使うことにします
Gemfileに追記しbundle install
を行います
gem 'active_storage_validations'
$ docker compose run web bundle install
Postモデルに画像用バリデーションの記述追加
class Post < ApplicationRecord
# 省略
has_one_attached :image # さっき追加したやつ
# ファイルの種類とサイズのバリデーション(gem ActiveStorage Validationを使用)
ACCEPTED_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
validates :image, content_type: ACCEPTED_CONTENT_TYPES,
size: { less_than_or_equal_to: 5.megabytes }
# 省略
end
image/jpeg, image/png, image/gif, image/webpのみを受け付けるようにし、ファイルサイズも5MB
以下のものを受け付けるようにします
このコードのように許可されたファイルの種類を定数として保存しておけば、viewのformでも再利用できますし、バリデーションを変更した場合にも即時にviewのformに反映させることができます。(ってgemのページに書いてありました。笑)
:image
をストロングパラメーターに追加
postsコントローラーで:image
を受け取れるようにストロングパラメーターに追加します
class PostsController < ApplicationController
# 省略
private
# 省略
def post_params
params.require(:post).permit(:title, :body, :image)
end
end
formに画像投稿用のものを追加
<%= form_with(model: post) do |form| %>
<% if post.errors.any? %>
<div>
<%= pluralize(post.errors.count, "error") %>
<ul>
<% post.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<%# 省略 %>
<%= form.label :image, "画像", class: "form-label" %>
<%= form.file_field :image,
class: "form-control",
accept: Post::ACCEPTED_CONTENT_TYPES.join(",")
%>
<small class="form-text text-white-50">
※ JPEG、PNG、GIF、WebP形式(5MB以下)のみアップロード可能です
</small>
<%# 省略 %>
<%= form.submit %>
<% end %>
ile_field
のオプションで先ほどの定数を用いてaccept: Post::ACCEPTED_CONTENT_TYPES.join(",")
のように設定しています
これにより最初に指定したファイルしかリストに現れないのでユーザビリティが向上します。
i18nにエラーメッセージ追加
※追加しなくても自動でgem "active_storage_validations”が用意してくれてるけど違和感あるので追加します。
ja:
activerecord:
# 省略
errors:
models:
# 省略
post:
# 省略
image:
content_type_invalid: "はJPEG、PNG、GIF、WebP形式のみアップロード可能です"
file_size_not_less_than_or_equal_to: "は %{max}以下にしてください (現在のサイズは %{file_size})"
ちなみにですが、このActiveStorage Validation
はテスト用のマッチャーも用意してくれていて、そのテスト内容もとてもしっかりしています
gem shoulda-matchers
と併用するととても簡単に画像アップロード時のテストが書けるので超おすすめです!
今回は扱いませんがCarrierWaveにも同じようなバリデーション用のgemがあったのでもしかすると同じようなことができるかもしれません
参考:
https://github.com/igorkasyanchuk/active_storage_validations?tab=readme-ov-file#test-matchers
https://github.com/thoughtbot/shoulda-matchers
Cloudinary用の設定
Active StorageでCloudinaryに保存するための各種設定を追加していきます
事前に以下の動画を参考にユーザー登録とapiキーなどの取得を行っておいてください
まずは必要なgemをGemfileに追加してbundle install
します
# 画像保存用ストレージcloudinaryのgem
gem "cloudinary"
$ docker compose run web bundle install
config/storage.ymlにcloudinaryを使うときの設定を追加します
cloudinary:
service: Cloudinary
config/environments/production.rb
と config/environments/development.rb
にActiveStorage用の設定が出てきてるはずなので以下のように編集します
※ develop環境で必要なければ config/environments/development.rb
のモノはデフォルトのままでOK
- config.active_storage.service = :local # 削除
+ config.active_storage.service = :cloudinary # 追加
cloudinaryの設定ファイルをconfig以下に作成&記述します
$ touch config/cloudinary.yml
production:
cloud_name: <%= Rails.application.credentials.dig(:cloudinary, :cloud_name) %>
api_key: <%= Rails.application.credentials.dig(:cloudinary, :api_key) %>
api_secret: <%= Rails.application.credentials.dig(:cloudinary, :api_secret) %>
secure: true
development:
cloud_name: <%= Rails.application.credentials.dig(:cloudinary, :cloud_name) %>
api_key: <%= Rails.application.credentials.dig(:cloudinary, :api_key) %>
api_secret: <%= Rails.application.credentials.dig(:cloudinary, :api_secret) %>
secure: true
上記で使う秘匿情報をクレデンシャルファイルconfig/credentials.yml.enc
に追加
# bashに入ってからvimでcredentialsを開く
$ docker compose exec web bash
EDITOR="vi" rails credentials:edit
# cloudinaryの秘匿情報
cloudinary:
cloud_name: "hoge"
api_key: "huga"
api_secret: "uho"
※cloud_nameはCloudinaryのダッシュボードから確認できます
ここまで設定できればOK!
ここまで設定できていれば画像の投稿はできると思います!
ですがこのままだとアップロードされた画像をそのまま保存しているのでDBに負担がかかったり、画像の拡張子がwebpでなかったりする場合があるので、この画像を表示する画面の読み込みが遅くなったりするということがあります。
画像サイズをそろえたりwebpに変換するだけならばCloudinaryの機能で可能ですが、大きすぎる画像は必要ありませんし、DBの容量も節約したいので保存前にリサイズ&形式の変換もしてしまいましょう。
cloudinaryに画像を保存していた場合、表示する際に使う配信リンクにクエリパラメータでオプションを追加して送信することで画像のリサイズや変換、文字の挿入ができたりします。
特に文字の挿入はとても便利で、Music Hourでもこの機能を用いて動的OGPを設定しています
参考:
https://dev.classmethod.jp/articles/cloudinary-transform-images/
https://pote-chil.com/posts/cloudinary
保存前の画像フォーマット
Active Storageには残念ながらデフォルトでの保存前の画像フォーマットがありません。
なので今回はgem image_processing
,gem ruby-vips
を通してlibvips
を使い、画像のリサイズと変換を実装していきます
今後のために、postだけでなく他のモデル、例えばuserモデルでプロフィール画像を設定したい場合などでも使えるようにしておきたいので、今回はRailsのお作法に則ってapp/models/concerns
配下にモジュールを追加しようと思います
libvipsの準備
libvipsという画像ライブラリが必要なのですがこれは直接インストールしておかないといけないのでDockerを用いている今回はDockerfileに記述を追加してインストールします。
※ Dockerでない場合はHomebrewなどを用いてインストールしてください
FROM ruby:3.3.6
ENV LANG C.UTF-8
ENV TZ Asia/Tokyo
RUN apt-get update -qq \
&& apt-get install -y ca-certificates curl gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& NODE_MAJOR=20 \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \
&& wget --quiet -O - /tmp/pubkey.gpg https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \
&& echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs yarn vim
# ここから
RUN apt-get update && \
apt-get install -y libvips42 libvips-dev && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# ここまで追記
RUN mkdir /myapp
WORKDIR /myapp
RUN gem install bundler
COPY Gemfile /myapp/Gemfile
COPY Gemfile.lock /myapp/Gemfile.lock
COPY yarn.lock /myapp/yarn.lock
RUN bundle install
RUN yarn install
COPY . /myapp
追記ができたらビルドをし直します。
$ docker-compose build --no-cache
次に、バリアント作成時の処理で使うバリアントプロセッサの設定をvipsにしておきます
今回はバリアントを使いませんが念のためです
require_relative "boot"
require "rails/all"
Bundler.require(*Rails.groups)
module Myapp
class Application < Rails::Application
# 省略
config.active_storage.variant_processor = :vips # 追加
end
end
これでlibvipsの設定は完了しました。
次は実際に画像の処理のコードを追加していきます。
画像処理ロジックの追加
app/models/concerns
配下にimage_processable.rb
を作成し、ロジックを追加していきます。
$ touch app/models/concerns/image_processable.rb
module ImageProcessable
# エラーの出力を行いたいのでStandardErrorクラスをImageProcessingErrorクラスに継承
class ImageProcessingError < StandardError; end
# 画像処理メソッド。image_ioにはparams[:post][:image]の中の一時ファイルを渡す
# widthには横幅の最大値を渡す
def process_and_transform_image(image_io, width)
return unless image_io.present?
begin
# 画像処理の部分。横幅に合うようにアスペクト比を維持してリサイズ、その後webpに変換する
processed_image = ImageProcessing::Vips
.source(image_io)
.resize_to_fit(width, nil)
.convert("webp")
.saver(strip: true, quality: 85)
.call
# ActionDispatch::Http::UploadedFileを返す
ActionDispatch::Http::UploadedFile.new(
tempfile: processed_image,
filename: "#{File.basename(image_io.original_filename, '.*')}.webp",
type: "image/webp"
)
rescue => e
Rails.logger.error "Image processing error: #{e.message}"
raise ImageProcessingError, "画像の処理中にエラーが発生しました: #{e.message}"
end
end
end
画像処理部分は以下を参考に追加
https://github.com/janko/image_processing/blob/master/doc/vips.md#readme
-
.source
読み込み -
.resize_to_fit(width, nil)
リサイズ -
.convert("webp")
webpに変換 -
.saver(strip: true, quality: 85)
保存時の設定-
strip: true
画像のメタデータを削除。プライバシー保護の目的とデータ量の削減 -
quality: 85
画像の圧縮品質を85%に設定
-
-
.call
実際に保存を実行
これを一時ファイルを管理しているActionDispatch::Http::UploadedFile.new
のインスタンス化し、返す
といったことを行っています
ActionDispatch::Http::UploadedFile
については以下を参照
要するにアップロードされたファイルをRailsで扱いやすいようにしてくれるものです(ざっくり)
コントローラーの記述
画像処理を画像の保存時に実行したいのでコントローラーを少し変更します
class PostsController < ApplicationController
before_action :set_post, only: %i[ show edit update destroy ]
# 省略
def create
# まずは属性の更新のみを行う
@post = current_user.posts.build(post_params)
if @post.valid?
begin
# 画像を処理して更新
@post.image = ImageProcessable.process_and_transform_image(params[:post][:image], 854) if params[:post][:image].present?
if @post.save
flash[:notice] = "番組を作成しました"
redirect_to @post
end
# モジュールで設定したエラーのキャッチ
rescue ImageProcessable::ImageProcessingError => e
flash.now[:danger] = e.message
render :new, status: :unprocessable_entity
end
else
flash.now[:danger] = "番組を作成できませんでした、番組作成フォームを確認してください"
render :new, status: :unprocessable_entity
end
end
def update
@post.assign_attributes(post_params)
if @post.valid?
begin
@post.image = ImageProcessable.process_and_transform_image(params[:post][:image], 854) if params[:post][:image].present?
if @post.save
flash[:notice] = "番組を編集しました"
redirect_to @post
end
rescue ImageProcessable::ImageProcessingError => e
flash.now[:danger] = e.message
render :edit, status: :unprocessable_entity
end
else
flash.now[:danger] = "番組を編集できませんでした、番組編集フォームを確認してください"
render :edit, status: :unprocessable_entity
end
end
private
def set_post
@post = Post.find(params[:id])
end
def post_params
params.require(:post).permit(:title, :body, :image)
end
end
まず保存はせず属性の更新のみ行います
@post.valid?
でのバリデーションチェックを通過した場合
ImageProcessable.process_and_transform_image(params[:post][:image], 854) if params[:post][:image].present?
の部分で画像を処理し、帰ってきた一時ファイルでimgae
カラムを更新して保存
といった流れになっています。
imageカラムはActiveStorage::Attached::One
のオブジェクトで フォームから渡されたファイルはActionDispatch::Http::UploadedFile
のオブジェクトになるので本来なら直接渡せません。
しかしActive Storageでは、@post.image = uploaded_fileと代入すると、直接的に@post.imageに設定するのではなく、「添付予定」として保存される仕組みになっているためエラーが発生せず、見た目上は他のカラムと同じように扱えるようになっています。
参考:
https://github.com/rails/rails/blob/22a61df687a5408a37ed0b9dcf73c08b250dc9be/activestorage/lib/active_storage/attached/model.rb#L118
https://zenn.dev/masato_kato/scraps/06a5daff59c73f
画像処理ロジック追加完了!
これで画像処理ロジックの追加が完了しました
画像を保存すれば自動的にリサイズと変換が行われ、保存されます
まとめ
これで画像アップロード機能と保存時の画像フォーマットが完成しました
ここまでやってみてスタンダードな構成と今回の構成を比べた感想としては
- 画像処理など複雑な処理をするならcarrierwaveのほうがよさそう(デフォルトで画像処理する設定を書く部分がある)
- Active Storageは簡単に設定できて画像保存ロジックも直感的
- Active Storageには画像のキャッシュ機能がないためバリデーションエラー時に画像を選択しなおさないといけない
- 上記の理由からActive Storageよりもcarrierwave使ったほうがよさげ
- MiniMagickとruby-vipsは互換性が高く簡単に入れ替えられるためどちらでもよさげ(脆弱性や処理速度を考えるとruby-vipsが好感触)
- S3とcloudinaryは好み(そもそも僕がそんなに深堀りしていないので・・・w)
という感じでした
今回、スタンダードな構成ではなく逆張り的な構成で実装してみたおかげで、なぜスタンダードな構成がよく用いられるのか分かった気がしました。
今後もただ単に流行っているものやスタンダードなものを選んで使うだけでなく、よく使われるものはなぜよく使われるのか、なぜ流行っているのかといったことにもしっかりと目を向けて深堀りをしていきたいと思いました。
こういうのを理解するために、逆張り実装、おすすめです。