Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: 個人のサービスをRails7にアップグレードしたのでやったこととかメモ📝

2021/12/15 Rails 7がリリースされました🎉

rubyonrails.org

さっそく個人のサービスをRails 6.1.4.4 -> Rails 7にアップグレードしたのでやったこととかメモ📝

Rails 7の主要な対応

While most Rails applications won’t need a dependency on Node given these defaults, we’ve still managed to also dramatically improve the integration story for those who do in Rails 7.

import_mapによりnode依存を解消!

madogiwa0124.hatenablog.com

また以下の大きな新機能 ※ActiveRecordで属性の暗号化、SQLコメントで実行元をトレース、非同期のeagar_loading

  • At-Work Encryption With Active Record
  • Trace Query Origins With Marginalia-Style Tagging
  • Asynchronous Query Loading
  • Zeitwerk Exclusively

新しいブランドサイトもカッコいいですね👍

rubyonrails.org

引用元:https://rubyonrails.org/2021/12/15/Rails-7-fulfilling-a-vision

Rails 7へのアップグレード手順

Rails以外のgemのバージョンアップ

まだRails 7に対応してない可能性があるので、まずはRails以外のgemを最新に上げときます。

Railsのバージョンアップ

上記でrails以外のgemが最新になっているはずなのでbundle update railsでバージョンをあげます。

bundle update時にRails 7に対応してないgemが見つかった場合は、masterに向ける OR 対応のPRが出ていれば、そのbranchに向けたりすることも検討します。

Upgrade Guideを読む

edgeguides.rubyonrails.org

上記を読んで自身のサービスに影響がありそうなところを把握します。

自分のサービスだと以下の変更はキャッシュ及び暗号化された値の読み込みに影響がありそうでしたが、 今回のサービスではキャッシュは無くなってもいいのと暗号化された値も暗号化されたクッキーが読めず、 ログインセッションが切れる等が発生しても問題ないので一旦そのまま入れることにしました。

  • 3.10 Key generator digest class changing to use SHA256
  • 3.12 New ActiveSupport::Cache serialization format

リリースノートにも目を通しておきます。

edgeguides.rubyonrails.org

RailsDiffで設定ファイルまわりの更新を取り込む

RailsDiff is about what you'd have to change about your app's configuration when upgrading Rails versions, not about what Rails has changed internally. https://railsdiff.org/

バージョンごとの設定ファイルの差分を閲覧出来るRailsDiffというサービスを使って差分を確認しつつ、反映が必要そうな箇所を修正していきます。

https://railsdiff.org/6.1.4.4/7.0.0

bin/rails app:updateを使っても良いですが、オーバーライドの良し悪しとかの判断がCLIの対話形式だとやりにくいと感じたので上記方法を選択しています。

正常に動作することを確認する

最後に既存のテストコードが通ることや、実際に動かしてみて動作することを確認できればOKです🎉

アップグレード時に発生したエラーとか

missing require leading to uninitialized constant ActiveSupport::XmlMini::IsolatedExecutionState

テスト実行時にmissing require leading to uninitialized constant ActiveSupport::XmlMini::IsolatedExecutionStateが発生したため、 ActiveSupportをrequireするようにしました。

# config/application.rb

require 'active_support'

module MyApp
  class Application < Rails::Application

github.com

その他気づいたこと

redirect_toでopen redirectの検証がされるようになった

redirect_toでopen redirectの検証がデフォルトで入るようになったようです。

By default, Rails protects against redirecting to external hosts for your app's safety, so called open redirects. Note: this was a new default in Rails 7.0, after upgrading opt-in by uncommenting the line with raise_on_open_redirects in config/initializers/new_framework_defaults_7_0.rb https://api.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to

従来どおりの挙動にする場合は引数allow_other_hostにtrueを渡す。

redirect_to "https://rubyonrails.org", allow_other_host: true

ActiveRecord::Core#strict_loading!が追加されてた

今までGlobalで設定されていたsrict_loadingを無効化したいケースのためにmasterから実装を持ってきてパッチを当ててたのですが、publicなメソッドとして追加されてました。

Sets the record to strict_loading mode. This will raise an error if the record tries to lazily load an association. Parameters:

  • value - Boolean specifying whether to enable or disable strict loading.
  • mode - Symbol specifying strict loading mode. Defaults to :all. Using

https://api.rubyonrails.org/classes/ActiveRecord/Core.html#method-i-strict_loading-21

modeも指定出来るようになっていて、念願のn_plus_one_onlyも指定出来るのですが、globalでn_plus_one_onlyに指定する方法が分からなかった。。。(strict_loading_modeはdefaultでallで、read_attributesになっていた。。。)

madogiwa0124.hatenablog.com

参考

inside.pixiv.blog

Ruby on Rails: 開発環境込みでcircleci/rubyからcimg/rubyに乗り換えるメモ

今までCircleCI及び開発環境でcircleci/rubynode-browserのイメージを利用していた*1のですが、下記ということでcimg/rubyに乗り換えることにしました。

プレフィックスが「 circleci / 」のレガシーイメージは、 2021 年 12 月 31 日に廃止されます。 ビルドを高速化するには、次世代の CircleCI イメージを使ってプロジェクトをアップグレードしてください。 https://circleci.com/docs/ja/2.0/circleci-images/

開発環境を含めた乗り換え方法とかをメモ📝

開発環境をcircleci/rubyからcimg/rubyに乗り換える

circleci/rubynode-browserのイメージからcimg/rubyに乗り換える際にはcimg/rubybrowserのイメージに乗り換えます。

ブラウザー ブラウザー バリアントのベースは元の Ruby イメージと同一ですが、こちらでは apt により Node.js、JavaSeleniumブラウザーの依存関係が事前インストールされます。 https://circleci.com/developer/ja/images/image/cimg/ruby#variants

単純に利用するimageを変更するだけだったら話が早いのですが、cimg/rubybrowserのイメージではデフォルトでブラウザ及びドライバーがインストールされません。

このバリアントは、CircleCI Browser-Tools Orb と組み合わせて使用する想定で設計されています。 この Orb を使用すると、任意のバージョンの Google ChromeFirefox のいずれかまたは両方をビルドでインストールできます。 https://circleci.com/developer/ja/images/image/cimg/ruby#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%83%BC

CI環境ではorbsが提供されているので、それを利用すればいいのですが、開発環境では自分でインストールする必要があります。

今回は以下のようなDockerfileを用意してcimg/rubybrowserのイメージに追加でChrome及びChrome Driverをインストールするようにしました。

# == base
FROM cimg/ruby:3.0.3-browsers

# instal stable chrome / chrome driver
RUN wget https://dl.google.com/linux/linux_signing_key.pub &&\
    sudo apt-key add linux_signing_key.pub &&\
    echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | sudo tee /etc/apt/sources.list.d/google-chrome.list &&\
    sudo apt -y update && sudo apt-get -y install google-chrome-stable
RUN CHROME_LATEST_VERSION=$(curl -sS "omahaproxy.appspot.com/linux?channel=stable") &&\
    CHROME_LATEST_MAJOR_VERSION=$(echo $CHROME_LATEST_VERSION | cut -d . -f 1) &&\
    CHROME_DRIVER_VERSION=$(curl -sS "chromedriver.storage.googleapis.com/LATEST_RELEASE_${CHROME_LATEST_MAJOR_VERSION}") &&\
    wget https://chromedriver.storage.googleapis.com/${CHROME_DRIVER_VERSION}/chromedriver_linux64.zip &&\
    unzip chromedriver_linux64.zip &&\
    sudo mv chromedriver /usr/bin/chromedriver &&\
    sudo chown root:root /usr/bin/chromedriver &&\
    sudo chmod +x /usr/bin/chromedriver

上記のDockerfileでcircleci/rubynode-browserのイメージと同様にheadless chromeを利用したE2Eテストが行えるようになりました✨

CI環境をcircleci/rubyからcimg/rubyに乗り換える

以下の通りcimg/rubybrowserイメージは、CircleCI Browser-Tools Orbを利用するとcircleci上で簡単に任意のブラウザ環境を立ち上げることができます。

このバリアントは、CircleCI Browser-Tools Orb と組み合わせて使用する想定で設計されています。 この Orb を使用すると、任意のバージョンの Google ChromeFirefox のいずれかまたは両方をビルドでインストールできます。 https://circleci.com/developer/ja/images/image/cimg/ruby#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%83%BC

※以下は一部を抜粋したものです。orbscircleci/browser-toolsを追加して、E2Eテストの実行前にbrowser-tools/install-chromebrowser-tools/install-chromedriverを実行すれば良い。

version: 2.1

orbs:
  ruby: circleci/ruby@1.2.0
  node: circleci/node@4.0.0
  browser-tools: circleci/browser-tools@1.2.3

web: &web
  - image: cimg/ruby:3.0.3-browsers
    environment:
      <<: *web-default-enviroments

db: &db
  - image: circleci/postgres:10.13
    environment:
      POSTGRES_USER: circleci
      POSTGRES_PASSWORD: password

executors:
  web:
    docker:
      - <<: *web
  web-db:
    docker:
      - <<: *web
      - <<: *db
jobs:
  ruby_test:
    executor:
      name: web-db
    steps:
      - attach_current
      - install_ruby_deps
      - install_node_deps
      - browser-tools/install-chrome
      - browser-tools/install-chromedriver
      - rails_migration
      - build_webpack
      - run:
          name: run tests
          command: bundle exec rspec spec/

circleci/browser-toolsの詳細は以下

circleci.com

参考

tecadmin.net

dev.classmethod.jp

blog.toshimaru.net

*1:こちらを参考に良い感じに依存ライブラリ等がデフォルトでインストールされていて便利だったので。

Ruby on Rails: Controllerのaction内に分岐を持たずにredirectと処理の続行を切り替える実装をしたいときのメモ

Controller内でaction内に分岐を持たずに特定条件でredirectと処理の続行を切り替えるみたいなことをやりたいときに、単純にredirect処理をprivate methodに移すみたいなやり方だと結局アクションに戻ってしまい上手いこと行かないので、ちょっと悩んだのですがブロックを使うといい感じに実装出来た気がしたのでメモ📝

サンプル実装

サンプルとして簡単にですが、なにかしらの品物に対してユーザーが支払った金額が一致していた場合に購入するような以下のようなコントローラーがあったとします。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    return redirect_to root_path unless @payment.amount == @item.price

    current_user.buy!(item: @item, payment: @payment)
    redirect_to thanks_path
  end
end

この処理がもりもりになってきて、不正な金額を検知してリダイレクトするような処理はアクションの外に切り出したいという想定で色々考えてみます。

🙅‍♀️ 単純にprivate methodに判定とリダイレクト処理を移す

単純にprivate methodに判定とリダイレクト処理を移しても、あくまでreturn先createアクションなので購入処理を中断することは出来ないのでだめです。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    prevent_illegal_payment

    current_user.buy!(item: @item, payment: @payment)
    redirect_to thanks_path
  end

  private

  def prevent_illegal_payment
    return redirect_to root_path unless @payment.amount == @item.price
  end
end

🤔 例外処理を使ってリダイレクトする

例外を使うみたいなやり方もありますが、ちょっと大げさすぎる気も。。。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    prevent_illegal_payment!
    current_user.buy!(item: item)
    redirect_to thanks_path
  rescue IllegalPayment
    redirect_to root_path
  end

  private

  def prevent_illegal_payment!
    raise IllegalPayment unless @payment.amount == @item.price
  end
end

😀 ブロックを使う

このようなケースだと、ブロックを使ってyieldで実行するようにするといい感じにaction内に分岐を持たずに書ける。

class PurchasesController < ApplicationRecord
  def create
    @item = Item.find(item_params[:item_id])
    @payment = current_user.payments.where(item: item).last
    prevent_illegal_payment do
      current_user.buy!(item: @item, payment: @payment)
      redirect_to thanks_path
    end
  end

  private

  def prevent_illegal_payment
    if @payment.amount == @item.price
      yield
    else
      redirect_to root_path
    end
  end
end

おわりに

Rubyはいろんな書き方出来て便利ですね✨

参考

docs.ruby-lang.org

Ruby on Rails: Capybaraを使ったシステムテストでHTTPリクエストを送信する方法

Capybaraを使ったシステムテストで何かしらHTTPリクエストを送信したいときに色々ハマったのでメモしておきます📝

システムテストについてはこちら

railsguides.jp

リクエストを送る方法

以下を行うとシステムテスト内でリクエストを送信することができます。

  • current_driver:rack_testに変更
  • allow_forgery_protectionfalseに変更

上記を行うHelperみたいなのを用意してあげると便利そう(?)

module SendRequestHelper
  def send_request(method, path, params={})
    use_rack_driver do
      disable_forgery_protection do
        page.driver.send(method, path, params)
      end
    end
  end

  private

  def use_rack_driver
    driver = Capybara.current_driver
    Capybara.current_driver = :rack_test
    yield
    Capybara.current_driver = driver
  end

  def disable_forgery_protection
    csrf_protection = ActionController::Base.allow_forgery_protection
    ActionController::Base.allow_forgery_protection = false
    yield
    ActionController::Base.allow_forgery_protection = csrf_protection
  end
end

# パラメータ付きで"/foo"にPOST
send_request(:post, foo_path, { foo: 'foo'})

対応が必要な理由

current_driver:rack_testに変更

デフォルトで利用されるCapybara::Selenium::Driverだとpostgetといったリクエストを送信するようなメソッドが実装されていませんが、

www.rubydoc.info

しかしCapybara::RackTest::Driverの場合はpostgetといったリクエストを送信するようなメソッドが実装されているので、current_driverをリクエストを送信出来るCapybara::RackTest::Driverに切り替えることで任意のタイミングでリクエストを送信出来るということですね。

www.rubydoc.info

※なお、認証状態といったものをRackTestとSelenium間で引き継ぐようなことは出来ないようです。(多分、Sessionが別になるため)

allow_forgery_protectionfalseに変更

直接リクエストを送る際にCSRFの保護が有効になっているとトークンがリクエストに含まれていない場合に、422 Unprocessable Entityが発生してしまいます。

そのためリクエスト送信時にはActionController::Base.allow_forgery_protectionfalseに変更しときます。

Railsのテスト環境ではデフォルトで無効になっているのでやらなくても良いかも

github.com

参考

qiita.com

poketo7878-dev.hatenablog.com

今更ながらActiveStorageの仕組みとか使い方とかメモ📝

今更ながらActiveStrageの仕組みとか使い方とかを動かしながらちょっと勉強したのでメモ📝

ActiveStorageとは

Active Storageは、Amazon S3Google Cloud Storage、Microsoft Azure Storageなどのクラウドストレージサービスへのファイルのアップロードや、ファイルをActive Recordオブジェクトにアタッチする機能を提供します。 Active Storage の概要 - Railsガイド

Rails 5.2で導入された簡単にファイルアップデート/参照が実装出来る機能です。

※2021/11/20現在、6.1.4のガイドよりも以下のedgeガイドのほうが内容がかなり充実しているので、edgeガイドを参照したほうが良いきがした。

edgeguides.rubyonrails.org

ActiveStrageの基本的な使い方

使い方は簡単でapplication.rbでコア機能がrequireされていることを確認し、

require "active_storage/engine"

利用するサービスに合わせてstorage.ymlを作成

local:
  service: Disk
  root: <%= Rails.root.join("storage") %>

amazon:
  service: S3
  access_key_id: ""
  secret_access_key: ""
  bucket: ""
  region: "" # e.g. 'us-east-1'

各種環境に利用するストレージサービスを設定

# development.rb
Rails.application.configure do
  config.active_storage.service = :local
end

以下のコマンドを実行し依存するtableを作成すれば準備OKです。

$ bin/rails active_storage:install

※S3等のクラウドストレージを利用する場合には対象のgemを、ダイレクトアップロードが必要な場合は、activestorage - npm、画像変換等を行う場合には、libvips等を適宜installしてください。詳しくはガイドを御覧ください。

あとは該当のmodelに画像添付用のDSLを記載することで、

class User < ApplicationRecord
  has_one_attached :avatar
  # 複数添付する場合は以下
  # has_many_attached :avatars
end

以下のような感じで画像のアップロードを参照を行う事ができます。

class UsersController < ApplicationController
  def new
    @user = User.new
  end

  def show
    @user = User.find(params[:id])
  end

  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to user_path(@user)
    else
      render :new
    end
  end

  private

  def user_params
    params.require(:user).permit(:email, :avatar)
  end
end
<!-- users/new -->
<div class="user-new">
  <%= form_with model: @user, method: :post, url: users_path do |f| %>
    <%= f.text_field :email %>
    <%= f.file_field :avatar %>
    <%= f.submit %>
  <% end %>
</div>


<!-- users/show -->
<%= @user.email %>
<%= image_tag url_for(@user.avatar) %>

ActiveStrageの仕組み

データ構造

ActiveStrageは以下の3つのtableを利用します。

  • active_storage_attachments : アタッチ対象のテーブルとblobとのリレーションを定義
  • active_storage_blobs: ファイル本体を表すテーブル
  • (active_storage_variant_records) : 変換処理等に使用するが大枠には関与しないため一旦今回の記載では扱わない

それぞれの関係は以下の通りです

アタッチ対象のtable 1-N active_storage_attachments 1-1 active_storage_blobs

inspectで見るとどういうデータが格納されるのか分かりやすいです

$ User.last.avatar
#<ActiveStorage::Attached::One:0x00007f918fae33f0
 @name="avatar",
 @record=
  #<User:0x00007f918d780868
   id: 2,
   email: "mail_234@example.com",
   secret_digest: "[FILTERED]",
   verified: true,
   created_at: Sat, 20 Nov 2021 15:01:04.745761000 JST +09:00,
   updated_at: Sat, 20 Nov 2021 15:03:49.002289000 JST +09:00>>

$ User.last.avatar.blob
#<ActiveStorage::Blob:0x00007f9191124470
 id: 1,
 key: "q71m490swybfjgnqpto4egnl1s86",
 filename: "sprite.png",
 content_type: "image/png",
 metadata: {"identified"=>true, "analyzed"=>true},
 service_name: "local",
 byte_size: 1655,
 checksum: "CSG/eyd0ZL4dTU/ccUUEYA==",
 created_at: Sat, 20 Nov 2021 15:01:04.787685000 JST +09:00>

ファイルのアップロードの流れ

実際にアップロードの流れを追ってみます。

まずはmodelにhas_one_attached使うと、定義したname(例: avator)で以下が定義されます。

  • getter/setter
  • active_storage_attachmentsとactive_storage_blobsのassociation
  • active_storage_attachments, active_storage_blobsをeager lodingするscope
  • 以下のcallback
    • after_save: attachment_changesにアップロード・削除等用のClassインスタンスを格納
    • after_commit: attachment_changesから該当のkeyで取得し、uploadが実行できれば実行

github.com

実際にアップロード処理を行なっている処理をみてみます。以下のコードの通り基本的には添付ファイルをもとにActiveStorage::Blobインスタンスを作成し、serviceのupload処理を呼び出しています。

# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/attached/changes/create_one.rb#L23
module ActiveStorage
  class Attached::Changes::CreateOne # :nodoc:
    def upload
      case attachable # アタッチ対象
      # 普通にformからファイルをアップロードする場合は`ActionDispatch::Http::UploadedFile`になる。
      when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
        # ActiveStorage::Blobのインスタンスを生成し`upload_without_unfurling`を実行
        blob.upload_without_unfurling(attachable.open) 
      when Hash
        blob.upload_without_unfurling(attachable.fetch(:io))
      end
    end

# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/app/models/active_storage/blob.rb#L246-L248
class ActiveStorage::Blob < ActiveStorage::Record
  def upload_without_unfurling(io) # :nodoc:
    service.upload key, io, checksum: checksum, **service_metadata
  end

各サービスのupload処理を実行してファイルを格納します。

# Disk
# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/service/disk_service.rb#L19
module ActiveStorage
  class Service::DiskService < Service
    def upload(key, io, checksum: nil, **)
      instrument :upload, key: key, checksum: checksum do
        IO.copy_stream(io, make_path_for(key)) # keyで指定したfolderにファイルを作成する
        ensure_integrity_of(key, checksum) if checksum
      end
    end

# S3
# https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activestorage/lib/active_storage/service/s3_service.rb
module ActiveStorage
  class Service::S3Service < Service
    attr_reader :client, :bucket
    attr_reader :multipart_upload_threshold, :upload_options

    def initialize(bucket:, upload: {}, public: false, **options)
      @client = Aws::S3::Resource.new(**options)
      @bucket = @client.bucket(bucket)

      @multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes
      @public = public

      @upload_options = upload
      @upload_options[:acl] = "public-read" if public?
    end

    def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, **)
      instrument :upload, key: key, checksum: checksum do
        content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename

        if io.size < multipart_upload_threshold
          # keyで指定bucketのobjectを作成しPUT
          upload_with_single_part key, io, checksum: checksum, content_type: content_type, content_disposition: content_disposition
        else
          upload_with_multipart key, io, content_type: content_type, content_disposition: content_disposition
        end
      end
    end

ファイルの参照の流れ

url_for

参照する際にはurl_forActiveStorage::Attachedインスタンスを渡すとデフォルトでは以下のようなURLが帰ってきます。

http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--27f4ef7c0fa6e180d8384cbb4a408db03d58a344/sprite.png

上記のURLはroutes上以下の定義となっており、active_storage/blobs/redirect#showが呼ばれます。

rails_service_blob_path  GET /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) 
active_storage/blobs/redirect#show

active_storage/blobs/redirect#showでは@blob.urlにリダイレクトします。 ※リダイレクトを回避するには後述のrails_storage_proxy_pathを参照

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/active_storage/blobs/redirect_controller.rb
class ActiveStorage::Blobs::RedirectController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    expires_in ActiveStorage.service_urls_expire_in # デフォルト5分
    redirect_to @blob.url(disposition: params[:disposition])
  end
end

ActiveStorage::Blob#urlはserviceのurlを呼び出します。

class ActiveStorage::Blob < ActiveStorage::Record
  def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
    service.url key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
      content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, **options
  end

下記の通りpublicオプションに応じて、各serviceのpublic_url/private_urlが呼ばれます。

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service.rb#L112-L125
module ActiveStorage
  class Service
    def url(key, **options)
      instrument :url, key: key do |payload|
        generated_url =
          if public?
            public_url(key, **options)
          else
            private_url(key, **options)
          end

        payload[:url] = generated_url

        generated_url
      end
    end

各サービスではpublic_url/private_urlの実装は以下のとおりです。どちらでも最終的にはURLの文字列が返却されてファイルを取得出来るというわけですね。

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/disk_service.rb#L104-L110
# Disk
# file名をもとにrails_disk_service_pathが呼ばれる。(ex: http://localhost:3000/rails/active_storage/disk/eyJfcmFpbH...281/sprite.png)
module ActiveStorage
  class Service::DiskService < 
      def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
        generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)
      end

      def public_url(key, filename:, content_type: nil, disposition: :attachment, **)
        generate_url(key, expires_in: nil, filename: filename, content_type: content_type, disposition: disposition)
      end

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rbhttps://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rb#L99-L107
# S3
# private_url: keyをもとに取得したobjectの署名付きURL(期限付き)で取得される。
# https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
# public_url : keyをもとに取得したobjectの公開URLが取得される。
module ActiveStorage
  class Service::S3Service < Service
      def private_url(key, expires_in:, filename:, disposition:, content_type:, **client_opts)
        object_for(key).presigned_url :get, expires_in: expires_in.to_i,
          response_content_disposition: content_disposition_with(type: disposition, filename: filename),
          response_content_type: content_type, **client_opts
      end

      def public_url(key, **client_opts)
        object_for(key).public_url(**client_opts)
      end

rails_storage_proxy_path

またRails 6.1で追加されたrails_storage_proxy_path(@user.avatar)を使用するとurl_forで発生していたリダイレクト処理を防ぐことが出来ます。処理としては以下の通りactive_storage/blobs/proxy#showが実行されます。

rails_service_blob_proxy_path    GET /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format)    
active_storage/blobs/proxy#show

active_storage/blobs/proxy#showはblobのダウンロードが実行され、各サービスで定義されたdownloadが呼ばれてファイルがダウンロードされ、データがブラウザに送られます。 ※リダイレクトは発生しないが、直接ファイルを閲覧できるURLが生成されるため注意

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/active_storage/blobs/proxy_controller.rb

class ActiveStorage::Blobs::ProxyController < ActiveStorage::BaseController
  include ActiveStorage::SetBlob

  def show
    if request.headers["Range"].present?
      send_blob_byte_range_data @blob, request.headers["Range"]
    else
      http_cache_forever public: true do
        response.headers["Accept-Ranges"] = "bytes"
        response.headers["Content-Length"] = @blob.byte_size.to_s

        send_blob_stream @blob
      end
    end
  end

# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/controllers/concerns/active_storage/streaming.rb#L60
module ActiveStorage::
    def send_blob_stream(blob, disposition: nil) # :doc:
      send_stream(
          filename: blob.filename.sanitized,
          disposition: blob.forced_disposition_for_serving || disposition || DEFAULT_BLOB_STREAMING_DISPOSITION,
          type: blob.content_type_for_serving) do |stream|
        blob.download do |chunk| # blobのダウンロードを実行
          stream.write chunk
        end
      end
    end


# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/app/models/active_storage/blob.rb#L252
class ActiveStorage::Blob < ActiveStorage::
  def download(&block)
    service.download key, &block
  end

# Disk
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/disk_service.rb#L26-L38
module ActiveStorage
  class Service::DiskService < Service
    def download(key, &block)
      if block_given?
        instrument :streaming_download, key: key do
          stream key, &block
        end
      else
        instrument :download, key: key do
          File.binread path_for(key) # fileをread
        rescue Errno::ENOENT
          raise ActiveStorage::FileNotFoundError
        end
      end
    end

# S3
# https://github.com/rails/rails/blob/ef65eeef081785e39da717cd9ce7888fa010d217/activestorage/lib/active_storage/service/s3_service.rb
module ActiveStorage
  class Service::S3Service < Service
    def download(key, &block)
      if block_given?
        instrument :streaming_download, key: key do
          stream(key, &block)
        end
      else
        instrument :download, key: key do
          object_for(key).get.body.string.force_encoding(Encoding::BINARY) # keyからobjectを取得しgetしてbodyを取得
        rescue Aws::S3::Errors::NoSuchKey
          raise ActiveStorage::FileNotFoundError
        end
      end
    end

CDNからファイルを配信するときのtipsは以下に記載されています。 https://edgeguides.rubyonrails.org/active_storage_overview.html#putting-a-cdn-in-front-of-active-storage

おわりに

ActiveStrageでファイルをアップロードするとてっきりアップロード先のURLを取得しストレージサービスに直接リクエストが行くのかと思っていたのですが、一度Railsを経由して配信されるんですねー(知らなかった)

確かに一度Railsを経由することでS3のprivateなバケットからファイルを取得して公開等が出来るんですね。

Railsでリクエストを受け付ける点は注意ですが、ActiveStrage便利ですね✨

参考

blog.takeyuweb.co.jp

https://edgeguides.rubyonrails.org/active_storage_overview.htm

Ruby: `standardrb`を最近使ったりしてるので使い感とかをメモ

RubyのLinterといえばrubocopだと思うのですが、個人のツールとかだと最近standardrbを使うことが多いので使い感とかをメモしておきます📝

standardrbとは?

github.com

StandardJSインスパイのRubyのLinterです。

This gem is a spiritual port of StandardJS and aims to save you (and others!) time in the same three ways:

  • No configuration. The easiest way to enforce consistent style in your project. Just drop it in.
  • Automatically format code. Just run standardrb --fix and say goodbye to messy or inconsistent code.
  • Catch style issues & programmer errors early. Save precious code review time by eliminating back-and-forth between reviewer & contributor.

記載の通り、rubocopのように.rubocop.ymlに毎回無効にするルールと記載するといった対応が不要で手軽に使えるのと、自動修正といった機能も備えています。

standardrbの使い方

基本的にはstandardrbをGemfileに追加し、bundle installを実行するだけで準備OKです。

あとは以下を実行することで静的解析を行う事ができます。

$ bundle exec standardrb

また設定不要で使えますが、特定のファイルは静的解析の対象外としたい場合には.standard.ymlを用意してあげます。

ignore:
  - 'some/file/in/particular.rb'
  - 'a/whole/directory/**/*'

おわりに

rubocopだと、以下のような日本語だと絶対に引っかかる特定のルールをまずは無効化するみたいなことをするのですが、そういうのがいらないのと、スタイル周りとか厳しすぎずストレスの感じにくい、いい感じのルールが自動で設定してくれるのがいいなぁと思っています。

standardrbはrubocopをもとに作られているのみたいですね、rubocopをベースにしているルールは以下で確認できそうです。

github.com

参考

qiita.com

Ruby on Rails: js-routesを使ってフロントエンドでもRoute系のHelperを使用するMEMO

フロントエンド側から非同期でRails側にリクエストを投げたいときとかに今までは以下のような定数を集めたmoduleをフロントエンド側に用意してたりしていたのですが、

export const BLOG_API_ENDPOINT = "/api/blogs";
export const LIKE_API_ENDPOINT = "/api/likes";

js-routesというgemを使うと便利だったので使い方とかをメモしておきます📝

github.com

js-routesを導入してフロントエンド用のroutesを生成する

使い方は簡単でjs-routesGemfileに追加して

group :development do
  gem "js-routes"
end

bundle installを実行後、以下のコマンドを実行してroutes.jsおよびroutes.d.tsを生成するだけです。TypeScriptを使用しない場合は、JsRoutes.definitions!の実行は不要です。

$ bin/rails c
JsRoutes.generate! # routes.js
JsRoutes.definitions! # routes.d.ts

※READMEを見る限りrake taskも用意されているのですが、なぜかrake -Tで表示されなかった。。。

js-routesで生成したroutesを利用する

あとは簡単でimportして以下のように使用する事ができます。

import { api_blogs_path } from "@js/routes";

import Client from "@js/services/Client";

export async function postBlog(params: object): Promise<PostBlogResponse> {
  const responseData: unknown = (await Client.post(api_blogs_path(), { blog: params })).data;
  return responseData as PostBlogResponse;
}

globalにroutesが使えるようにするにはapplication.js内に以下を追加すれば良いようです。

import * as Routes from 'routes';
window.Routes = Routes;

その他

生成ファイルがGitHub上のdiffで展開されないように.gitattributesに以下のような感じで追加してあげても便利そうかなと思いました。

# Mark the js-routes generated files.
app/javascript/routes.js
app/javascript/routes.d.ts

あとはDangerとかでroutes.rbの変更があったらPRのコメントとかで更新がもれないように通知とかしてあげても良さそう👀

github.com

参考

qiita.com