とりあえずコード書けよ

技術的なことの備忘録。

【Rails】PostgreSQLのバイナリ型で画像を扱う

経緯

  • 画像のアップロード機能を追加したい
  • アップロードしたデータはアップロードしたユーザー以外にみられたくない
  • S3などの外部ストレージに保存したくない

これらの条件を考えた時に、バイナリ型でDBに突っ込むのが一番手取り早いと思いこの実装にいたる。
また、アップローダー系のGemは外部ストレージに送る場合でない限りさほどメリットがなさそうだったので、バリデーションも自前で作る。

環境

概要

  • binaryデータとContentTypeをDBに保存
  • Base64エンコードしてインライン画像化する

実装

1.マイグレーションファイルの作成
rails g model Image user:references data:binary content_type:string uploaded_at:datetime
# db/migrate/xxxxxxxxxxxxxxxx_create_images.rb
class CreateImages < ActiveRecord::Migration[6.0]
  def change
    create_table :images do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.binary :data, null: false, limit: 10.megabyte, comment: 'データ'
      t.string :content_type, null: false, comment: 'ContentType'
      t.datetime :uploaded_at, default: -> { 'CURRENT_TIMESTAMP' }, null: false, comment: 'アップロード日'

      t.timestamps
    end
  end
end

※binaryのデフォルトのファイルサイズが64kbなので、limitでファイルサイズの設定をする必要あり。

2.マイグレーション実行
rails db:migrate
3.テストファイルの追加
# test/models/image_test.rb
require 'test_helper'

class ImageTest < ActiveSupport::TestCase
  include ActionDispatch::TestProcess

  test 'when upload_file size is over 10MB, it is invalid' do
    file = fixture_file_upload('files/invalid_image_1.jpg', 'image/jpeg')
    image = Image.new(upload_file: file)
    image.valid?
    assert_includes image.errors[:data], 'のファイルサイズは10MB以下にしてください'
  end

  test 'when upload_file content_type is not image/png or image/jpeg, it is invalid' do
    file = fixture_file_upload('files/invalid_image_2.pdf', 'application/pdf')
    image = Image.new(upload_file: file)
    image.valid?
    assert_includes image.errors[:content_type], 'はファイルの種類に問題があります'
  end
end

ActionDispatch::TestProcessをincludeすることでfixture_file_uploadを使えるようになる。
これでActionDispatch::Http::UploadedFileを(フォームからファイルアップロードした際に作られるオブジェクト)を擬似的に扱う。

4.バリデーション追加
# app/validators/content_type_validator.rb
class ContentTypeValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.nil?
    return if options[:allow_content_type].include?(value)

    record.errors.add(attribute, 'はファイルの種類に問題があります')
  end
end
# app/validators/size_limit_validator.rb
class SizeLimitValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.nil?
    return if options[:limit] >= value.size

    record.errors.add(attribute, "のファイルサイズは#{convert_size(options[:limit], value)}以下にしてください")
  end

  def convert_size(limit_size, value)
    return convert_kb(limit_size) if 100.kilobyte >= value.size
    return convert_mb(limit_size) if 100.megabyte >= value.size

    convert_gb(limit_size)
  end

  def convert_kb(limit_size)
    "#{(limit_size / (2 ** 10).to_f).round}KB"
  end

  def convert_mb(limit_size)
    "#{(limit_size / (2 ** 20).to_f).round}MB"
  end

  def convert_gb(limit_size)
    "#{(byte_size / (2 ** 30).to_f).round}GB"
  end
end

ContentTypeとファイルサイズのカスタムバリデータを作成する。
モデルに書くよりもカスタムバリデータとして個別に実装した方が見やすいと思うので、こちらに実装。

5.モデルの実装
# app/models/Image.rb
class Image < ApplicationRecord
  ALLOW_CONTENT_TYPE = ['image/jpeg', 'image/png']

  belongs_to :user

  attr_accessor :upload_file

  before_validation -> { self.data = upload_file.read }, if: -> { upload_file }
  before_validation -> { self.content_type = upload_file.content_type }, if: -> { upload_file }

  validates :data, presence: true, size_limit: { limit: 10.megabyte }
  validates :content_type, presence: true, content_type: { allow_content_type: ALLOW_CONTENT_TYPE }
end
6.テスト実行
$ rails test test/model/image_test.rb

Running via Spring preloader in process 57608
Run options: --seed 3934

# Running:

..

Finished in 0.689962s, 2.8987 runs/s, 5.7974 assertions/s.
2 runs, 4 assertions, 0 failures, 0 errors, 0 skips
7.コントローラーの作成
class ImagesController < ApplicationController
  def new
     @image = Image.new
  end

  def create
     @image = Image.new(image_params)
     if @image.save
        # 省略
     else
        # 省略
     end
  end

  private
     def image_params
        params.require(:image).permit(:upload_file)
     end
end
8.ビューの作成(フォーム)
<%= form_with model: @image do |f| %>
   <%= f.label :upload_file %>
   <%= f.file_field :upload_file %>
   <%= f.submit %>
<% end %>
9.画像表示用のメソッド追加
# app/models/Image.rb
class Image < ApplicationRecord
  ALLOW_CONTENT_TYPE = ['image/jpeg', 'image/png']

  belongs_to :user

  attr_accessor :upload_file

  before_validation -> { self.data = upload_file.read }, if: -> { upload_file }
  before_validation -> { self.content_type = upload_file.content_type }, if: -> { upload_file }

  validates :data, presence: true, size_limit: { limit: 10.megabyte }
  validates :content_type, presence: true, content_type: { allow_content_type: ALLOW_CONTENT_TYPE }

  # 追加
  def image_url
    "data:#{content_type};base64,#{Base64.encode64(image)}"
  end
end

画像のインライン用のフォーマットを作成する。
バイナリデータをBase64エンコードすることで作成可能。

10.ビューの作成(画像表示)
<%= image_tag @image.image_url %>
おまけ fixtures
# test/fixtures/images.yml
image_1:
  user: user_1
  image: !binary <%= Base64.strict_encode64(File.binread(Rails.root.join('test', 'fixtures', 'files', 'valid_image_1.jpg').to_s)) %>
  content_type: image/jpeg
  uploaded_at: 2020-05-25 15:07:23

バイナリデータ突っ込むの地味に苦戦した...

後書き

DBにバイナリデータを保存する形での画像アップロードの実装方法についてでした。
send_dataで画像のエンドポイントを作るパターンもありましたが、手間だったのでdata URIを直接メソッドで実装してしみました。
インライン画像にする場合のメリットとしては

  • httpリクエストが減る

デメリットとしては

  • データ容量が増える(約40%増)
  • キャッシュされない

あたりがあります。

今回はデータへのアクセス制限をRails内で完結したいということでパフォーマンス目的での実装じゃないのでその辺りはあまり考慮していません。
ただ、ライブラリ等無しでも案外簡単にやれたので、さすがRailsという感じ。