[Rails6]CarrierWaveを基本に忠実に使ってみる~キャッシュ・S3編~

Rails

その1のアプリを元に動作を確認していく。

ファイルの保存場所

デフォルト(特に指定しない場合)では、[アプリルート]/public/uploads直下になっているが、Uploaderのstore_dirで設定可能。genelaterでUploaderを作成すると、下記のようになる。

# ImageUploader.rb

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

public配下には保存したくない場合は、”../uploads/{model.class.to_s.underscore}/#{mounted_as}/#{model.id}” や、carrierwaveのルートフォルダをpublicから別のところに変更することで、保存可能。

保存可能だが、ただUploaderを変更するだけだと、保存した画像を表示する時にRouting Errorでファイルにアクセスができないために、表示ができなくなる。

# エラーログ

ActionController::RoutingError (No route matches [GET] "/home/ec2-user/environment/yuruli-carrierwave-app/uploads/user/profile_image/1/ファイル名"):

アクセスを許可するために、ルーティングを設定して、コントローラー用意して、表示させることは可能だと思うが、実運用的には別のストレージにアップロードされたファイルを保存するのが一般的だと思う。自前でサーバーを立てたりすれば、アプリのあるサーバーにそのまま保存することもあると思うが。

キャッシュの保存場所

デフォルトのcacheは、[アプリルート]/public/uploads/tmp直下になっている。ファイルの保存と同様に、Uploadeで指定可能。cache_dirメソッドを利用する。

下記の設定だと、[アプリルート]/public/uploads/cache/[モデル名]/[カラム名]/[id]直下に保存される。

# ImageUploader.rb

  def cache_dir
    "uploads/cache/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

クーロンなどのバッチで、定期的に消してあげるのがいい。

[アプリルート]/tmp以下にキャッシュを置くようにすると、ファイルの保存時と同じようにルーティングエラーが発生してキャッシュから画像を取得できなくなる。

# エラーログ

ActionController::RoutingError (No route matches [GET] "/home/ec2-user/environment/yuruli-carrierwave-app/tmp/uploads/cache/user/profile_image/1/1581420241-91499288490884-0007-6329/P9250119.JPG"):

キャッシュが保存されるタイミング

今回(Uploaderのstorageがfile)の場合は、Uploaderがマウントされたカラムにイメージファイルが代入されるタイミングで保存される。

ただ、CarrierWaveの基本1の記事で書いたような基本的なレンダリングの場合、一発で保存成功した場合は、キャッシュは作成されない。アップロード後にバリデーションエラーなどで再レンダリングされる場合は、キャッシュが作成される。

以下の例は、rails6のscaffoldで作成したusers_controllerのupdateメソッド。メソッド内の最初の2行をコメントアウトをしながら検証。

# users_controller.rb

  def update
    @user.assign_attributes(user_params) # キャッシュ作成される
    @user.profile_image = params[:user][:profile_image] # キャッシュ作成される
    
    respond_to do |format|
      if @user.update(user_params) # 成功時はキャッシュ作成されないが、失敗時は作成される
        format.html { redirect_to @user, notice: 'User was successfully updated.' }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  private
     def user_params
      params.require(:user).permit(:name, :profile_image, :profile_image_cache, :remove_profile_image)
    end

キャッシュがcarrierwave経由で保存されると、[カラム名]_cache(例:@user.profile_image_cache)に値がセットされている状態になり(キャッシュの保存とカラムへのセットどちらが先かは不明)、hiddenで[カラム名]_cacheをviewでセットしておくことで、バリデーションエラー等でエラー前にアップロードした画像を保持できる。

AWS S3に保存する

gemを追加して、bundle install

# Gemfile

gem 'fog-aws'

S3の接続情報を設定ファイルに記述する。config/initializers/carrierwave.rb がなければ作成して記述。application.rbでもいいはず(未検証)。

業務で扱っているソースには、config.fog_provider = ‘fog/aws’ を書いてあるが、今回の検証では不要だった。CarrierWaveかRailsのバージョンで不要になったのかもしれない。

aws_access_key_idやaws_secret_access_keyは IAM > ユーザー > 認証情報 > アクセスキーから確認できる。secret access keyは以前に作成したアクセスキーは画面からだと確認できない。作成時点でCSVをダウンロードしていれば、そのCSVを確認することができる。CSVもなければ、新しいアクセスキーを生成するしかない。

# config/initializers/carrierwave.rb

CarrierWave.configure do |config|
  config.fog_credentials = {
    provider:              'AWS',                        # required
    aws_access_key_id:     'XXX',                        # required unless using use_iam_profile
    aws_secret_access_key: 'YYY',                        # required unless using use_iam_profile
    region:                'ap-northeast-1',             # optional, defaults to 'us-east-1'
  }
  config.fog_directory  = 'バケット名'                      # required
end

Uploaderでfog利用を指定する。store_dirやcache_dirは、冒頭の説明のまま。

# app/uploaders/image_uploader.rb

  storage :fog  # ここが重要

  def store_dir
    "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end
  
  def cache_dir
    "uploads/cache/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}"
  end

上記の設定で起動してみる

my_image.jpgを保存する
文字が小さいがs3に保存されている

S3から画像を取得できている

S3にファイルがあるかを確認する。バケット名をルートとして、store_dirで指定した場所に画像が保存されていることがわかる。

キャッシュについても同様にcache_dirに指定した場所に保存されていた。(写真は割愛)

S3をパブリックアクセスを受け付けない状態でアクセスする

fog_publicをfalseにする。

fog_publicはデフォルトでtrueになっているのでfalseにすると、アクセスできるようになる。ファイルを取得する時は、通常通り[カラム名].url でurlにアクセスする。
そのURLを見ると、パラメータがいくつか付与されており、どうやら有効期限つきでアクセスできるようにしている。
デフォルトの有効期限は、60分。変更したい場合は、fog_authenticated_url_expirationの設定を追加する。

# config/initializers/carrierwave.rb

CarrierWave.configure do |config|
  config.fog_public     = false # デフォルトはtrue
  config.fog_authenticated_url_expiration = 600 # 単位は秒。デフォルトは600秒。
end

[補足]キャッシュ保存周りのソースを追って見た

カラムに保存される時にキャッシュを生成というのが気になったのでソースを追ってみた。デバッグをした訳ではなくGitHub上でソースをおっただけなので、理解が正しいかも定かではないので、正確性については間違っているかもなので、あしからず。

まずは、代入時の記述を発見。

# carrierwave/lib/carrierwave/orm/activerecord.rb
  
  def #{column}=(new_file)
    column = _mounter(:#{column}).serialization_column
    if !(new_file.blank? && __send__(:#{column}).blank?)
      __send__(:"\#{column}_will_change!")
    end
    super
  end
# carrierwave/lib/carrierwave/mount.rb

  def #{column}=(new_file)
    _mounter(:#{column}).cache([new_file])
  end

_mounter.cache(new_files)

# carrierwave/lib/carrierwave/mounter.rb

  def cache(new_files)
    return if !new_files.is_a?(Array) && new_files.blank?
    old_uploaders = uploaders
    @uploaders = new_files.map do |new_file|
      handle_error do
        if new_file.is_a?(String)
          if (uploader = old_uploaders.detect { |uploader| uploader.identifier == new_file })
            uploader.staged = true
            uploader
          else
            begin
              uploader = blank_uploader
              uploader.retrieve_from_cache!(new_file)
              uploader
            rescue CarrierWave::InvalidParameter
              nil
            end
          end
        else
          uploader = blank_uploader
          uploader.cache!(new_file)
          uploader
        end
      end
    end.compact
  end

uploader.cache! (new_file) メソッドでキャッシュを生成してそう。

そういえば、業務中にキャッシュを生成するために cache! というメソッドを使った記憶があり、キャッシュを保存するものとして理解していたが、今回、わざわざ記述しなくてもキャッシュの生成まですんなりできることがわかったし、上記のソースでも引数にファイルを渡している。闇雲に使いすぎてたなと反省。

闇雲をクリアにしたいと思い、cache!で動作するメソッドがあるかないかを調査したところ、carrierwave/lib/carrierwave/uploader/cache.rb のcache! (new_file = file) の一箇所該当した。
見落としがなければ、引数なしのメソッドの定義は存在してなかったので、cache!と呼び出した時は、上記のメソッドが呼び出され、fileが引数となる。
このファイルについても少し追って見たが、fog周りのファイルまでしか追えてない。完全な推測だが、storage :file の場合は、機能しないのではないかと思う。

本題に戻り、uploader.cache! (new_file)について調査した。
先ほどのcache.rbとstorage/fog.rb, storage/file.rbの3箇所で定義(実装)されていた。

cache.rbについて、storageがfileの時には、おそらくcache.rbのcache!(new file)が呼ばれる。

# carrierwave/lib/carrierwave/uploader/cache.rb

  def cache!(new_file = file)
    new_file = CarrierWave::SanitizedFile.new(new_file)
    return if new_file.empty?

    raise CarrierWave::FormNotMultipart if new_file.is_path? && ensure_multipart_form

    self.cache_id = CarrierWave.generate_cache_id unless cache_id

    @staged = true
    @filename = new_file.filename
    self.original_filename = new_file.filename

    begin
      # first, create a workfile on which we perform processings
      if move_to_cache
        @file = new_file.move_to(File.expand_path(workfile_path, root), permissions, directory_permissions)
      else
        @file = new_file.copy_to(File.expand_path(workfile_path, root), permissions, directory_permissions)
      end

      with_callbacks(:cache, @file) do
        @file = cache_storage.cache!(@file)
      end
    ensure
      FileUtils.rm_rf(workfile_path(''))
    end
  end

def cache!(new_file = file)の初期値のfileについて

# carrierwave/lib/carrierwave/storage/fog.rb

  ##
  # lookup file
  #
  # === Returns
  #
  # [Fog::#{provider}::File] file data from remote service
  #
  def file
    @file ||= directory.files.head(path)
  end

  # directoryメソッドについて。これ以上は追っていないが、ファイル名と処理からして明らかにfog利用時。
  ##
  # local reference to directory containing file
  #
  # === Returns
  #
  # [Fog::#{provider}::Directory] containing directory
  #
  def directory
    @directory ||= begin
      connection.directories.new(
        :key    => @uploader.fog_directory,
        :public => @uploader.fog_public
      )
    end
  end

cache.rbで呼ばれているnew_file.copy_toを追う。(デフォルトでmove_to_fileはfalse)

# carrierwave/lib/carrierwave/sanitized_file.rb
  
  ##
  # Creates a copy of this file and moves it to the given path. Returns the copy.
  #
  # === Parameters
  #
  # [new_path (String)] The path where the file should be copied to.
  # [permissions (Integer)] permissions to set on the copy
  # [directory_permissions (Integer)] permissions to set on created directories.
  #
  # === Returns
  #
  # @return [CarrierWave::SanitizedFile] the location where the file will be stored.
  #
  def copy_to(new_path, permissions=nil, directory_permissions=nil)
    return if self.empty?
    new_path = File.expand_path(new_path)
  
    mkdir!(new_path, directory_permissions)
    copy!(new_path)
    chmod!(new_path, permissions)
    self.class.new({:tempfile => new_path, :content_type => content_type})
  end

mkdir!やcopy!は実際にOSのコマンドを実行しているので、ここで指定したキャッシュフォルダにファイルを生成している。

これ以上深くは追っていないが、いかにも外部のストレージへ接続しに行っている。

storageフォルダ以下のcache!(new_file)について

抽象クラスの方は、実装してねと書いてあるだけ。

# carrierwave/lib/carrierwave/storage/abstract.rb  
  def cache!(new_file)
    raise NotImplementedError.new("Need to implement #cache! if you want to use #{self.class.name} as a cache storage.")
  end

実装クラスの方は、file.rbの方は、Uploaderのキャッシュのパスにファイルを移動しているようなことをしている。

# carrierwave/lib/carrierwave/storage/file.rb
  
  ##
  # Stores given file to cache directory.
  #
  # === Parameters
  #
  # [new_file (File, IOString, Tempfile)] any kind of file object
  #
  # === Returns
  #
  # [CarrierWave::SanitizedFile] a sanitized file
  #
  def cache!(new_file)
   new_file.move_to(::File.expand_path(uploader.cache_path, uploader.root), uploader.permissions, uploader.directory_permissions, true) 
  rescue Errno::EMLINK, Errno::ENOSPC => e
   raise(e) if @cache_called
   @cache_called = true
  
   # NOTE: Remove cached files older than 10 minutes
   clean_cache!(600)
  
   cache!(new_file)
  end

fogの場合は、保存しているような処理が書いてある??これより先は力尽きたのでまたいつか調べる。

# carrierwave/lib/carrierwave/storage/fog.rb
  
  ##
  # Stores given file to cache directory.
  #
  # === Parameters
  #
  # [new_file (File, IOString, Tempfile)] any kind of file object
  #
  # === Returns
  #
  # [CarrierWave::SanitizedFile] a sanitized file
  #
  def cache!(new_file)
    f = CarrierWave::Storage::Fog::File.new(uploader, self, uploader.cache_path)
    f.store(new_file)
    f
  end

new_fileというのがいくつか出てくるが、carrierwave/lib/carrierwave/sanitized_file.rb だと思って良さそう。それ以外だとfog.rb内のFileクラス。

SanitizedFileは、すべてに共通のAPIを提供する基本クラスです さまざまな風変わりなRubyファイルライブラリ。 Tempfileをサポートしています。 ファイル、StringIO、Merbスタイルのアップロードハッシュ、および次のパス 文字列とパス名。 おそらく不必要に包括的かつ複雑です。ヘルプは大歓迎です。

sanitized_file.rb のコメントアウトをGoogle翻訳したもの。

キャッシュまでの大まかな流れ

ブラウザでファイルをアップロードして、Submitボタンをクリックする

サーバーの/tmpフォルダに保存される

CarrierWaveのUploaderがマウントされたカラムにファイルが代入される

キャッシュをtmpファイルからコピーされる

コメント