【Rails】PostgreSQLのバイナリ型で画像を扱う
経緯
- 画像のアップロード機能を追加したい
- アップロードしたデータはアップロードしたユーザー以外にみられたくない
- S3などの外部ストレージに保存したくない
これらの条件を考えた時に、バイナリ型でDBに突っ込むのが一番手取り早いと思いこの実装にいたる。
また、アップローダー系のGemは外部ストレージに送る場合でない限りさほどメリットがなさそうだったので、バリデーションも自前で作る。
環境
- Rails 6.0.2.2
- Ruby 2.6.6
- PostgreSQL 9.6.15
概要
実装
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という感じ。