0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パーフェクトRuby on Rails 5章 メモ・雑感

Posted at

Rails標準の機能を活用して素早く機能実装する

雑感

この章はRailsの組み込みライブラリについての内容でした。初見の機能も多く、個人的にはなかなかヘビィな内容でしたが、非同期処理やメール送信機能など、かなり実務に近い内容になってきている印象です。以下、不具合の解消法なども交えた忘備録です。

Active Jobによる非同期実行

ジョブの例外処理

  • Active Jobはジョブ実行時に発生した例外をキャッチして対応を変える仕組みが用意されている
  • retry_onはジョブのリトライを実行。ただし、Sidekiqなどバックエンド側でリトライの仕組みを持っていることがあるので二重実行に注意が必要
app/jobs/async_log_job.rb
class AsyncLogJob < ApplicationJob
  
  # ジョブのリトライ(wait: 待ち時間, attempts: リトライ回数)
  retry_on StandardError, wait: 5.seconds, attempts: 3
  # 複数の例外を指定することも可能
  retry_on ZeroDivisionError, TypeError, wait: 10.seconds, attempts: 3
end
  • discard_onはジョブの破棄を実行
app/jobs/async_log_job.rb
class AsyncLogJob < ApplicationJob

  # ジョブの破棄
  discard_on StandardError
  # ブロックを使うことも可能
  # 引数にジョブオブジェクトと例外オブジェクトを受け取れる
  discard on StandardError do |job, error|
    SomeNotifier.push_error(error) # 通知を送る
  end
end

5-2 Active Storageによるファイルアップロード

Active Strageのセットアップ方法

  • Active StrageはRails組み込みのファイルアップロード機能
  • 機能を使うためには以下のコマンドの実行が必要
$ bin/rails active_storage:install
  • モデルの生成時にActive Storage用の属性をattachmentオプションで用意する。これにより生成されたモデルファイルには属性が追加される。なお、この属性はテーブルのカラムとしては追加されない
rails generate model User name:string portrait:attachment
app/models/user.rb
class User < ApplicationRecord
  validates :name, presence: true
  has_one_attached :portrait # Active Strage用の属性
end
  • デフォルトだとファイルの保存先はローカルのファイルシステムとなる。S3などのクラウドストレージを利用する際は以下のファイルを編集する
config/storage.yml
test:
  service: Disk
  root: <%= Rails.root.join("tmp/storage") %>

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

# S3の設定の雛形はデフォルトでコメントアウトされた状態で用意されている
# Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
# amazon:
#   service: S3
#   access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
#   secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
#   region: us-east-1
#   bucket: your_own_bucket


# ミラーリング用の設定
# 一度に複数のストレージにファイルアップロードしたい場合はこちらを利用する
# mirror:
#   service: Mirror
#   primary: local
#   mirrors: [ amazon, google, microsoft ]
config/environments/development.rb
Rails.application.configure do
  # Store uploaded files on the local file system (see config/storage.yml for options).
  # デフォルトはローカルストレージを利用
  config.active_storage.service = :local

  # Active Storageのサービスを上記ファイルのS3に設定変更する場合
  config.active_storage.service = :amazon
end

Active Strageの追加機能

サムネイル生成

  • アップロードした画像を自動でリサイズし、サムネイルを作成して表示することができる。機能を利用するためには以下のコマンドを実行し、リンク表示箇所を修正する
Gemfile
# 以下がコメントアウトされているので解除しておく
# Use Active Storage variant
gem 'image_processing', '~> 1.2'
$ bundle install
$ brew install imagemagick <-- macOSの場合
app/views/users/show.html.erb
<p>
  <strong>Portrait:</strong>
  <%# <%= link_to @user.portrait.filename, @user.portrait if @user.portrait.attached? %>
  <%= image_tag @user.portrait.variant(resize_to_limit: [100, 100]) %>
</p>
  • 上記まではImageMagickを利用しているが、画像形式が限定される代わりによりメモリ消費量が少なく実行速度が速いlibvipsというライブラリもサポートしている以下の設定で利用できる
$ brew install vips
config/application.rb
module PortraitsUploader
  class Application < Rails::Application
    
    # vipsを使うための設定
    config.active_storage.variant_processor = :vips
  end
end

ダイレクトアップロード

  • ダイレクトアップロードにも対応しており、サーバーを経由しないことでアップロード時間の短縮やサーバーの負荷軽減につながる。以下の設定で利用可能
app/javascript/packs/application.js
// Rails6.0以上はデフォルトで有効化
require("@rails/activestorage").start()
app/views/users/_form.html.erb
<div class="field">
  <%= form.label :portrait %>
  <%# <%=  form.file_field :portrait %>
  <%# 複数ファイルをアップロードしたい場合は multiple: true に設定 %>
  <%= form.file_field :portrait, direct_upload: true %>
</div>

Active Strageの問題点

validationヘルパーの不足

例えば、画像のアップローダーに動画がアップされた場合などは、バリデーションエラーとしたいがそのためのヘルパーメソッドが存在しない。自分で実装するかactive_storage_validationsgemを利用する必要がある

cacheの不足

画像をアップロードした後にフォームの入力でバリデーションエラーとなった場合、画像をキャッシュできず、フォームの再入力とともに画像も再アップする必要がある

ActionMailerによるメール送信

Action Mailerのセットアップ

  • Action MailerはRails組み込みのメール送信機能
  • Action Mailerで使用するファイルはmailerコマンドで生成できる
    • 生成されるファイル
      • メイラークラスの定義:
        app/mailers/user_mailer.rb
      • プレビューの作成:
        test/mailers/previews/user_mailer_preview.rb
      • テスト:
        test/mailers/user_mailer_test.rb
$ bin/rails g mailer UserMailer
  • 上記コマンドで生成したapp/mailers/user_mailer.rbファイルの内容を編集し対応するビューファイルをapp/views/user_mailer/ディレクトリ配下に実装することでメール送信機能を作ることができる
app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
+ # ユーザー登録完了時のメール送信機能
+ def welcome
+   @name = params[:name]
+   # mailメソッドでHTML/textのテンプレートを探してメールを作成
+   mail(to: params[:to], subject: '登録完了') 
+ end
end

以下の2種類のフォーマットを作成し、受信側で選んで表示させることができる

app/views/user_mailer/welcome.html.erb
<p> <%= @name %></p>
<p> ユーザー登録が完了しました。</p>
app/views/user_mailer/welcome.text.erb
<%= @name %> 様
ユーザー登録が完了しました。
  • デフォルトの送信元メールアドレスはすべてのメイラークラスの継承元のApplicationMailerで設定
app/mailers/user_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: 'ここに任意の送信元アドレスを記述する'
  layout 'mailer'
end
  • 作成したメイラーとビューの動作による文面作成はプレビュー機能で確認できる。test/mailers/user_mailer_test.rbを編集し、localhost:3000/rails/mailers/user_mailer/welcomeへとアクセスすることで確認できる
test/mailers/previews/user_mailer_preview.rb
# Preview all emails at http://localhost:3000/rails/mailers/user_mailer
class UserMailerPreview < ActionMailer::Preview
+  def welcome
+    UserMailer.with(to: "igarashi@example.com", name: "igaiga").welcome
+  end
end
  • 実際にメールを送信する場合は以下のようにdeliver_xxxメソッドを利用する
    • deliver_now: 同期的に送信
    • delilver_later(wait: x.minutes): x分後に送信(wait untilにすると送信日時を指定)
app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)

    respond_to do |format|
      if @user.save
        # ユーザー登録完了時にメールを送信
        UserMailer.with(to: @user.email, name: @user.name).welcome.deliver_now
        format.html { redirect_to @user, notice: "User was successfully created." }
        format.json { render :show, status: :created, location: @user }
      else

end
  • メール送信の動作を開発環境で確認するには以下の環境設定ファイルを編集する。以下の場合は、メール送信を含むアクションをトリガーするとtmp/mail配下にファイルが作成されてその末尾に内容が追記される
config/environments/development.rb
Rails.application.configure do
  
  config.action_mailer.delivery_method = :file # メールをファイルとして保存
  # ファイル保存先を指定
  # config.action_mailer.file_settings = { location: Rails.root.join('log/mail') } 
  # 指定しない場合は、tmp/mail 以下に保存される
end
  • 実際にメールを送信するためにはSMTPサーバを用意する必要がある。本書では外部サーバとしてSendGridを利用している

本書の内容通りに進めていくとうまく設定ができなかったので、SendGridの認証設定を最新の推奨方式(APIキー認証)に変更して進めました。設定方法は以下です。

手順:
1. SendGridのダッシュボードにログイン
2. Settings → API Keys → Create API Key
3. API Keyを生成(Full Access または Restricted Access で Mail Send の権限を付与)
4. 生成されたAPIキーをコピーして、上記の【SendGridで生成したAPIキー】の部分に貼り付け

なお、認証情報の登録に関してもエディタを開くために追加のオプションが必要でした。以下はvscodeでエディタを開く方法です。編集してxで閉じると最下部のメッセージが出力され編集が完了します。

$ EDITOR="code --wait" bin/rails credentials:edit
略
File encrypted and saved.

認証情報の編集内容は以下です。

# aws:
#   access_key_id: 123
#   secret_access_key: 345

# Used as the base secret for all MessageVerifiers in Rails, including the one protecting cookies.
secret_key_base: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
sendgrid:
  api_key: 【SendGridで生成したAPIキー】
  • SMTPへ接続するためには以下ファイルを編集する
config/environments/development.rb
Rails.application.configure do
  
  config.action_mailer.delivery_method = :smtp # SMTPサーバを利用
  config.action_mailer.smtp_settings = {
  user_name: 'apikey',
  password: Rails.application.credentials.dig(:sendgrid, :api_key),
  domain: '送信元に設定したアドレスの@以下の文字列',
  address: 'smtp.sendgrid.net',
  port: 587,
  authentication: :plain,
  enable_starttls_auto: true
}
end
  • 単体テストは最初に生成したtest/mailers/配下のファイルで行う

Action Mailboxによるメール受信

Action Mailboxのセットアップ

  • Action Mailboxはメールを受信した時に処理を行う機能を提供する組み込みライブラリ。各メールサービス、サーバと協調して動作する。対応サービスはRailsガイドを参照
  • 機能を使うためには以下のコマンドを使用。受信メールのメタ情報を保存するmigrationファイルも生成されるのでマイグレーションも同時に実行する
$ bin/rails action_mailbox:install
$ bin/rails db:migrate
  • コマンド実行後、環境設定は以下のようにする
config/environments/production.rb
Rails.application.configure do
  
  # Sendgridを使う設定
  config.action_mailbox.ingress = :sendgrid
end

認証情報は以下のように追加する

$ EDITOR="code --wait" bin/rails credentials:edit
action_mailbox:
  ingress_password: メールサービスのパスワード

本書の内容だとここからさらにSendGridの設定でDomain Authentication設定を行うように記述されていて、メール受信用のドメインが必要とされています。ですが、本書の内容を進める範囲に限ってはこの設定は行わなくても問題ありませんでした。私はこの辺りの設定で少し詰まってしまったので補足のために追記いたします。

Action Mailboxの実装

  • 受信メールに対しての処理はActive Jobによる非同期処理キューに登録することで行われる。キューのジョブは以下の設定で各処理に振り分けられる
app/mailboxes/application_mailbox.rb
class ApplicationMailbox < ActionMailbox::Base
  # 判断条件 => 振り分け先
  # 複数マッチする場合は最初にマッチした処理を実行
  routing (/[0-9]+-comment@/i) => :comments # CommentsMailboxクラスへ処理を振り分け
  # routing -> (inbound_email) { inbound_email.mail.to.size > 2 } => :multiple_recipients
  # routing CustomAddress.new => :custom
  routing :all => :backstop
end
  • 振り分けられたメールを処理するMailboxクラスは以下のように実装する
$bin/rails g mailbox comments
app/mailboxes/comments_mailbox.rb
class CommentsMailbox < ApplicationMailbox
+  before_processing :validate_request
+
+  # メールの内容をコメントとして保存する処理
+  def process
+    board.comments.create!(body: mail.decoded, creator: commenter)
+  end
+
+  # リクエストのバリデーション
+  def validate_request
+    return if commenter && board
+
+    # 引数で受け取ったActionMailerオブジェクトに対してdeliver_laterメソッドを実行し、かつInboundMailの状態をbouncedに変更
+    # bounced状態の時はprocessメソッドが実行されない
+    bounce_with CommentMailer.invalid_request(
+      inbound_email, commenter: commenter, board: board
+    )
+  end

+  # コメント投稿者となるオブジェクトを取得
+  def commenter
+    return @commenter if defined?(@commenter)
+    @commenter = User.find_by(email: mail.from)
+  end

+  # 書き込み先Boardオブジェクトを取得
+  def board
+    return @board if defined?(@board)
+    @board = Board.find_by(id: mail.to.split('-')[0])
+  end
end

メール受信時の処理を確認する開発支援用Web UI

  • Action Mailboxには実際にメールを送信しなくてもメール受信時の処理を確認するための開発用のWeb UIが用意されている。下記のURLにアクセスするとメールの送受信をシミュレートできる
    http://localhost:3000/rails/conductor/action_mailbox

Action Textによるリッチテキスト機能

Action Textのセットアップ

  • Action Textはドラッグ&ドロップによるファイルアップロードなどにも対応したリッチテキスト機能
  • 機能を利用するためには以下のコマンドを実行する。また、Active Storageの機能を利用するため、同様にimage_processingImageMagickなどのライブラリもインストールする必要がある(上記参照)
$ bin/rails action_text:install
$ bin/rails db:migrate

Action Textの実装

  • 実装したいモデルファイルにリッチテキストを属性として定義する
app/models/message.rb
class Message < ApplicationRecord
+  has_rich_text :content # contentという名前で属性を定義
end

ビューテンプレートでrich_text_helperを利用するとcontent編集用のフィールドが表示される

app/views/messages/_form.html.erb
<%= form_with(model: message, local: true) do |form| %>+  <div class="field">
+    <%= form.label :content %>
+    <%= form.rich_text_area :content %>
+  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>

コントローラでは送信されるリッチテキストを許可するように設定

app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  
  def message_params
+    params.require(:message).permit(:content)
  end
end
  • 保存したリッチテキストはモデルの属性として利用できる
app/views/messages/show.html.erb
<p id="notice"><%= notice %></p>
+ <%= @message.content %>
<%= link_to 'Edit', edit_message_path(@message) %> |
<%= link_to 'Back', messages_path %>

Action TextにおけるN+1問題の対処

  • meesage.contentのようにリッチテキスト属性にアクセスする場合、一見モデルのカラムを参照しているように見えるが実際にはActionText::RichTextというモデルのテーブルへアクセスしている。つまり、その都度クエリが発行されるのでN+1問題が発生することになる。専用のeager loadメソッドが用意されているのでそれで対処できる
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  # GET /messages or /messages.json
  def index
-    @messages = Message.all # N+1問題が発生する
+    @messages = Message.with_rich_text_content # eager_loadを使ってN+1問題を解消
  end
end

Action Cableによるリアルタイム通信

Action CableはWebSocketを使用したチャット機能などのリアルタイム処理を提供する組み込みライブラリ

Action Cableのセットアップ

Action CableのセットアップにはJSファイルの修正も含まれるのでrails sに加えてbin/webpack-dev-serverも起動しておくとスムーズに開発が進められる

  • 以下のchannelサブコマンドで必要ファイルを生成できる
    • 生成されるファイル
      app/channels/room_channel.rb: サーバーサイド
      app/javascript/channels/room_channel.js: クライアントサイド
$ bin/rails g channel room speak
  • ファイルを生成した状態でrails sしてページにアクセスして以下のメッセージがサーバログに出力すればWebSocketの接続が確認できたことになる
$ bin/rails s
略
RoomChannel is streaming from room_channel

ビューの実装

app/views/rooms/show.html.erb
<h1>Chat room</h1>
<div id="messages">
  <!-- 部分テンプレートを呼び出してインスタンス変数の内容をレンダリング -->
  <%= render @messages %>
  <!-- 以下と同等 -->
  <%# <%= render partial: "message", collection: @messages %>
</div>

<form>
  <label>Say something:</label><br>
  <input type="text" data-behavior="room_speaker">
</form>
app/views/messages/_message.html.erb
<div class="message">
  <p><%= message.content %></p>
</div>

サーバサイドの実装

app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  # 購読後に呼ばれる
  def subscribed
+    stream_from "room_channel" # ブロードキャスト用のチャンネル名を指定
  end

  # 購読解除後に呼ばれる
  def unsubscribed
  end

  # クライアントがチャットメッセージを送信したときに実行される
  def speak(data)
+    message = Message.create!(content: data["message"])
+    # 生成したメッセージを部分テンプレートでHTMLに変換して、room_channelにブロードキャスト
+    ActionCable.server.broadcast(
+      "room_channel", { message: render_message(message) }
+    )
  end

+  private
+
+  # 部分テンプレートでメッセージのHTMLを生成
+  def render_message(message)
+    # ApplicationController.renderでコントローラーを介さずに部分テンプレ+ートをレンダリング
+    ApplicationController.render(
+      partial: "messages/message",
+      locals: { message: message }
+    )
+  end
end

クライアントサイドの実装

app/javascript/channels/room_channel.js
import consumer from "./consumer"

consumer.subscriptions.create("RoomChannel", {
  connected() { // 接続時
+    // ユーザーが入力フィールドでEnterキーを押すと、その内容がサーバーに送信される
+    document.
+      querySelector('input[data-behavior=room_speaker]').
+      addEventListener('keypress', (event) => {
+        if (event.key === 'Enter') {
+          this.speak(event.target.value);
+          event.target.value = '';
+          return event.preventDefault();
+        }
+      });
  },

  disconnected() { // 切断時
  },

  received(data) { // サーバーからのメッセージ受信時
+    const element = document.querySelector('#messages');
+    // DOM操作でサーバーからのメッセージをHTMLに追加
+    element.insertAdjacentHTML('beforeend', data['message']); 
  },

  speak: function(message) {
+    // サーバーに speak メソッドを実行するように伝える
+    return this.perform('speak', {message: message}); 
  }
});

本番で運用する場合のAction Cableの設定

アダプタを設定

以下のファイルでWebsocketの接続管理を行うアダプターとアダプターへの接続先を指定する

config/cable.yml
development:
  adapter: async # インメモリで接続情報を管理

test:
  adapter: test

production:
  adapter: redis # 本番では複数の接続情報をを管理するためにredisを使用する
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  channel_prefix: actioncable_sample_production

Action Cableサーバをスタンドアローンで立ち上げる

  • デフォルトだとAction Cable用サーバは/cableへマウントされ、一つのプロセスにWebサーバとWebSocketサーバが混在している
  • RackアプリケーションとしてAction Cable用のサーバプロセスを立ち上げることでスタンドアローンで動作させることができる。以下のファイルを作成してpumaコマンドでサーバを起動させる
cable/config.ru
require_relative "../config/environment"
Rails.application.eager_load!

run ActionCable.server
$ bundle exec puma cable/config.ru

さらに以下の設定をアンコメントしてWebサーバにAction Cableがマウントされているのを解除する

config/environments/production.rb
config.action_cable.mount_path = nil # アンコメントする
  • Action Cable用のサーバを立ち上げてもデフォルトではAction Cableへの接続はlocalhostが指定されている。サブドメインを設定するには以下の設定をアンコメントする
config/environments/production.rb
# アンコメントする
config.action_cable.url = 'wss://example.com/cable' 
# 以下の設定で接続可能なオリジンを制限することも可能
config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ]
  

さらに設定をクライアントサイド全体に反映させるためにビューを修正する。javascript_pack_tagが書かれている行より前にaction_cable_meta_tagを配置する

app/views/layouts/application.html.erb
<%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%# 以下で生成されたタグを見て接続先を判別する %>
    <%= action_cable_meta_tag %> 

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>

Action Cableに認証処理を実装する

コネクションを利用するとWebSocketの認証認可処理を行うことができる。Userモデルでログイン処理を実装した後に以下のファイルに処理を追加する

app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user # current_userメソッドが使えるようになる

    # クライアントが接続した時に呼ばれる
    def connect
      self.current_user = find_verified_user
    end

    private

    def find_verified_user
      if verified_user = User.find_by(id: cookies.signed[:user_id])
        verified_user
      else
        reject_unauthorized_connection # ユーザーが見つからない場合は接続を拒否
      end
    end
  end
end

Action Cableのワーカ数を変更する

WebSocket経由で受け取ったメッセージはAction Cable用のワーカスレッドで処理される。以下の設定でワーカ数を変更できる

config/environments/production.rb
# デフォルトは4つ
# WebとWebSocketを同一プロセスとしている場合は二つのスレッドを掛け合わせた値が必要
# サーバを分離している場合は、Webサーバ用のスレッドを設定
config.action_cable.worker_pool_size = 10

サーバを分離している場合は以下ファイルでWebSocketのワーカ数を設定

config/cable.yml
development:
  adapter: redis
  url: redis://localhost:6379/1
  worker_pool_size: 10 # WebSocketのワーカ数

test:
  adapter: test

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
  worker_pool_size: 10

Action Cableのテスト

チャネルとコネクション、二つのテストを用意する

test/channels/room_channel_test.rb
require "test_helper"

class RoomChannelTest < ActionCable::Channel::TestCase
  test "subscribes" do
    subscribe # 購読作成をシミュレート
    # subscriptionメソッドでRoomChannelオブジェクトを取得し、購読ができたか確認
    assert subscription.confirmed?
    assert_has_stream "room_channel" # ストリームがroom_channelであることを確認
  end

  test 'broadcast' do
    subscribe
    text = "hello"
    # メッセージをHTMLに変換
    broadcast_text = ApplicationController.render(
      partial: "messages/message",
      locals: { message: Message.new(content: text) }
    )
    # performメソッドでクライアントからspeakアクションを呼び出して、ブロードキャストされるか確認
    assert_broadcast_on("room_channel", message: broadcast_text) do
      perform :speak, message: text
    end
  end
end
test/channels/application_cable/connection_test.rb
require "test_helper"

class ApplicationCable::ConnectionTest < ActionCable::Connection::TestCase
  test "connects with accepts" do
    user = User.first
    cookies.signed[:user_id] = user.id
    connect # Connection#connectメソッドを呼び出して、接続を確立
    assert_equal connection.current_user.id, user.id
  end

  test "connection rejects" do
    cookies.signed[:user_id] = nil # ユーザーIDがnilの場合
    assert_reject_connection { connect } # 接続が拒否されることを確認
  end
end
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?