【Rails 7】Active Storage × Cloudinary で画像保存＋libvipsで画像フォーマット

# はじめに
こんにちは、こんばんは、初めまして。
プログラミングスクールRUNTEQで学習し、現在就活フェイズに入った[massan](https://qiita.com/massan-E/questions)です。

[先週の記事](https://qiita.com/massan-E/items/31eac680af7772f029cf)でも触れましたが、現在RUNTEQの[アウトプットリレー](https://note.com/hashtag/%E3%82%A2%E3%82%A6%E3%83%88%E3%83%97%E3%83%83%E3%83%88%E3%83%AA%E3%83%AC%E3%83%BC?f=new&paid_only=false)という企画に参加しており、毎週記事を書いています。

今週からテーマがフリーになるので、今後は自分がRUNTEQで学んだことなどをアウトプットしていこうかなと考えています

今回も僕のRUNTEQでの卒業制作、「Music Hour」で実装したことについてアウトプットがてら記事にしていきます
Music Hourについては先週の記事にざっくりと書いてあるので[そちら](https://qiita.com/massan-E/items/31eac680af7772f029cf#music-hour%E3%81%A8%E3%81%AF)をご覧ください

今回は画像投稿機能の実装ですが、タイトルの通り、スタンダードな構成とは少し違っており、逆張り的な構成での実装になっています。
この構成に至った経緯などはNoteにまとめました

https://note.com/massan_e/n/n237246f5a6d4


本文のほとんどで実装手順を書いていき、最後のまとめでざっくりと今回の構成とスタンダードな構成の違いについて感想を書こうと思います。

# 対象読者
- MVCやRailsの基本的な動作が理解できる
- 画像投稿機能や関連するgemがある程度わかるもしくは自分で調べられる
- 画像投稿機能実装してみたい
- libvipsを用いた画像処理について知りたい
- スタンダードな構成ではなく別の構成の画像投稿機能も試してみたい
- Music Hourの画像投稿機能について知りたい

:::note info
この記事を書くのが精いっぱい、かつ長くなりそうだったので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を追加します
```ruby:Gemfile
# Rails標準のActiveStorageで使うので標準で用意されているが、コメントアウトされているので外す
gem "image_processing", "~> 1.2"
```
gemfileに記述終わったらbundle install
```bash
# ローカルの場合はbundle installのみ
$ docker compose run web bundle install
```
ちなみにimage_processingをインストールするとmini_magickもインストールされています（gemfile lockを確認）


https://github.com/janko/image_processing

https://github.com/cloudinary/cloudinary_gem

<br>

必要なgemのインストールが終わったら、次はActiveStorageの有効化を行っていきます
```bash
# コンテナに入る
$ docker compose exec web bash
# コンテナ内で（ローカルの場合はここから）実行
$ rails active_storage:install  # 自動的にActive Storage用のマイグレーションファイルが生成される
$ rails db:migrate  # マイグレーションファイルは特に編集せずにmigrate
```

モデルに以下を追加して画像を保存できるようにします。今回は例としてpostモデルにします。
```ruby:app/models/post.rb
class Post < ApplicationRecord
# 省略
  has_one_attached :image   # imageはカラム名
# 省略
end
```
:::note info
has_one_attachedは読んで字のごとく一つの画像のみを保存するためのものです
複数保存したい場合はhas_many_attachedを使用してください

参考：https://railsguides.jp/active_storage_overview.html#%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%92%E3%83%AC%E3%82%B3%E3%83%BC%E3%83%89%E3%81%AB%E6%B7%BB%E4%BB%98%E3%81%99%E3%82%8B
:::
<br>

### 画像用のバリデーションを追加

ActiveStorageは標準のバリデーションメソッドを用意してくれていないのでgemを使って直感的にバリデーションをかけられるようにします
いくつかありましたが今回は`gem ActiveStorage Validation`を使うことにします

Gemfileに追記し`bundle install`を行います
```ruby:Gemfile
gem 'active_storage_validations'
```

```bash
$ docker compose run web bundle install
```
Postモデルに画像用バリデーションの記述追加

```ruby:app/models/post.rb
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のページに書いてありました。笑）

<br>

### `:image`をストロングパラメーターに追加
postsコントローラーで`:image`を受け取れるようにストロングパラメーターに追加します

```ruby:app/controllers/psots_controller.rb
class PostsController < ApplicationController
  # 省略
  private
  # 省略
    def post_params
      params.require(:post).permit(:title, :body, :image)
    end
end
```

<br>

### formに画像投稿用のものを追加

```erb:app/views/posts/_form.html.erb
  <%= 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 %>
```

<br>

`ile_field`のオプションで先ほどの定数を用いて`accept: Post::ACCEPTED_CONTENT_TYPES.join(",")`のように設定しています
これにより最初に指定したファイルしかリストに現れないのでユーザビリティが向上します。

https://github.com/igorkasyanchuk/active_storage_validations?tab=readme-ov-file#internationalization-i18n

<br>

<details>
<summary>i18nにエラーメッセージ追加</summary>

※追加しなくても自動でgem "active_storage_validations”が用意してくれてるけど違和感あるので追加します。

https://github.com/igorkasyanchuk/active_storage_validations?tab=readme-ov-file#internationalization-i18n

```yaml
ja:
  activerecord:
    # 省略
    errors:
      models:
        # 省略
        post:
          # 省略
            image:
              content_type_invalid: "はJPEG、PNG、GIF、WebP形式のみアップロード可能です"
              file_size_not_less_than_or_equal_to: "は %{max}以下にしてください (現在のサイズは %{file_size})"
```
</details>

<br>

:::note info
ちなみにですが、この`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
:::

<br>

### Cloudinary用の設定
Active StorageでCloudinaryに保存するための各種設定を追加していきます

事前に以下の動画を参考にユーザー登録とapiキーなどの取得を行っておいてください

https://youtu.be/682QkAioZGk?t=989

まずは必要なgemをGemfileに追加して`bundle install`します
```ruby:Gemfile
# 画像保存用ストレージcloudinaryのgem
gem "cloudinary"
```

```bash
$ docker compose run web bundle install
```

config/storage.ymlにcloudinaryを使うときの設定を追加します

```yaml:config/storage.yml
cloudinary:
  service: Cloudinary
```

`config/environments/production.rb`と `config/environments/development.rb`にActiveStorage用の設定が出てきてるはずなので以下のように編集します　
※ develop環境で必要なければ `config/environments/development.rb`のモノはデフォルトのままでOK

```ruby
- config.active_storage.service = :local       # 削除
+ config.active_storage.service = :cloudinary  # 追加
```

cloudinaryの設定ファイルをconfig以下に作成＆記述します
```bash
$ touch config/cloudinary.yml
```

```yaml: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
# bashに入ってからvimでcredentialsを開く
$ docker compose exec web bash
EDITOR="vi" rails credentials:edit
```

```bash:config/credentials.yml.enc
# cloudinaryの秘匿情報
cloudinary:
  cloud_name: "hoge"
  api_key: "huga"
  api_secret: "uho"
```
:::note info
※cloud_nameはCloudinaryのダッシュボードから確認できます
:::

<br>

### ここまで設定できればOK！
ここまで設定できていれば画像の投稿はできると思います！

ですがこのままだとアップロードされた画像をそのまま保存しているのでDBに負担がかかったり、画像の拡張子がwebpでなかったりする場合があるので、この画像を表示する画面の読み込みが遅くなったりするということがあります。

画像サイズをそろえたりwebpに変換するだけならばCloudinaryの機能で可能ですが、大きすぎる画像は必要ありませんし、DBの容量も節約したいので保存前にリサイズ＆形式の変換もしてしまいましょう。

:::note
cloudinaryに画像を保存していた場合、表示する際に使う配信リンクにクエリパラメータでオプションを追加して送信することで画像のリサイズや変換、文字の挿入ができたりします。
特に文字の挿入はとても便利で、Music Hourでもこの機能を用いて動的OGPを設定しています

参考：
https://dev.classmethod.jp/articles/cloudinary-transform-images/
https://pote-chil.com/posts/cloudinary
:::

<br>

## 保存前の画像フォーマット
Active Storageには残念ながらデフォルトでの保存前の画像フォーマットがありません。

なので今回は`gem image_processing`,`gem ruby-vips`を通して`libvips`を使い、画像のリサイズと変換を実装していきます

今後のために、postだけでなく他のモデル、例えばuserモデルでプロフィール画像を設定したい場合などでも使えるようにしておきたいので、今回はRailsのお作法に則って`app/models/concerns`配下にモジュールを追加しようと思います

<br>

### libvipsの準備
libvipsという画像ライブラリが必要なのですがこれは直接インストールしておかないといけないのでDockerを用いている今回はDockerfileに記述を追加してインストールします。
※ Dockerでない場合はHomebrewなどを用いてインストールしてください

```dockerfile:Dockerfile
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

```

追記ができたらビルドをし直します。
```bash
$ docker-compose build --no-cache
```

 
次に、バリアント作成時の処理で使うバリアントプロセッサの設定をvipsにしておきます
今回はバリアントを使いませんが念のためです

```ruby:config/application.rb
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の設定は完了しました。
次は実際に画像の処理のコードを追加していきます。

<br>

### 画像処理ロジックの追加
`app/models/concerns`配下に`image_processable.rb`を作成し、ロジックを追加していきます。

```bash
$ touch app/models/concerns/image_processable.rb
```

```ruby: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`については以下を参照

https://ippachi.hatenablog.com/entry/2019/12/19/130900

https://qiita.com/selmertsx/items/2beb0d7ec0774cbbf050

要するにアップロードされたファイルをRailsで扱いやすいようにしてくれるものです（ざっくり）

<br>

### コントローラーの記述

画像処理を画像の保存時に実行したいのでコントローラーを少し変更します

```ruby:app/controllers/posts_controller.rb
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`カラムを更新して保存
といった流れになっています。

:::note warn
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
:::

### 画像処理ロジック追加完了！
これで画像処理ロジックの追加が完了しました
画像を保存すれば自動的にリサイズと変換が行われ、保存されます

<br>

# まとめ
これで画像アップロード機能と保存時の画像フォーマットが完成しました
ここまでやってみてスタンダードな構成と今回の構成を比べた感想としては
- 画像処理など複雑な処理をするならcarrierwaveのほうがよさそう（デフォルトで画像処理する設定を書く部分がある）
- Active Storageは簡単に設定できて画像保存ロジックも直感的
- Active Storageには画像のキャッシュ機能がないためバリデーションエラー時に画像を選択しなおさないといけない
- 上記の理由からActive Storageよりもcarrierwave使ったほうがよさげ
- MiniMagickとruby-vipsは互換性が高く簡単に入れ替えられるためどちらでもよさげ（脆弱性や処理速度を考えるとruby-vipsが好感触）
- S3とcloudinaryは好み（そもそも僕がそんなに深堀りしていないので・・・ｗ）

という感じでした

今回、スタンダードな構成ではなく逆張り的な構成で実装してみたおかげで、なぜスタンダードな構成がよく用いられるのか分かった気がしました。

今後もただ単に流行っているものやスタンダードなものを選んで使うだけでなく、よく使われるものはなぜよく使われるのか、なぜ流行っているのかといったことにもしっかりと目を向けて深堀りをしていきたいと思いました。

こういうのを理解するために、逆張り実装、おすすめです。